Resources Numerical accuracy Calculation policy

2.5-axis toolpaths

Generates the tool moves for facing, pockets, contours, drilling and chamfering: depth levels, entry helixes and ramps, lead arcs, tabs and hole depths.

How the precision is kept

XY moves follow the exact lines and arcs from the offset code, in 64-bit. Depths are divided evenly between top and bottom with the last level set to the bottom itself. Helical and ramp moves split the height in proportion to the length traveled. Clearance checks along straight links are sampled at fixed spacings.

Calculations under this policy

17 calculations in 1 source file. 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.

cam-paths.js PartCraft 0.8.1 64-bit 17 calculations Reviewed

Tests tools/partcraft-test/test-cam.mjs

Depth levels

64-bit

The heights of the passes between the top and the bottom of a cut.

n = ceil((top − bottom) ÷ stepdown − 1e-9); z_i = top − (top − bottom)(i + 1) ÷ n; last = bottom

Levels are computed from the ends each time, not by repeated subtraction of the stepdown, and the last level is the bottom value itself.

  • cam-paths.js
  • lines 71 to 75
  • PartCraft 0.8.1
function levels(top, bottom, step) {
  if (!(top > bottom + 1e-9)) return [];
  const n = Math.max(1, Math.ceil((top - bottom) / step - 1e-9));
  return Array.from({ length: n }, (_, i) => (i === n - 1 ? bottom : top - ((top - bottom) * (i + 1)) / n));
}

Clearance, retract and cut heights

64-bit

The heights an operation moves at, from the stock top and the operation's settings.

clearance = stock top + clearance; retract = min(clearance, stock top + retract); bottom = geometry bottom + offset

Sums of stored values; nothing is rounded.

  • cam-paths.js
  • lines 77 to 83
  • PartCraft 0.8.1
function heights(op, ctx, geomTop, geomBottom) {
  const p = op.params, st = ctx.model.stock.max[2];
  const top = p.top ?? geomTop ?? st;
  const bottom = (p.bottom ?? geomBottom) + (p.bottom === null ? p.bottomOff : 0);
  const clearance = st + p.clearance;
  return { clearance, retract: Math.min(clearance, st + p.retract), feed: top + p.feedHeight, top, bottom };
}

Arc move with changing height

64-bit

Writes one line or arc as moves, splitting an arc into half turns and sharing the height change among them.

z after a piece = z + (z₁ − z₀) × piece length ÷ arc length; last piece ends at z₁

The final piece's end height is set to z₁ itself, so rounding in the proportional split does not reach the end point.

  • cam-paths.js
  • lines 49 to 61
  • PartCraft 0.8.1
/** One prim at z0 → z1 (lines become G1, arcs G2/G3, split ≤ 180°). */
prim(p, z0, z1, f) {
  if (p.k === 'L') { this.feed(p.e[0], p.e[1], z1, f); return; }
  const parts = O.limitArcs([p]);
  let z = z0;
  const L = O.lengthOf(p);
  for (const q of parts) {
    const zn = parts.length === 1 ? z1 : z + (z1 - z0) * (O.lengthOf(q) / L);
    const end = [q.e[0], q.e[1], q === parts[parts.length - 1] ? z1 : zn];
    if (!this.same(end)) { this.moves.push({ k: 'arc', p: end, c: q.c.slice(), sw: q.sw, f }); this.pos = end; }
    z = zn;
  }
}

Ramp along a path

64-bit

Lowers the tool linearly while it follows a chain of lines and arcs.

z = z₀ + (z₁ − z₀) × length so far ÷ total length

The running length is a sum added in order; the height at each joint is computed from that sum, not by adding height steps.

  • cam-paths.js
  • lines 63 to 68
  • PartCraft 0.8.1
/** Follow prims while z goes linearly from z0 to z1 over their length. */
ramp(prims, z0, z1, f) {
  const L = prims.reduce((s, p) => s + O.lengthOf(p), 0);
  let acc = 0;
  for (const p of prims) { const a = z0 + (z1 - z0) * (acc / L); acc += O.lengthOf(p); this.prim(p, a, z0 + (z1 - z0) * (acc / L), f); }
}

Facing rows

64-bit

The positions of the back-and-forth rows that face the stock.

n = ceil(width ÷ (stepover × d) − 1e-9); row_i = b₀ + (b₁ − b₀) i ÷ n; run from a₀ − R − overhang to a₁ + R + overhang

