Standard CNC software assumes a Z-axis: when a path ends, the tool lifts and travels to the next starting point. Hot-wire foam cutters don't have this option. The wire stays in contact with the foam at all times, and any unintended movement leaves a visible burn mark.
This makes image-to-GCode conversion for foam cutters a fundamentally different problem. You can't just export paths independently — you need one single unbroken wire path that visits every contour in the image, connecting them at the shortest possible joining points.
CostyCNC solves this with a proprietary greedy algorithm with path rotation, built on top of a modified Potrace library. The result: clean cuts with minimal stray lines, all running in the browser with no installation required.
The core challenge
Given an image with multiple contours (letters, complex shapes), Potrace produces N independent closed paths. For a hot-wire cutter, these must become one continuous uninterrupted wire path.
Each closed path can start from any point in its sequence — it's a cycle. The algorithm exploits this property by rotating each path to start from the point nearest to the current wire position.
This is a practical solution to a variant of the Traveling Salesman Problem applied to orientable closed curves.
The Complete Pipeline
How extract() Works
The extract() function is the heart of CostyCNC. It takes an array
of N closed path arrays and returns a single continuous coordinate sequence
starting from the machine origin (0, 0).
Key insight: A closed path can start from any node in its sequence. By rotating the path array to start at the nearest point, we eliminate the need for long connection moves between contours.
Find the nearest candidate
For every remaining path and every point within it, compute squared Euclidean distance to the current position in the built path. No sqrt() needed — comparison works on squared values.
Rotate the chosen path
Once the best path pat1 and its entry point pos1 are found, the array is rotated: splice(pos1).concat(rest). The wire enters at the closest point.
Close and concatenate
The first point is pushed to the end to close the loop, then the path is spliced into the global path at position pos0.
Adaptive sampling for speed
Searching every point in the growing pathx would be O(n²). Instead, pstep = max(10, len/100) samples it at ~1% intervals, keeping performance acceptable even for 70,000+ point paths.
function extract(costyx) { // Start from machine origin pathx = [[0, 0]]; while (costyx.length) { // Adaptive step: fast on large paths const pstep = Math.max( 10, Math.floor(pathx.length / 100) ); minDiff = Number.MAX_VALUE; // Search pathx (sampled) × all remaining paths for (i = 0; i < pathx.length; i += pstep) { x = pathx[i][0]; y = pathx[i][1]; for (m = 0; m < costyx.length; m++) { path = costyx[m]; for (n = 0; n < path.length; n++) { x2 = x - path[n][0]; y2 = y - path[n][1]; // Squared distance — no sqrt needed currDiff = x2*x2 + y2*y2; if (currDiff < minDiff) { minDiff = currDiff; pos0 = i; // insertion point in pathx pat1 = m; // which path to take pos1 = n; // entry point (rotation) } } } } // ROTATE path to start at nearest point let p = costyx.splice(pat1, 1)[0]; p = p.splice(pos1).concat(p); p.push(p[0]); // close the loop // Insert into global continuous path pathx = pathx .slice(0, pos0) .concat(p, pathx.slice(pos0 - 1)); } return pathx; }
Why Rotation Is the Right Solution
In standard CNC routing, the quality of a "travel move" doesn't matter — the tool is lifted. For hot-wire cutting, every move is a cut. The wire burns whatever foam it touches, whether intentional or not.
This means minimizing total path length isn't enough. What matters is where the wire enters each contour. A long connection from the wrong entry point is worse than a short one from the right one, because the stray burn will appear in the middle of the finished piece.
Path rotation solves this elegantly: instead of jumping from the end of path A to the arbitrary first node of path B (which could be anywhere on the contour), the algorithm finds the node in path B that is physically closest to the current wire position and starts there.
When contours touch or overlap (as in letter shapes), the joining distance frequently drops to zero — meaning the wire naturally continues from one contour into the next with no visible transition.
The rotation property: A closed path [A, B, C, D, E]
can be equivalently represented as [C, D, E, A, B] — same shape,
different start point. The algorithm exploits this to make every path entry
optimal, regardless of how Potrace originally ordered the nodes.
CostyCNC vs Other Approaches
no path rotation
router / laser
Hierholzer's algorithm
greedy + rotation
Why We Modified Potrace
CostyCNC doesn't use Potrace as a black box. The library was modified to expose intermediate path data as JavaScript coordinate arrays, intercepting the flow before SVG serialization.
This gives extract() direct access to the raw closed path arrays
— exactly what it needs to build the optimized continuous path.
The modified getSVG1() function then takes the unified result
and generates GCode instead of SVG.
The integration also handles DPI scaling correctly: the pixel-space coordinates from Potrace are converted to real millimeter dimensions using the image DPI and a user-configurable scale factor.
// Modified Potrace output function // Outputs G01 moves instead of SVG paths function getSVG1(pathx, scale, feedRate, power) { let gcode = ""; // Standard GCode preamble gcode += "G21\n"; // millimeters gcode += "G90\n"; // absolute positioning gcode += "G92 X0 Y0\n"; // set origin gcode += `M3 S${power}\n`; // wire on // Single continuous path — no Z moves for (let i = 0; i < pathx.length; i++) { const x = (pathx[i][0] * scale).toFixed(3); const y = (pathx[i][1] * scale).toFixed(3); gcode += `G01 X${x} Y${y} F${feedRate}\n`; } gcode += "M5\n"; // wire off gcode += "G00 X0 Y0\n"; // return to origin return gcode; }
Try the algorithm live
CostyCNC is free, runs entirely in your browser, and requires no installation. Upload any image and get optimized GCode in seconds.