costycnc.it — Technical documentation

The Single-Path Problem:
How We Convert Images to
Continuous GCode

Hot-wire foam cutters have no Z-axis. Every wire movement leaves a mark. Here's how CostyCNC solves the fundamental challenge of connecting N independent closed paths into one optimized continuous toolpath.

algorithm: extract() library: modified potrace author: boboaca costel costycnc.it/algorithm

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

INPUT     Raster image (BMP, PNG, JPG) or SVG
POTRACE  Traces black contours → produces N closed Bézier paths
extract() CostyCNC proprietary algorithm
→ Finds nearest path using greedy nearest-neighbor
→ Rotates each closed path to start at the optimal entry point
→ Concatenates all paths into one continuous sequence
→ Adaptive sampling (pstep) for performance on large paths
getSVG1() Converts unified path to G01 GCode
→ Scales with DPI × user factor → real mm dimensions
→ Adds M3, G21, G90, G92, feed rate F and power S
OUTPUT   Single continuous GCode — ready for hot-wire foam cutter

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.

1

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.

2

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.

3

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.

4

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.

extract.js — CostyCNC proprietary
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.

WITHOUT rotation stray burn! WITH rotation (CostyCNC) ≈0 gap single continuous output path start (0,0)

CostyCNC vs Other Approaches

Approach
Result on foam
User effort
Naive nearest-neighbor
no path rotation
Random entry points → visible stray burn lines
Automatic
Standard CNC software
router / laser
Not applicable: assumes Z-axis lift between paths
Requires manual path surgery
Eulerian path
Hierholzer's algorithm
Theoretically optimal, zero stray burns
O(n³) — too slow for browser use
CostyCNC extract() ★
greedy + rotation
Optimized entry points → minimal visible joins
Fully automatic, runs in browser, instant

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.

getSVG1() — GCode output
// 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.