Rows are evenly spaced between the stock's two sides, each computed from the ends.

  • cam-paths.js
  • lines 188 to 191
  • PartCraft 0.8.1
const step = p.stepover * tool.d;
const n = Math.max(1, Math.ceil((b1 - b0) / step - 1e-9));
const rows = Array.from({ length: n + 1 }, (_, i) => b0 + ((b1 - b0) * i) / n);
const xa = a0 - R - p.overhang, xb = a1 + R + p.overhang;

Pocket offset levels

64-bit

The successive inward offsets a pocket is cleared along.

level k at distance d₀ + k × stepover, until nothing is left

Each level is offset from the original region by d₀ + k × s, not from the previous level, so offset errors do not build up from pass to pass.

  • cam-paths.js
  • lines 274 to 292
  • PartCraft 0.8.1
/**
 * Offset levels d0, d0 + s, … as a tree of components: node {d, loops (outer first, then its holes), children}.
 */
export function offsetTree(region, d0, s) {
  const lv = [];
  for (let k = 0; k < 10000; k++) {
    const ls = O.offsetRegion(region, d0 + k * s);
    if (!ls.length) break;
    lv.push(components(ls, d0 + k * s));
  }
  for (let k = lv.length - 1; k > 0; k--) {
    for (const c of lv[k]) {
      const q = O.pointAt(c.loops[0][0], 0);
      const parent = lv[k - 1].find((pc) => O.inside(pc.loops, q)) || lv[k - 1][0];
      parent.children.push(c);
    }
  }
  return lv[0] || [];
}

Helix entry

64-bit

The half-turn arcs that take the tool down into a pocket at a set ramp angle.

pitch = 2π ρ tan(angle); half turns = ceil((z_from − z) ÷ (pitch ÷ 2) − 1e-9); z after half turn i = z_from − (z_from − z)(i + 1) ÷ n

The number of half turns is rounded up, so the actual ramp angle is at most the one asked for. Heights are computed from the ends for each half turn.

  • cam-paths.js
  • lines 321 to 329
  • PartCraft 0.8.1
const pitch = 2 * Math.PI * rho * Math.tan((p.rampAngle * Math.PI) / 180);
const halves = Math.max(1, Math.ceil((zFrom - z) / (pitch / 2) - 1e-9));
let zz = zFrom;
for (let i = 0; i < halves; i++) {
  const zn = zFrom - ((zFrom - z) * (i + 1)) / halves;
  P.prim(O.arc(start, rho, i % 2 ? Math.PI : 0, Math.PI), zz, zn, sp.ramp);
  zz = zn;
}
P.loop([O.circle(start, rho, true, halves % 2 ? Math.PI : 0)], z, sp.feed);

Ramp entry along a loop

64-bit

How far along the first loop the tool travels while ramping down a level.

length needed = (z_from − z) ÷ tan(angle); whole loops = floor(needed ÷ L); remainder split off the loop

The remainder is needed − turns × L, a subtraction of nearly equal numbers when the length needed is close to a whole number of loops; a remainder below 1e-9 mm is dropped.

  • cam-paths.js
  • lines 336 to 344
  • PartCraft 0.8.1
const need = (zFrom - z) / Math.tan((p.rampAngle * Math.PI) / 180);
const L = O.loopLength(loop);
const turns = Math.floor(need / L);
const prims = [];
for (let i = 0; i < turns; i++) prims.push(...loop);
const rest = need - turns * L;
const tail = rest > 1e-9 ? O.splitAtLengths(loop, [rest])[0] : [];
prims.push(...tail);
P.ramp(prims, zFrom, z, sp.ramp);

Contour offset

64-bit

The path of the tool center round an outline.

d = R + stock to leave; outside: offset by −d (grow); inside: offset by +d

One exact offset of the outline. The cut direction is set by reversing the loops, which does not change their geometry.

  • cam-paths.js
  • lines 392 to 394
  • PartCraft 0.8.1
const d = R + p.leave;
const grow = set.side === 'outside';
let paths = O.offsetRegion(set.loops, grow ? -d : d);

Lead-in and lead-out arcs

64-bit

Quarter-circle arcs that bring the tool onto and off a contour through the air side.

center = start + ρ × left normal; quarter turn ending (or starting) at the contour's start; ρ halved up to 4 times until the arc is in air

The arcs are exact; whether they are clear of material is tested at points sampled along them at a chord tolerance of 0.01 mm. A radius of 0.01 mm or less gives no lead.

  • cam-paths.js
  • lines 455 to 471
  • PartCraft 0.8.1
/** Quarter-circle lead-in/out arcs on the air side (the tool's left: climb puts material on the right). */
function leadArcs(loop, rho, air) {
  for (let k = 0; k < 4 && rho > 0.01; k++, rho /= 2) {
    const P0 = loop[0].s, t0 = O.tangentAt(loop[0], 0);
    const last = loop[loop.length - 1], t1 = O.tangentAt(last, 1);
    const nA0 = [-t0[1], t0[0]], nA1 = [-t1[1], t1[0]];
    const c0 = [P0[0] + rho * nA0[0], P0[1] + rho * nA0[1]];
    const a0 = Math.atan2(P0[1] - c0[1], P0[0] - c0[0]);
    const inn = O.arc(c0, rho, a0 - Math.PI / 2, Math.PI / 2, null, P0);
    const c1 = [P0[0] + rho * nA1[0], P0[1] + rho * nA1[1]];
    const a1 = Math.atan2(P0[1] - c1[1], P0[0] - c1[0]);
    const out = O.arc(c1, rho, a1, Math.PI / 2, P0, null);
    const pts = [...O.samplePrim(inn, 0.01).slice(0, -1), ...O.samplePrim(out, 0.01).slice(1)];
    if (pts.every(air)) return { inn, out };
  }
  return null;
}

Holding tabs

64-bit

Where the tool lifts over tabs that keep the part attached.

tab k centered at (k + ½) L ÷ tabs; lifted over tab width + tool diameter; tab top = bottom + tab height

Tab positions are fractions of the loop length computed from the total each time. The lifted length adds one tool diameter so the tab left standing has the width asked for.

  • cam-paths.js
  • lines 472 to 486
  • PartCraft 0.8.1
function cutWithTabs(P, loop, z, set, h, p, tool, sp) {
  const tabTop = h.bottom + p.tabHeight;
  if (!(p.tabs > 0) || z >= tabTop - 1e-9 || set.side !== 'outside') { P.loop(loop, z, sp.feed); return; }
  const L = O.loopLength(loop), w = p.tabWidth + tool.d;
  if (w * p.tabs >= L) { P.loop(loop, z, sp.feed); return; }
  const cuts = [];
  for (let k = 0; k < p.tabs; k++) { const c = ((k + 0.5) * L) / p.tabs; cuts.push(c - w / 2, c + w / 2); }
  const pieces = O.splitAtLengths(loop, cuts);
  pieces.forEach((pc, i) => {
    const onTab = i % 2 === 1;
    if (!pc.length) return;
    if (onTab) { P.feedZ(tabTop, sp.plunge); P.loop(pc, tabTop, sp.feed); P.feedZ(z, sp.plunge); }
    else P.loop(pc, z, sp.feed);
  });
}

Drill depth and R plane

64-bit

How deep each hole is drilled and the height the drill returns to between holes.

through: z = bottom − breakthrough − (tip length when full diameter is asked); R = min(retract, highest stock or part near the hole + R plane)

The tip length R ÷ tan(half point angle) comes from the tool profile. The hole's top and bottom come from the machine model: the exact edge height when one was within tolerance, otherwise the mesh's.

  • cam-paths.js
  • lines 515 to 519
  • PartCraft 0.8.1
