Resources Numerical accuracy Calculation policy

Laser job paths, fill lines and run estimate

Turns the drawing's shapes into the paths a laser follows: arcs as chords, fill lines across closed shapes, the moves in machine coordinates, and the length and time of the job.

How the precision is kept

Coordinates stay 64-bit from the drawing to the list of moves. An arc is replaced by chords whose sag is at most the machine's tolerance, so that tolerance sets how closely a cut follows a curve. The run time is travel length over speed, without acceleration.

Open item. For shallow arcs, place the chord points from the chord and the sag instead of from the far-away center.

Calculations under this policy

10 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

laser/laser-geom.js CadCraft 0.8.2 64-bit 6 calculations Reviewed

Tests tools/laser/test.mjs

Arcs into chords

64-bit

Replaces each bulge arc of a path by straight chords that stay within a tolerance of the arc.

θ = 4 atan(b); r = chord ÷ (2 sin(|θ| ÷ 2)); steps = ⌈|θ| ÷ (2 acos(1 − min(tol, r) ÷ r))⌉

The arc's own end points are kept as they are; the points between come from the center as center + r × (cos, sin). For a nearly straight arc r and the center are very large, so each of those points is a difference of two large numbers and loses digits. The arc cosine's argument is clamped to [−1, 1].

  • laser/laser-geom.js
  • lines 22 to 42
  • CadCraft 0.8.2
export function flatten(path, closed = true, tol = 0.01) {
  const out = [];
  const n = path.length;
  const last = closed ? n : n - 1;
  for (let i = 0; i < n; i++) {
    const p = path[i];
    out.push({ x: p.x, y: p.y });
    if (i >= last) continue;
    const q = path[(i + 1) % n], b = p.b || 0;
    if (!b) continue;
    const th = 4 * Math.atan(b), ch = Math.hypot(q.x - p.x, q.y - p.y);
    if (ch < 1e-12) continue;
    const r = ch / (2 * Math.sin(Math.abs(th) / 2));
    const steps = Math.max(1, Math.ceil(Math.abs(th) / (2 * Math.acos(Math.max(-1, Math.min(1, 1 - Math.min(tol, r) / r))))));
    const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2, ux = (q.x - p.x) / ch, uy = (q.y - p.y) / ch;
    const d = r * Math.cos(th / 2) * Math.sign(th);
    const cx = mx - uy * d, cy = my + ux * d, a0 = Math.atan2(p.y - cy, p.x - cx);
    for (let k = 1; k < steps; k++) { const a = a0 + th * k / steps; out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); }
  }
  return out;
}

Signed area of a loop

64-bit

The area of a closed path, used to tell which loop is the larger when one lies in another.

A = ½ Σ (xᵢ yᵢ₊₁ − xᵢ₊₁ yᵢ)

The shoelace sum about the drawing origin, added in point order. For a loop far from the origin the terms are large and mostly cancel.

  • laser/laser-geom.js
  • lines 44 to 48
  • CadCraft 0.8.2
export function area(pts) {
  let a = 0;
  for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; a += p.x * q.y - q.x * p.y; }
  return a / 2;
}

Length of a path

64-bit

The total length of a flattened path.

L = Σ √(Δx² + Δy²)

One Math.hypot per segment, added in order. For an arc this is the length of its chords, not of the arc.

  • laser/laser-geom.js
  • lines 54 to 59
  • CadCraft 0.8.2
export function length(pts, closed = false) {
  let L = 0;
  for (let i = 0; i + 1 < pts.length; i++) L += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
  if (closed && pts.length > 1) L += Math.hypot(pts[0].x - pts[pts.length - 1].x, pts[0].y - pts[pts.length - 1].y);
  return L;
}

Which loop lies in which

64-bit

Counts how many other loops contain each loop, so inner shapes are cut first.

two edge midpoints inside the other loop, and the other loop larger in area

A yes-or-no decision from two point-in-polygon tests in plain 64-bit arithmetic, not an exact predicate. It decides cutting order only.

  • laser/laser-geom.js
  • lines 69 to 79
  • CadCraft 0.8.2
export function depths(loops) {
  const polys = loops.map((l) => (l.pts || l));
  return polys.map((p, i) => {
    // a point of p that is not on another loop: its middle edge's midpoint is as good as any; use two probes
    const probe = (k) => { const a = p[k % p.length], b = p[(k + 1) % p.length]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
    const probes = [probe(0), probe(Math.floor(p.length / 2))];
    let n = 0;
    polys.forEach((q, j) => { if (j !== i && probes.every((pt) => inside(pt, q)) && Math.abs(area(q)) > Math.abs(area(p))) n++; });
    return n;
  });
}

Turn into and out of the fill direction

64-bit

Rotates a point by the fill angle so the fill lines are horizontal, and back.

x′ = x cos a − y sin a; y′ = x sin a + y cos a; a in degrees × π ÷ 180

The cosine and sine are computed again for each point. The shape's points are turned into the fill frame and the ends of each fill line are turned back, so a burned end point carries the rounding of both rotations.

  • laser/laser-geom.js
  • line 81
  • CadCraft 0.8.2
export const rotate = (p, deg) => { const c = Math.cos(-deg * DEG), s = Math.sin(-deg * DEG); return { x: p.x * c - p.y * s, y: p.x * s + p.y * c }; };

Fill lines of a closed shape

64-bit

The scan lines that fill the even–odd region of the loops, with the spans to burn on each line.

y_k = (k + 0.5) × interval + offset; x = a_x + (b_x − a_x)(y − a_y) ÷ (b_y − a_y)

Each line's position is computed from its index, not by adding the interval repeatedly. An edge is crossed when exactly one of its ends is at or below the line, so a vertex on the line counts once. Spans shorter than 1e-9 are dropped, an absolute limit.

  • laser/laser-geom.js
  • lines 84 to 108
  • CadCraft 0.8.2
export function hatch(loops, { interval = 0.1, angle = 0, offset = 0 } = {}) {
  const polys = loops.map((l) => (l.pts || l).map((p) => rotate(p, angle))).filter((p) => p.length >= 3);
  if (!polys.length || !(interval > 0)) return [];
  let y0 = Infinity, y1 = -Infinity;
  for (const pl of polys) for (const p of pl) { if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; }
  const out = [];
  const k0 = Math.floor((y0 - offset) / interval - 0.5), k1 = Math.ceil((y1 - offset) / interval - 0.5);
  for (let k = k0; k <= k1; k++) {
    const y = (k + 0.5) * interval + offset;
    if (y < y0 || y > y1) continue;
    const xs = [];
    for (const pl of polys) {
      for (let i = 0; i < pl.length; i++) {
        const a = pl[i], b = pl[(i + 1) % pl.length];
        if ((a.y <= y) !== (b.y <= y)) xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));      // half-open: a vertex on the line counts once
      }
    }
    if (xs.length < 2) continue;
    xs.sort((p, q) => p - q);
    const spans = [];
    for (let i = 0; i + 1 < xs.length; i += 2) if (xs[i + 1] - xs[i] > 1e-9) spans.push([xs[i], xs[i + 1]]);
    if (spans.length) out.push({ y, spans });
  }
  return out;
}
laser/laser-plan.js CadCraft 0.8.2 64-bit 4 calculations Reviewed

Tests tools/laser/test.mjs

Machine coordinates

64-bit

Moves every point so the job's lower left corner is the machine's 0, 0.

x_machine = x − x_origin; y_machine = y − y_origin

One subtraction per coordinate. For a drawing far from its origin the machine coordinate keeps the absolute error of the drawing coordinate.

  • laser/laser-plan.js
  • lines 60 to 61
  • CadCraft 0.8.2
const origin = job.origin === 'drawing' ? { x: 0, y: 0 } : { x: box.x0, y: box.y0 };
const M = (x, y, s, extra = {}) => ({ x: x - origin.x, y: y - origin.y, s, ...extra });

Burn length, travel and time

64-bit

The length burned, the length traveled with the laser off, and the time of the job.

burn = Σ d × passes; time = Σ (d ÷ speed) × 60 × passes

Sums of Math.hypot distances between successive moves, in move order. The time divides by the layer's speed or the machine's rapid speed and does not count acceleration, as the file's header says.

  • laser/laser-plan.js
  • lines 112 to 126
  • CadCraft 0.8.2
const stats = { burn: 0, travel: 0, time: 0, paths: 0, moves: 0, passes: 0 };
for (const op of ops) {
  let px = 0, py = 0;
  for (const m of op.moves) {
    const d = Math.hypot(m.x - px, m.y - py);
    const fast = (m.start && !m.link) || m.rapid;
    if (m.s > 0) { stats.burn += d * op.passes; stats.time += d / op.speed * 60 * op.passes; }
    else if (fast) { stats.travel += d * op.passes; stats.time += d / machine.rapid * 60 * op.passes; }
    else { stats.travel += d * op.passes; stats.time += d / op.speed * 60 * op.passes; }
    if (m.start) stats.paths += op.passes;
    stats.moves += op.passes;
    px = m.x; py = m.y;
  }
  stats.passes += op.passes;
}

Fill lines into moves

64-bit

Turns the fill lines back to drawing coordinates and adds the overscan before and after each line.

start = first − dir × overscan; end = last + dir × overscan

Each span end is rotated back from the fill frame. A gap of 5 mm or more inside a line is crossed as a rapid move.

  • laser/laser-plan.js
  • lines 145 to 169
  • CadCraft 0.8.2
/** Scan lines (rotated frame) → moves in drawing coordinates, boustrophedon when bidirectional. Returns the new head position. */
function scanMoves(moves, lines, angle, l, S, M, head) {
  let dir = 1, prev = null;
  const os = l.overscan;
  for (const line of lines) {
    const spans = dir > 0 ? line.spans : line.spans.slice().reverse().map(([a, b]) => [b, a]);
    const first = spans[0][0], last = spans[spans.length - 1][1];
    const P = (x) => unrotate({ x, y: line.y }, angle);
    const a = P(first - dir * os), a2 = P(first);
    const link = !!(l.bidirectional && prev && Math.hypot(a.x - prev.x, a.y - prev.y) <= RAPID_GAP);
    moves.push(M(a.x, a.y, 0, { start: true, ...(link ? { link: true } : {}) }));
    if (os > 0) moves.push(M(a2.x, a2.y, 0));
    for (let i = 0; i < spans.length; i++) {
      const [xa, xb] = spans[i], pa = P(xa), pb = P(xb);
      if (i > 0) { const pg = P(spans[i - 1][1]); if (Math.hypot(pa.x - pg.x, pa.y - pg.y) >= RAPID_GAP) moves.push(M(pa.x, pa.y, 0, { rapid: true })); else moves.push(M(pa.x, pa.y, 0)); }
      moves.push(M(pb.x, pb.y, S));
    }
    const e = P(last + dir * os);
    if (os > 0) moves.push(M(e.x, e.y, 0));
    prev = os > 0 ? e : P(last);
    head = prev;
    if (l.bidirectional) dir = -dir;
  }
  return head;
}

Picture rows into moves

64-bit

Lays a picture's burn levels on its rectangle as rows of moves with a power for each run.

cols = round(w ÷ interval); dx = w ÷ cols; y = y₀ + h − (r + 0.5) dy; power = min + (max − min) × level ÷ 255

The column width is the picture's width divided by a whole number of columns, so the row pitch is close to but not exactly the interval asked for. Positions are computed from the index.

  • laser/laser-plan.js
  • lines 171 to 202
  • CadCraft 0.8.2
function imageMoves(moves, pic, l, machine, M, head) {
  const cols = Math.max(1, Math.round(pic.w / l.interval)), rows = Math.max(1, Math.round(pic.h / l.interval));
  const vals = resample(pic.gray, pic.pw, pic.ph, cols, rows);
  const lev = dither(vals, cols, rows, { method: l.dither, threshold: l.threshold, gamma: l.gamma, invert: l.invert, levels: l.levels });
  const dx = pic.w / cols, dy = pic.h / rows;
  const sOf = (level) => sValue(machine, l.minPower + (l.power - l.minPower) * level / 255);
  let dir = 1, prev = null;
  for (let r = 0; r < rows; r++) {
    const burn = runs(lev, cols, rows, r).filter((q) => q[2] > 0);
    if (!burn.length) continue;
    const y = pic.y + pic.h - (r + 0.5) * dy;
    const list = dir > 0 ? burn : burn.slice().reverse();
    const seg = (q) => (dir > 0 ? [pic.x + q[0] * dx, pic.x + q[1] * dx] : [pic.x + q[1] * dx, pic.x + q[0] * dx]);
    const [f0] = seg(list[0]), [, l1] = seg(list[list.length - 1]);
    const os = l.overscan;
    const link = !!(l.bidirectional && prev && Math.hypot(f0 - dir * os - prev.x, y - prev.y) <= RAPID_GAP);
    moves.push(M(f0 - dir * os, y, 0, { start: true, ...(link ? { link: true } : {}) }));
    if (os > 0) moves.push(M(f0, y, 0));
    let at = f0;
    for (const q of list) {
      const [xa, xb] = seg(q);
      if (Math.abs(xa - at) > 1e-9) moves.push(M(xa, y, 0, Math.abs(xa - at) >= l.skipGap && l.skipGap > 0 ? { rapid: true } : {}));
      moves.push(M(xb, y, sOf(q[2])));
      at = xb;
    }
    if (os > 0) moves.push(M(l1 + dir * os, y, 0));
    prev = { x: l1 + dir * os, y };
    head = { x: prev.x, y };
    if (l.bidirectional) dir = -dir;
  }
  return head;
}

In detail

Method
Bulge arcs flattened by equal angle steps; shoelace area; even–odd scan-line fill in a frame turned by the fill angle; nearest-neighbor ordering; time = distance ÷ speed.
Number types
64-bit numbers.
Tolerances
Chord sag at most tol (0.01 by default; a job uses its machine's value). A chord shorter than 1e-12 is not an arc. Fill spans shorter than 1e-9 are dropped. The bed check allows 1e-6 mm. All are absolute lengths.
Limits
Curves are cut as chords. The time estimate leaves out acceleration. A very shallow arc has a very large radius, and its chord points are then differences of large numbers.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach