partforge 0.150.0 → 0.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,13 +8,14 @@
8
8
  import { fmtMm } from "../sheet/constants.js";
9
9
 
10
10
  export const README_HEADINGS = Object.freeze([
11
- "BEFORE YOU CUT", "SHEETS", "PIECES", "PRINTED PARTS", "CLEARANCES (design fit, not kerf)",
11
+ "BEFORE YOU CUT", "SHEETS", "PIECES", "ROUTER OPERATIONS", "PRINTED PARTS", "CLEARANCES (design fit, not kerf)",
12
12
  "SETTINGS", "CHECKS", "OVERSIZED PIECES", "RENAMED FILES", "LIMITS",
13
13
  ]);
14
14
  export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,height_mm,quantity";
15
15
 
16
16
  const SVG_LEGEND = "Colours: engrave = filled black #000000, score = blue #0000FF, cut = red #FF0000 (inner cuts come before outer cuts).";
17
17
  const DXF_LEGEND = "DXF layers: CUT (colour 1), SCORE (colour 5), ENGRAVE (colour 7) — set ENGRAVE to fill; R12 has no hatch.";
18
+ const ROUTER_LEGEND = "DXF layers: CUT (colour 1) is the through cut; POCKET_<depth>MM and DRILL_D<dia>_<depth>MM / _THRU carry one depth each; SECONDARY outlines operations done with another tool (see ROUTER OPERATIONS); REFERENCE is layout only. Laser pieces keep CUT/SCORE/ENGRAVE.";
18
19
  const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
19
20
 
20
21
  // C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
@@ -52,6 +53,7 @@ const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
52
53
  export function renderReadme(m) {
53
54
  const k = m.kerf;
54
55
  const kerfed = k > 0;
56
+ const router = m.destination === "cnc-router";
55
57
  // A cutting service gets each piece's score and engrave lines in a separate
56
58
  // -marks.dxf, which neither table lists — so the files are named here, or a service
57
59
  // order built from the cut files alone silently leaves the marks off.
@@ -60,14 +62,16 @@ export function renderReadme(m) {
60
62
  "BEFORE YOU CUT": [
61
63
  ...m.stockNotes.map((g) =>
62
64
  `This kit assumes ${g.thickness.toFixed(2)} mm ${cleanLabel(g.material)} — if yours differs, change "${cleanLabel(g.control ?? "the sheet thickness setting")}" and re-download.`),
63
- kerfed
64
- ? `Kerf: ${k.toFixed(2)} mm applied to the cut lines only. Set kerf in ONE place — these download options, or your laser software / cutting service — never both.`
65
- : "Kerf: none applied — your laser software or cutting service must compensate.",
66
- ...(kerfed ? [] : ["To have the files adjusted instead, download again with a kerf set, and turn compensation off in your laser software or service."]),
65
+ router
66
+ ? "Kerf: none — a CNC router's CAM applies the tool offset."
67
+ : kerfed
68
+ ? `Kerf: ${k.toFixed(2)} mm applied to the cut lines only. Set kerf in ONE place — these download options, or your laser software / cutting service — never both.`
69
+ : "Kerf: none applied — your laser software or cutting service must compensate.",
70
+ ...(kerfed || router ? [] : ["To have the files adjusted instead, download again with a kerf set, and turn compensation off in your laser software or service."]),
67
71
  "Scale check — measure one cut piece against its line:",
68
72
  ...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
69
73
  kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
70
- m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
74
+ router ? ROUTER_LEGEND : m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
71
75
  ...(marksFiles.length ? [`Score and engrave marks are in their own files, not in the cut files — send them with the cut files if you want the marks made: ${marksFiles.join(", ")}.`] : []),
72
76
  // A service kit whose every piece is oversized writes no sheets/ at all.
73
77
  ...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
@@ -78,12 +82,25 @@ export function renderReadme(m) {
78
82
  String(s.pieces), `${Math.round(s.used)}%`])),
79
83
  PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
80
84
  pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
85
+ "ROUTER OPERATIONS": [...(m.router ?? []).flatMap((r) => [
86
+ `${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from the machined face:`,
87
+ ...table(["op", "label", "size", "depth", "method"], [
88
+ ...r.pockets.map((pk) => ["pocket", pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
89
+ ...r.drills.map((dr) => ["drill", dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
90
+ ...r.extra.map((x) => ["extra", cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
91
+ ]),
92
+ ]),
93
+ // The layer legend: one colour per layer name across the whole kit.
94
+ ...((m.router ?? []).length && (m.layers ?? []).length
95
+ ? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "colour", "depth"], m.layers.map((l) => [l.name, l.colour, l.depth]))]
96
+ : [])],
81
97
  "PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
82
98
  "CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
83
99
  `${cleanLabel(c.label)}: ${settingValue(c.value)}${c.unit ? ` ${cleanLabel(c.unit)}` : ""}`),
84
100
  SETTINGS: m.settings.map((s) => `${cleanLabel(s.key)} = ${settingValue(s.value)}`),
85
101
  CHECKS: m.checks.length ? m.checks.map(cleanLabel) : ["none"],
86
- "OVERSIZED PIECES": m.destination === "service" ? m.oversized.map(cleanLabel) : [],
102
+ // Only a non-strict layout (a service, a CNC router) can leave a piece off the sheets.
103
+ "OVERSIZED PIECES": m.destination === "service" || router ? m.oversized.map(cleanLabel) : [],
87
104
  "RENAMED FILES": m.renamed.map(cleanLabel),
88
105
  LIMITS: [LIMITS_LINE],
89
106
  };
@@ -22,6 +22,7 @@ export const SVG_STROKE_MM = 0.025; // a hairline: laser software cuts it as a
22
22
  // The group order is the cut order — the same list as drawing.js's LAYER_ORDER, which
23
23
  // this leaf cannot import (drawing.js reaches paper); test/kit-svg.test.js pins them equal.
24
24
  const ORDER = Object.keys(SVG_COLORS);
25
+ const SVG_HEX = /^#[0-9A-F]{6}$/i;
25
26
 
26
27
  const num = (x) => { const v = Number(x.toFixed(4)); return String(v === 0 ? 0 : v); }; // -0 → "0"
27
28
  const escapeXml = (s) => String(s)
@@ -74,13 +75,24 @@ export function renderSvg(doc) {
74
75
  `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${num(W)}mm" height="${num(H)}mm" viewBox="0 0 ${num(W)} ${num(H)}">`,
75
76
  `<title>${escapeXml(doc.title)}</title>`,
76
77
  ];
77
- for (const id of ORDER) {
78
+ // Group order mirrors dxf.js: engrave, score, every custom (named) layer in first-seen
79
+ // order across the placements, then cut-inner and cut-outer.
80
+ const custom = [];
81
+ for (const placement of doc.placements) for (const layer of placement.drawing.layers)
82
+ if (layer.name !== undefined && !custom.includes(layer.id)) custom.push(layer.id);
83
+ const ids = [...ORDER.filter((id) => !id.startsWith("cut")), ...custom, ...ORDER.filter((id) => id.startsWith("cut"))];
84
+ for (const id of ids) {
78
85
  const paths = [];
79
86
  for (const placement of doc.placements) {
80
87
  const layer = placement.drawing.layers.find((l) => l.id === id);
81
88
  if (!layer || layer.paths.length === 0) continue;
82
89
  const place = placer(placement);
83
- if (id === "engrave") {
90
+ if (layer.name !== undefined) {
91
+ // a routed layer: stroked in its own colour, never filled
92
+ if (!SVG_HEX.test(layer.svgColor ?? "")) throw new Error(`renderSvg: layer ${JSON.stringify(layer.id)} needs an svgColor`);
93
+ for (const p of layer.paths)
94
+ paths.push(`<path d="${pathData(p, place, H)}" fill="none" stroke="${layer.svgColor}" stroke-width="${SVG_STROKE_MM}"/>`);
95
+ } else if (id === "engrave") {
84
96
  // one filled path per piece: all its engrave rings, even-odd, so a glyph's
85
97
  // counter stays open whatever the rings' winding
86
98
  const d = layer.paths.map((p) => pathData(p, place, H)).join(" ");
@@ -625,5 +625,6 @@ export { translateProfile, rotateProfile, scaleProfile, mirrorProfile,
625
625
  // the drawing ↔ world converters. Plain JS over the contour IR — not kernel ops, so
626
626
  // they add nothing to the kernel contract's op lists.
627
627
  export { sheetPart } from "../sheet/part.js";
628
+ export { routedPart } from "../sheet/routed.js";
628
629
  export { fingers, tabs, tSlots, sheetPanel, matchingSlots, fingerBox, printedTab, sheetHole, JOINERY_SCREWS } from "../sheet/joinery.js";
629
630
  export { sheetToWorld, worldToSheet } from "../sheet/pose.js";
@@ -166,7 +166,7 @@ function sheetRowFacts(kernel, part, name, view, { p, d }, budget, volume) {
166
166
  const expected = f.area * f.thickness - MARK_DEPTH * (f.marksArea ?? 0);
167
167
  return {
168
168
  ...f,
169
- at: { bridge: lift(f.at2d.bridge), gap: lift(f.at2d.gap), marks: lift(f.at2d.marks) },
169
+ at: { bridge: lift(f.at2d.bridge), gap: lift(f.at2d.gap), marks: lift(f.at2d.marks), outside: lift(f.at2d.outside ?? null) },
170
170
  solidMatchPct: f.customBuild && f.marksArea != null && expected > 1e-9
171
171
  ? (100 * Math.abs(volume - expected)) / expected
172
172
  : null,
@@ -192,7 +192,7 @@ function unknownProcessFacts(kernel, sp, p, d) {
192
192
  process: sp.sheet.process, material: meta?.material ?? null, thickness: meta?.thickness ?? null, group: meta?.group ?? null,
193
193
  flat, area, pieces, customBuild: sp.build !== sp.sheet.generatedBuild,
194
194
  marksArea: null, bridge: null, bridgeCapped: false, gap: null, gapCapped: false, marksOutside: null, solidMatchPct: null,
195
- at2d: { bridge: null, gap: null, marks: null }, at: { bridge: null, gap: null, marks: null },
195
+ at2d: { bridge: null, gap: null, marks: null, outside: null }, at: { bridge: null, gap: null, marks: null, outside: null },
196
196
  readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
197
197
  evaluated: false,
198
198
  };
@@ -0,0 +1,174 @@
1
+ // CNC routing — process module #2. Flat stock machined from ONE face: through cuts
2
+ // (profile), flat-floored pockets at a depth, drilled holes at a depth, and `extra`
3
+ // operations the operator does with another tool after the flat milling. The
4
+ // drawing is one layered DXF per piece, depth on the layer name and colour.
5
+ //
6
+ // Import-free apart from sheet/constants.js and lint/finding.js (test/sheet-layering):
7
+ // lint and the oracle read the registry. The resolver that turns pockets/drills/extra
8
+ // into shapes lives in sheet/routed-resolve.js (it may import geometry); the writer in
9
+ // process/cnc-router/export.js (it reaches paper), loaded only by the kit.
10
+ import {
11
+ EMPTY_MARK_RE, MARK_DEPTH, MARK_OVERCUT, ROUTED_DOC_ID, ROUTED_KEYS, LOSS_TOL_MM2, isSheetPart, sheetMeta, fmtMm,
12
+ } from "../../sheet/constants.js";
13
+ import { err } from "../../lint/finding.js";
14
+
15
+ // The preview: the profile extruded to the thickness, each pocket cut `depth` down from
16
+ // the machined face (z = t), each drill as a cylinder from the face to its depth —
17
+ // through holes and through pockets (depth = thickness) clear BOTH faces by MARK_OVERCUT so no cap is coplanar with a face
18
+ // (OCCT's coplanar booleans are fragile) — each extra solid subtracted as given, and
19
+ // the reference grooves cut like a laser score. A tool whose region turned out empty
20
+ // (EMPTY_MARK_RE) is dropped, never a build failure; matched by message, never by
21
+ // asking the shape, so this stays probe-safe.
22
+ function orEmpty(make) {
23
+ try { return make(); } catch (e) { if (EMPTY_MARK_RE.test(e?.message ?? "")) return null; throw e; }
24
+ }
25
+ function routedPreview(k, s) {
26
+ const t = s.thickness;
27
+ const body = s.profile.extrude({ h: t });
28
+ const { pockets, drills, extra } = s.routed;
29
+ const tools = [
30
+ ...pockets.map((pk) => orEmpty(() => (pk.depth >= t
31
+ ? pk.region.extrude({ h: t + 2 * MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
32
+ : pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
33
+ ...drills.map((dr) => (dr.through
34
+ ? k.cylinder({ d: dr.d, h: t + 2 * MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
35
+ : k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
36
+ ...extra.map((x) => x.solid),
37
+ ...s.grooves.map((g) => orEmpty(() => g.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH]))),
38
+ ].filter(Boolean);
39
+ return tools.length ? body.cutAll(tools) : body;
40
+ }
41
+
42
+ // ── facts: the operations table ───────────────────────────────────────────────
43
+ // In the laser facts' shape (the laser-only readings null), so oracle/measure.js's
44
+ // sheetRowFacts reads a routed row unchanged; `routed` carries what the router does.
45
+ // `at2d.outside` is the 2-D spot of the first region found outside the profile;
46
+ // `at.outside` (its 3-D place) is left null here — measure.js's sheetRowFacts lifts it.
47
+ const round2 = (x) => Math.round(x * 100) / 100;
48
+ const sizeOf = (shape) => { const { min, max } = shape.boundingBox(); return [max[0] - min[0], max[1] - min[1]]; };
49
+ // The centre of the bounding box of `shape`'s largest region; null when empty.
50
+ function centreOfLargest(shape) {
51
+ if (shape.isEmpty()) return null;
52
+ let best = null, bestArea = -Infinity;
53
+ for (const region of shape.regions()) { const a = region.area(); if (a > bestArea) { best = region; bestArea = a; } }
54
+ const { min, max } = best.boundingBox();
55
+ return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2];
56
+ }
57
+ // Does `shape` leave the profile? Its part outside, when it has an area.
58
+ function outsideOf(shape, profile) {
59
+ const off = shape.cut(profile);
60
+ return off.isEmpty() || off.area() <= LOSS_TOL_MM2 ? null : off;
61
+ }
62
+
63
+ function routedFacts(s) {
64
+ const { profile, thickness: t } = s;
65
+ const pieces = profile.toContours().length;
66
+ const area = pieces ? profile.area() : 0;
67
+ const flat = pieces ? sizeOf(profile) : [0, 0];
68
+ const { pockets, drills, extra } = s.routed;
69
+
70
+ let outside = 0, empty = 0, outsideAt = null;
71
+ const noteOutside = (shape) => { const off = outsideOf(shape, profile); if (off) { outside++; outsideAt ??= centreOfLargest(off); } };
72
+
73
+ const pocketRows = [];
74
+ for (const pk of pockets) {
75
+ if (pk.region.isEmpty()) { empty++; continue; }
76
+ const [w, h] = sizeOf(pk.region);
77
+ pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()) });
78
+ noteOutside(pk.region);
79
+ }
80
+ // Drills grouped by (d, depth, through): one row per tool setting, labels joined.
81
+ const drillGroups = new Map();
82
+ for (const dr of drills) {
83
+ const key = `${dr.d}|${dr.depth}|${dr.through}`;
84
+ const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, count: 0 };
85
+ g.count++;
86
+ if (dr.label && !g.labels.includes(dr.label)) g.labels.push(dr.label);
87
+ drillGroups.set(key, g);
88
+ noteOutside(dr.shape);
89
+ }
90
+ const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count }) =>
91
+ ({ label: labels.length ? labels.join(", ") : null, d, depth, through, count }));
92
+ // An extra op whose footprint resolved to nothing draws nothing: counted under
93
+ // `empty` with the empty pockets, never a row (its size has no bounding box).
94
+ const extraRows = [];
95
+ for (const x of extra) {
96
+ if (x.footprint.isEmpty()) { empty++; continue; }
97
+ const [w, h] = sizeOf(x.footprint);
98
+ noteOutside(x.footprint);
99
+ // How deep the op reaches below the machined face (z = t), within the stock. An
100
+ // empty solid has no bounding box and reaches nothing.
101
+ const bb = x.solid.boundingBox();
102
+ const bottom = bb?.min?.[2];
103
+ const reach = Number.isFinite(bottom) ? t - bottom : 0;
104
+ extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))) });
105
+ }
106
+
107
+ return {
108
+ process: s.process, material: s.material, thickness: t, group: s.group,
109
+ flat, area, pieces, customBuild: s.customBuild,
110
+ marksArea: null, bridge: null, bridgeCapped: false, gap: null, gapCapped: false, marksOutside: null, solidMatchPct: null,
111
+ at2d: { bridge: null, gap: null, marks: null, outside: outsideAt },
112
+ at: { bridge: null, gap: null, marks: null, outside: null },
113
+ readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
114
+ evaluated: true,
115
+ routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty },
116
+ };
117
+ }
118
+
119
+ // ── verify metrics: all warnings, volunteered by checks() ─────────────────────
120
+ // None is `budgeted` and none has a `readError`: a routed part's facts are taken
121
+ // whole, so nothing is budget-withheld.
122
+ const METRICS = {
123
+ routedOutside: { kind: "warn", doc: ROUTED_DOC_ID,
124
+ extract: (s) => s.sheet?.routed?.outside ?? null,
125
+ locate: (s) => s.sheet?.at?.outside ?? null,
126
+ hint: "a pocket, drill or extra-op footprint of this routed part lies partly outside its profile — the router would cut air or the next piece; move it inside the outline, or widen the profile" },
127
+ routedEmpty: { kind: "warn", doc: ROUTED_DOC_ID,
128
+ extract: (s) => s.sheet?.routed?.empty ?? null,
129
+ hint: "a pocket or extra-op footprint of this routed part resolved to an empty region at these parameters, so nothing is drawn for it — check the region's size and position, or drop the entry" },
130
+ routedPieces: { kind: "warn", doc: ROUTED_DOC_ID,
131
+ extract: (s) => s.sheet?.pieces ?? null,
132
+ hint: "the profile is not exactly one piece — a routed part must cut out as one region; join the pieces or split them into separate routedPart sub-parts" },
133
+ };
134
+
135
+ // ── lint ──────────────────────────────────────────────────────────────────────
136
+ const DRILL_DEPTH_HINT = "A drill's depth is measured from the machined face; a depth at or past the stock thickness is a through hole. Lower the depth, raise the stock thickness control, or set depth to the thickness to drill through.";
137
+ const ROUTER_RULES = [{
138
+ id: "routed-drill-depth",
139
+ run: ({ part, p, d, deriveError }) => {
140
+ if (deriveError || !part?.parts || typeof part.parts !== "object") return [];
141
+ return Object.entries(part.parts).flatMap(([name, sp]) => {
142
+ if (!isSheetPart(sp) || sp.sheet.process !== "cnc-router" || typeof sp.sheet.drills !== "function") return [];
143
+ const meta = sheetMeta(sp, p, d);
144
+ if (!meta) return [];
145
+ let drills;
146
+ try { drills = sp.sheet.drills(p, d); } catch { return []; } // a throwing drills() is the build rules' business
147
+ if (!Array.isArray(drills)) return [];
148
+ return drills.flatMap((dr, i) => {
149
+ if (!dr || typeof dr.depth !== "number" || !(dr.depth > meta.thickness)) return [];
150
+ const who = typeof dr.label === "string" && dr.label.trim() ? `drill "${dr.label.trim()}"` : `drill ${i + 1}`;
151
+ return [err("routed-drill-depth", `sub-part "${name}": ${who} is ${fmtMm(dr.depth)} mm deep in ${fmtMm(meta.thickness)} mm stock`,
152
+ DRILL_DEPTH_HINT, `parts.${name}.sheet.drills`, ROUTED_DOC_ID)];
153
+ });
154
+ });
155
+ },
156
+ }];
157
+
158
+ export const ROUTER = {
159
+ id: "cnc-router",
160
+ label: "CNC routing",
161
+ stock: "sheet",
162
+ docId: ROUTED_DOC_ID,
163
+ // profile = the through cut; reference = vector lines (centerlines); pockets,
164
+ // drills and extra footprints = filled regions at a depth.
165
+ layers: { profile: "region", reference: "line", pockets: "region", drills: "region", extra: "region" },
166
+ keys: ROUTED_KEYS,
167
+ reservedKeys: [],
168
+ preview: routedPreview,
169
+ destinations: [{ id: "cnc-router", cutFormat: "dxf" }],
170
+ facts: routedFacts,
171
+ checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1" }),
172
+ metrics: METRICS,
173
+ lintRules: ROUTER_RULES,
174
+ };
@@ -0,0 +1,74 @@
1
+ // The cnc-router exporter: one resolved routed piece → one Drawing whose layers carry
2
+ // their own DXF name and colour (drawing.js ROUTED_COLORS). Loaded only through
3
+ // process/exporters.js. Kerf never applies: the CAM offsets for the tool, and
4
+ // validateKitOptions has already refused a kerf for this destination — the check here
5
+ // is the belt for a laser destination carrying a routed piece.
6
+ //
7
+ // Colours: `ctx.palette` (drawing.js routedPalette, built by the kit over EVERY routed
8
+ // piece) when given, so one depth is one colour across the kit; without it — a direct
9
+ // call — the piece ranks its own depths. Each named layer also carries its `depth` (mm,
10
+ // two decimals) and drills `through`, which the README's layer legend reads.
11
+ import {
12
+ refitRing, ringsOf, cutRings, drawingBounds, routedLayerName, depthToken, drillKey, ROUTED_COLORS,
13
+ } from "../../export/drawing.js";
14
+ import { KIT_OPTIONS_ERROR } from "../../export/formats.js";
15
+
16
+ const closedPath = (ring) => ({ start: ring.start, segments: ring.segments, closed: true });
17
+ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.outer), holes: rg.holes.map(refitRing) }));
18
+ const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
19
+ const ringsOfShape = (shape) => allRings(refitRegions(shape.toContours())).map(closedPath);
20
+
21
+ function drawing(s, o, k, ctx = {}) {
22
+ const label = ctx.label ?? s.label ?? "routed part";
23
+ if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
24
+ const nominal = refitRegions(s.profile.toContours());
25
+ if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
26
+ const cut = cutRings(nominal);
27
+ const { pockets, drills, extra } = s.routed;
28
+
29
+ const layers = [];
30
+ const reference = [
31
+ ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
32
+ ...s.score.shapes.flatMap(ringsOfShape),
33
+ ];
34
+ if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
35
+ const secondary = extra.flatMap((x) => ringsOfShape(x.footprint));
36
+ if (secondary.length) layers.push({ id: "secondary", name: "SECONDARY", ...ROUTED_COLORS.secondary, paths: secondary });
37
+
38
+ // Drills grouped by (d, depth, through) in first-seen order; each hole a closed ring
39
+ // of two exact arcs, which dxf.js writes as a CIRCLE.
40
+ const palette = ctx.palette ?? null;
41
+ const drillGroups = new Map();
42
+ drills.forEach((dr) => {
43
+ const key = drillKey(dr);
44
+ const colour = palette?.drill.get(key) ?? ROUTED_COLORS.drill[drillGroups.size % ROUTED_COLORS.drill.length];
45
+ const g = drillGroups.get(key) ?? { id: `drill-${key}`, name: routedLayerName("drill", dr), ...colour,
46
+ depth: Number(depthToken(dr.depth)), through: dr.through, paths: [] };
47
+ g.paths.push(...ringsOfShape(dr.shape));
48
+ drillGroups.set(key, g);
49
+ });
50
+ layers.push(...drillGroups.values());
51
+
52
+ // Pockets grouped by two-decimal depth, shallowest layer first; an empty region draws nothing.
53
+ const pocketGroups = new Map();
54
+ for (const pk of pockets) {
55
+ const paths = ringsOfShape(pk.region);
56
+ if (paths.length === 0) continue;
57
+ const key = depthToken(pk.depth);
58
+ const g = pocketGroups.get(key) ?? { id: `pocket-${key}`, name: routedLayerName("pocket", pk), depth: Number(key), paths: [] };
59
+ g.paths.push(...paths);
60
+ pocketGroups.set(key, g);
61
+ }
62
+ [...pocketGroups.values()].sort((a, b) => a.depth - b.depth).forEach((g, i) => {
63
+ const colour = palette?.pocket.get(depthToken(g.depth)) ?? ROUTED_COLORS.pocket[i % ROUTED_COLORS.pocket.length];
64
+ layers.push({ ...g, ...colour });
65
+ });
66
+
67
+ if (cut.inner.length) layers.push({ id: "cut-inner", paths: cut.inner.map(closedPath) });
68
+ layers.push({ id: "cut-outer", paths: cut.outer.map(closedPath) });
69
+
70
+ const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
71
+ return { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
72
+ }
73
+
74
+ export default { drawing };
@@ -8,6 +8,7 @@
8
8
  // test/kit-export-guards.test.js fails until the two id lists agree.
9
9
  export const EXPORTER_LOADERS = Object.freeze({
10
10
  laser: () => import("./laser/export.js"),
11
+ "cnc-router": () => import("./cnc-router/export.js"),
11
12
  });
12
13
 
13
14
  // The process's ProcessExporter ({ drawing(s, o, k, ctx) }), loaded on first use.
@@ -6,8 +6,9 @@
6
6
  // the descriptors are: lint and the oracle read this file.
7
7
  import { isSheetPart } from "../sheet/constants.js";
8
8
  import { LASER } from "./laser/descriptor.js";
9
+ import { ROUTER } from "./cnc-router/descriptor.js";
9
10
 
10
- export const PROCESSES = [LASER];
11
+ export const PROCESSES = [LASER, ROUTER];
11
12
  export const PROCESS_IDS = PROCESSES.map((d) => d.id);
12
13
 
13
14
  export const processById = (id) => PROCESSES.find((d) => d.id === id) ?? null;
@@ -17,6 +17,13 @@
17
17
  // lint finding and DFM check carries it as `pattern`, which survives the host's
18
18
  // truncation of message/hint.
19
19
  export const SHEET_DOC_ID = "sheet-parts";
20
+ // The routed-parts guide section id ("## Routed parts" in docs/AUTHORING-PARTS.md):
21
+ // every cnc-router lint finding and check carries it as `pattern`.
22
+ export const ROUTED_DOC_ID = "routed-parts";
23
+ // The keys only routedPart() admits, on top of SHEET_KEYS. `reference` is stored on
24
+ // the sheet record as `score` (same array shape, same preview groove) and drawn on
25
+ // the REFERENCE layer; the other three are resolved by sheet/routed.js.
26
+ export const ROUTED_KEYS = Object.freeze(["pockets", "drills", "extra", "reference"]);
20
27
 
21
28
  // The six axis words a SheetPose names its face and up with.
22
29
  export const AXIS_WORDS = Object.freeze(["+X", "-X", "+Y", "-Y", "+Z", "-Z"]);
@@ -26,8 +26,8 @@ import { applyPose, resolveSheet, sheetPreview } from "./resolve.js";
26
26
  import { PROCESSES, PROCESS_IDS, processById } from "../process/registry.js";
27
27
 
28
28
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
29
- const KNOWN = [...SHEET_KEYS, ...SUBPART_PASSTHROUGH_KEYS];
30
- const KEY_LIST = `${SHEET_KEYS.join(", ")}, plus the sub-part keys ${SUBPART_PASSTHROUGH_KEYS.join(", ")}`;
29
+ const KNOWN_BASE = [...SHEET_KEYS, ...SUBPART_PASSTHROUGH_KEYS];
30
+ const keyList = (processKeys) => `${[...SHEET_KEYS, ...processKeys].join(", ")}, plus the sub-part keys ${SUBPART_PASSTHROUGH_KEYS.join(", ")}`;
31
31
  const optionalFn = (v) => v === undefined || v === null || typeof v === "function";
32
32
 
33
33
  // Every mistake throws here, at module load, naming the fix — in the order a reader
@@ -43,11 +43,21 @@ function checkSpec(spec) {
43
43
  const reservedKeys = [...RESERVED_KEYS, ...PROCESSES.flatMap((proc) => proc.reservedKeys ?? [])];
44
44
  const reserved = keys.find((key) => reservedKeys.includes(key));
45
45
  if (reserved) throw new Error(`sheetPart: "${reserved}" is reserved for a future process and not supported yet`);
46
- const unknown = keys.find((key) => !KNOWN.includes(key));
47
- if (unknown) throw new Error(`sheetPart: unknown key "${unknown}" — the keys are ${KEY_LIST}`);
48
-
49
46
  const process = spec.process ?? "laser";
50
- if (!processById(process)) throw new Error(`sheetPart: unknown process ${JSON.stringify(process)} — valid: ${PROCESS_IDS.join(", ")}`);
47
+ const desc = processById(process);
48
+ if (!desc) {
49
+ throw new Error(`sheetPart: unknown process ${JSON.stringify(process)} — valid: ${PROCESS_IDS.join(", ")}; for CNC-routed stock with pockets and drills at depth use routedPart()`);
50
+ }
51
+ // A process's own declaration keys (sheet/constants.js ROUTED_KEYS for cnc-router).
52
+ // `reference` is routedPart's name for the score layer and arrives here already
53
+ // mapped onto `score`, so it is not admitted on the record itself.
54
+ const processKeys = (desc.keys ?? []).filter((key) => key !== "reference");
55
+ const unknown = keys.find((key) => !KNOWN_BASE.includes(key) && !processKeys.includes(key));
56
+ if (unknown) throw new Error(`sheetPart: unknown key "${unknown}" — the keys are ${keyList(processKeys)}`);
57
+ for (const key of processKeys) {
58
+ if (!optionalFn(spec[key])) throw new Error(`sheetPart: ${key} must be a function (k, p, d) => …, or omitted`);
59
+ }
60
+
51
61
  const { material: m, thickness: t } = spec;
52
62
  if (!(typeof m === "function" || (typeof m === "string" && m.trim() !== "")))
53
63
  throw new Error('sheetPart: material must be a non-empty string or a (p, d) => string function — a stock label like "birch plywood"');
@@ -63,11 +73,11 @@ function checkSpec(spec) {
63
73
  if (reason) throw new Error(`sheetPart: ${reason}`);
64
74
  }
65
75
  if (spec.place !== undefined && typeof spec.place !== "function") throw new Error("sheetPart: place must be a function (solid, ctx) => solid");
66
- return process;
76
+ return { process, processKeys };
67
77
  }
68
78
 
69
79
  export function sheetPart(spec) {
70
- const process = checkSpec(spec);
80
+ const { process, processKeys } = checkSpec(spec);
71
81
  const sub = {};
72
82
  for (const key of SUBPART_PASSTHROUGH_KEYS)
73
83
  if (key !== "place" && spec[key] !== undefined) sub[key] = spec[key];
@@ -109,6 +119,7 @@ export function sheetPart(spec) {
109
119
  place: authorPlace,
110
120
  generatedBuild,
111
121
  generatedPlace,
122
+ ...Object.fromEntries(processKeys.map((key) => [key, spec[key] ?? null])),
112
123
  });
113
124
  sub.build = generatedBuild;
114
125
  if (generatedPlace) sub.place = generatedPlace;
@@ -12,6 +12,7 @@
12
12
  import { EMPTY_MARK_RE, MARK_DEPTH, MARK_OVERCUT, SCORE_WIDTH, sheetGroup } from "./constants.js";
13
13
  import { poseSteps, validatePose } from "./pose.js";
14
14
  import { recoverArcs } from "../geometry/arc-fit.js";
15
+ import { resolveRouted } from "./routed-resolve.js";
15
16
 
16
17
  const fail = (sp, problem) => new Error(`sheet part${sp.label ? ` "${sp.label}"` : ""}: ${problem}`);
17
18
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
@@ -45,8 +46,16 @@ function shapeGroove(shape) {
45
46
  return orEmpty(() => outer.cut(shape.offset(-SCORE_WIDTH / 2))) ?? outer;
46
47
  }
47
48
 
49
+ // A process's own resolver, keyed by id, producing the `routed`-style field its
50
+ // descriptor's facts/preview and its exporter read. The laser has none. Lives here,
51
+ // not on the descriptor, because a descriptor may import nothing but constants and
52
+ // finding.js, and a resolver lifts shapes with the kernel.
53
+ const PROCESS_RESOLVERS = Object.freeze({ "cnc-router": resolveRouted });
54
+
48
55
  // → ResolvedSheet: { process, material, thickness, group, label, pose, profile,
49
- // score: { lines, shapes }, engrave, grooves, customBuild, trustedShape2d, recoverArcs }.
56
+ // score: { lines, shapes }, engrave, grooves, customBuild, trustedShape2d, recoverArcs,
57
+ // routed? } — `routed` ({ pockets, drills, extra }, sheet/routed-resolve.js) only for the
58
+ // cnc-router process; a laser sheet has no such field.
50
59
  // Throws a `sheet part "<label>": …` error for a field that resolves to something
51
60
  // unusable. `trustedShape2d` is the kernel's lift for machine-produced regions (no
52
61
  // profile validation, arcs kept), or null where the kernel has none (a probe): the
@@ -98,13 +107,15 @@ export function resolveSheet(k, sp, p, d) {
98
107
  ...shapes.map(shapeGroove),
99
108
  ];
100
109
 
101
- return {
110
+ const base = {
102
111
  process: s.process, material, thickness, group: sheetGroup(material, thickness),
103
112
  label: sp.label ?? null, pose, profile, score: { lines, shapes }, engrave, grooves,
104
113
  customBuild: sp.build !== s.generatedBuild,
105
114
  trustedShape2d: typeof k.shape2d?.trusted === "function" ? k.shape2d.trusted : null,
106
115
  recoverArcs,
107
116
  };
117
+ const resolveProcess = PROCESS_RESOLVERS[s.process];
118
+ return resolveProcess ? { ...base, routed: resolveProcess(k, sp, p, d, base) } : base;
108
119
  }
109
120
 
110
121
  // The default preview: the profile extruded to the thickness, the engrave pocketed and
@@ -0,0 +1,62 @@
1
+ // Resolve a routed part's pockets, drills and extra ops at (p, d) — the cnc-router
2
+ // process's half of resolveSheet (sheet/resolve.js dispatches here by process id).
3
+ //
4
+ // PROBE-SAFE like sheet/resolve.js: kernel and Shape2D METHODS only, no geometry
5
+ // queries, no branching on geometry. The exact circle for a drill is written as the
6
+ // contract's two-arc ring (sheetHole's form) rather than imported from joinery.js, which
7
+ // the oracle's closure may not reach.
8
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
9
+ const isPoint = (q) => Array.isArray(q) && q.length === 2 && isNum(q[0]) && isNum(q[1]);
10
+ const isSolid = (v) => !!v && typeof v === "object" && typeof v.cutAll === "function" && typeof v.translate === "function";
11
+ const circleRing = ([x, y], r) => ({ start: [x + r, y], segments: [{ to: [x - r, y], via: [x, y + r] }, { to: [x + r, y], via: [x, y - r] }] });
12
+
13
+ const fail = (sp, problem) => new Error(`routed part${sp.label ? ` "${sp.label}"` : ""}: ${problem}`);
14
+ const name = (entry, i, kind) => (typeof entry.label === "string" && entry.label.trim() ? `${kind} "${entry.label}"` : `${kind} ${i + 1}`);
15
+ const depthOf = (sp, entry, who) => {
16
+ if (!isNum(entry.depth) || entry.depth <= 0) throw fail(sp, `${who} depth must be a finite number > 0 (mm), got ${JSON.stringify(entry.depth)}`);
17
+ return entry.depth;
18
+ };
19
+ const labelOf = (entry) => (typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : null);
20
+ const list = (sp, fn, args, what, shape) => {
21
+ if (!fn) return [];
22
+ const out = fn(...args);
23
+ if (out === null || out === undefined) return [];
24
+ if (!Array.isArray(out)) throw fail(sp, `${what} must return an array of ${shape}`);
25
+ return out.filter((e) => e !== null && e !== undefined);
26
+ };
27
+
28
+ // `base` is resolveSheet's ResolvedSheet so far (profile, thickness, score…); the
29
+ // return value is merged onto it as `routed`.
30
+ export function resolveRouted(k, sp, p, d, base) {
31
+ const s = sp.sheet;
32
+ const t = base.thickness;
33
+
34
+ const pockets = list(sp, s.pockets, [k, p, d], "pockets(k, p, d)", "{ region, depth, label? }").map((e, i) => {
35
+ const who = name(e, i, "pocket");
36
+ if (!e || typeof e !== "object" || e.region === null || e.region === undefined) throw fail(sp, `${who} needs a region (a Shape2D or profile)`);
37
+ const depth = depthOf(sp, e, who);
38
+ if (depth > t) throw fail(sp, `${who} is ${e.depth} mm deep in ${t} mm stock — a pocket may be at most the thickness; make it a through cut in \`profile\` if it should go through`);
39
+ return { region: k.shape2d(e.region), depth, label: labelOf(e) };
40
+ });
41
+
42
+ const drills = list(sp, s.drills, [p, d], "drills(p, d)", "{ at, d, depth }").map((e, i) => {
43
+ const who = name(e, i, "drill");
44
+ if (!e || typeof e !== "object" || !isPoint(e.at)) throw fail(sp, `${who} needs at: [x, y]`);
45
+ if (!isNum(e.d) || e.d <= 0) throw fail(sp, `${who} d must be a finite number > 0 (mm), got ${JSON.stringify(e.d)}`);
46
+ const depth = depthOf(sp, e, who);
47
+ const through = depth >= t;
48
+ return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e) };
49
+ });
50
+
51
+ const extra = list(sp, s.extra, [k, p, d], "extra(k, p, d)", "{ solid, footprint, label, method }").map((e, i) => {
52
+ const label = labelOf(e);
53
+ if (!label) throw fail(sp, `extra op ${i + 1} needs a label — say what it is ("12° domino slot")`);
54
+ const who = `extra op "${label}"`;
55
+ if (typeof e.method !== "string" || !e.method.trim()) throw fail(sp, `${who} needs a method — say how the operator makes it ("table saw, blade tilted 12°")`);
56
+ if (!isSolid(e.solid)) throw fail(sp, `${who} needs a solid — the geometry it removes, for the preview and the STEP`);
57
+ if (e.footprint === null || e.footprint === undefined) throw fail(sp, `${who} needs a footprint — its outline on the machined face, for the drawing`);
58
+ return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim() };
59
+ });
60
+
61
+ return { pockets, drills, extra };
62
+ }
@@ -0,0 +1,44 @@
1
+ // routedPart(spec) — a sub-part CNC-routed from flat stock: the profile is the through
2
+ // cut, pockets are flat-floored regions at a depth, drills are round holes at a depth
3
+ // (through when depth >= thickness), and `extra` entries are operations the operator
4
+ // does with another tool after the flat milling (a tilted slot, a scooped thumb hold):
5
+ // a solid for the preview and the STEP, a footprint for the drawing, a label and a
6
+ // method for the README. It is sheetPart with process "cnc-router": one model, so the
7
+ // assembly, the renders, the STEP and the kit all read the same declaration.
8
+ //
9
+ // Every mistake throws here, at module load, naming the fix. The per-(p, d) shapes are
10
+ // resolved in resolveRouted (./routed-resolve.js), called by sheet/resolve.js for this process.
11
+ import { sheetPart } from "./part.js";
12
+ import { ROUTED_KEYS } from "./constants.js";
13
+
14
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
15
+ const optionalFn = (v) => v === undefined || v === null || typeof v === "function";
16
+
17
+ const SIGNATURES = {
18
+ pockets: "pockets must be a function (k, p, d) => [{ region, depth, label? }], or omitted",
19
+ drills: "drills must be a function (p, d) => [{ at: [x, y], d, depth, label? }], or omitted",
20
+ extra: "extra must be a function (k, p, d) => [{ solid, footprint, label, method }], or omitted",
21
+ reference: "reference must be a function (k, p, d) => [[x, y], [x, y]] lines and shapes, or omitted",
22
+ };
23
+
24
+ export function checkRoutedSpec(spec) {
25
+ if (!isPlainObject(spec)) throw new Error("routedPart: expected an options object — routedPart({ material, thickness, profile, pockets, drills, views, … })");
26
+ const keys = Object.keys(spec);
27
+ if (keys.includes("score")) throw new Error('routedPart: "score" is not a routedPart key — centerlines and layout marks go in `reference`');
28
+ if (keys.includes("engrave")) throw new Error('routedPart: "engrave" is not a routedPart key — a routed part has no engraving; a shallow pocket is `pockets`');
29
+ if (keys.includes("process")) throw new Error('routedPart: "process" is not a routedPart key — routedPart is always the cnc-router process');
30
+ for (const key of ROUTED_KEYS) {
31
+ if (!optionalFn(spec[key])) throw new Error(`routedPart: ${SIGNATURES[key]}`);
32
+ }
33
+ }
34
+
35
+ export function routedPart(spec) {
36
+ checkRoutedSpec(spec);
37
+ const { reference, ...rest } = spec;
38
+ try {
39
+ return sheetPart({ ...rest, process: "cnc-router", ...(reference ? { score: reference } : {}) });
40
+ } catch (e) {
41
+ // sheetPart's own refusals, re-labelled so the author sees the helper they called.
42
+ throw new Error(String(e.message).replace(/^sheetPart:/, "routedPart:"), { cause: e });
43
+ }
44
+ }