const tip = profile(tool).tip || 0;
const holes = order.map((x) => {
  const bottom = x.through ? x.bottom - p.breakthrough - (p.fullDiameter ? tip : 0) : x.bottom;
  const above = Math.max(x.top, ctx.stockAt(x.c[0], x.c[1], tool.d / 2 + 1));
  return { at: x.c.slice(), top: x.top, z: bottom, r: Math.min(h.retract, above + p.rPlane), q: p.cycle === 'drill' ? 0 : p.peck, ret: 'r' };

Highest point between two holes

64-bit

The highest part or stock under the drill as it moves from one hole to the next.

max of heights at 9 points across the tool, every 1 mm along the move

A sampled check: the move is tested at steps of at most 1 mm and at the center, four axis points and four diagonal points at 0.7 × the radius. The part heights are the mesh heights from cam.js.

  • cam-paths.js
  • lines 538 to 548
  • PartCraft 0.8.1
function sweptTop(a, b, r, ctx) {
  const n = Math.max(2, Math.ceil(O.dist(a, b) / 1));
  let m = -Infinity;
  for (let i = 0; i <= n; i++) {
    const q = [a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n];
    for (const [dx, dy] of [[0, 0], [r, 0], [-r, 0], [0, r], [0, -r], [0.7 * r, 0.7 * r], [-0.7 * r, 0.7 * r], [0.7 * r, -0.7 * r], [-0.7 * r, -0.7 * r]]) {
      m = Math.max(m, ctx.model.heightAt(q[0] + dx, q[1] + dy), ctx.stockAt(q[0] + dx, q[1] + dy, 0));
    }
  }
  return m;
}

Chamfer depth and offset

64-bit

How deep and how far out a chamfer mill runs to cut a chamfer of a given width.

β = tool angle ÷ 2; depth = width ÷ tan β; offset = tip radius + tip offset × tan β; z = edge height − depth − tip offset

Closed-form cone geometry. The angle is converted with Math.PI ÷ 180. The check that the cone is wide enough has an absolute slack of 1e-9 mm.

  • cam-paths.js
  • lines 554 to 557
  • PartCraft 0.8.1
const beta = (tool.angle / 2) * Math.PI / 180, rt = tool.tipD / 2;
const depth = p.width / Math.tan(beta);
const off = rt + p.tipOffset * Math.tan(beta);
if (rt + (depth + p.tipOffset) * Math.tan(beta) > tool.d / 2 + 1e-9) throw new Error(t('mfg.chamfer.small', { width: fmt(p.width), tip: fmt(p.tipOffset), d: fmt(tool.d) }));

Engagement angle of adaptive clearing

64-bit

The angle of the cutter's rim in material for a set cutting width, listed with the operation.

angle = acos(1 − min(1, w ÷ R)) × 180 ÷ π

The ratio is limited to 1 before acos. For a very small width, 1 − w ÷ R is close to 1, where acos loses digits; the widths used in practice are a sizable fraction of the radius.

  • cam-paths.js
  • line 599
  • PartCraft 0.8.1
ctx.info.engagement = { w, angle: Math.acos(1 - Math.min(1, w / R)) * 180 / Math.PI, cell: A.cell };

Length of a toolpath segment

64-bit

The length of a straight or helical move, used for the machining time.

line: |b − a|; arc: √((|sweep| × r)² + Δz²)

The helix length is exact for a constant pitch. The radius is taken from the start point's distance to the center.

  • cam-paths.js
  • lines 709 to 713
  • PartCraft 0.8.1
export const segLength = (s) => {
  if (s.k !== 'arc') return Math.hypot(s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]);
  const r = Math.hypot(s.a[0] - s.c[0], s.a[1] - s.c[1]);
  return Math.hypot(Math.abs(s.sw) * r, s.b[2] - s.a[2]);
};

Points along a toolpath segment

64-bit

Sample points along an arc move for the simulation overlay and checks.

step angle = 2 acos(1 − tol ÷ r); point i at a₀ + sweep × i ÷ n, z linear

The last point is the segment's stored end. Other points are computed from the center and the start radius.

  • cam-paths.js
  • lines 695 to 708
  • PartCraft 0.8.1
/** Points along one segment (arcs sampled at chord tolerance tol). */
export function segPoints(s, tol = 0.01) {
  if (s.k !== 'arc') return [s.a, s.b];
  const r = Math.hypot(s.a[0] - s.c[0], s.a[1] - s.c[1]);
  const a0 = Math.atan2(s.a[1] - s.c[1], s.a[0] - s.c[0]);
  const da = r > tol ? 2 * Math.acos(Math.max(-1, 1 - tol / r)) : Math.PI / 4;
  const n = Math.max(2, Math.ceil(Math.abs(s.sw) / da));
  const out = [];
  for (let i = 0; i <= n; i++) {
    const a = a0 + (s.sw * i) / n;
    out.push(i === n ? s.b : [s.c[0] + r * Math.cos(a), s.c[1] + r * Math.sin(a), s.a[2] + ((s.b[2] - s.a[2]) * i) / n]);
  }
  return out;
}

In detail

Method
Offset clearing inside-out, contour at tool radius plus stock to leave, canned drilling cycles, chamfer by cone geometry, as described in the file's header.
Number types
64-bit numbers; moves are plain arrays [x, y, z].
Tolerances
A move shorter than 1e-9 mm in every axis is not emitted. Level counts use ceil(x − 1e-9). Stay-down links are checked every 0.25 mm against the walls with a 1e-6 mm slack. Holes match a requested diameter within 1e-3 mm. Drill links are checked every 1 mm at nine points across the tool.
Limits
Sampled link checks can miss an obstacle narrower than the sample spacing. Heights of the part between holes come from the mesh height query in cam.js.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach