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.
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.
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;
}
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.