partforge 0.150.1 → 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.
@@ -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
+ }
@@ -315,7 +315,13 @@ export interface SheetPartSpec<P = ResolvedParams, D = Derived> {
315
315
  engrave?: ((k: GeometryKernel, p: P, d: D) => Shape2D | ProfileInput | null) | null;
316
316
  /** Where the piece sits, for display AND export. Omitted: flat, no transform. */
317
317
  pose?: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
318
- process?: "laser";
318
+ process?: "laser" | "cnc-router";
319
+ /** `process: "cnc-router"` only: flat-floored pockets at a depth (as `routedPart`). */
320
+ pockets?: ((k: GeometryKernel, p: P, d: D) => (RoutedPocket | null)[] | null) | null;
321
+ /** `process: "cnc-router"` only: drilled holes at a depth (as `routedPart`). */
322
+ drills?: ((p: P, d: D) => (RoutedDrill | null)[] | null) | null;
323
+ /** `process: "cnc-router"` only: operations done with another tool (as `routedPart`). */
324
+ extra?: ((k: GeometryKernel, p: P, d: D) => (RoutedExtra | null)[] | null) | null;
319
325
  label?: string;
320
326
  views: string[] | Record<string, true | ((s: Solid, p: P, d: D) => Solid)>;
321
327
  display?: SubPartDefinition<P, D>["display"];
@@ -330,6 +336,43 @@ export interface SheetPartSpec<P = ResolvedParams, D = Derived> {
330
336
  /** A sub-part cut from flat stock: an ordinary sub-part with `build`, `place` and `sheet` filled in. */
331
337
  export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P, D>): SubPartDefinition<P, D>;
332
338
 
339
+ // --- routed parts (docs/AUTHORING-PARTS.md "Routed parts") --------------------
340
+
341
+ /** A flat-floored pocket machined `depth` mm down from the machined face. */
342
+ export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string }
343
+ /** A drilled hole; `depth` equal to the stock thickness drills through — deeper is a `routed-drill-depth` lint error. */
344
+ export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string }
345
+ /** An operation done with another tool after the flat milling: the solid is cut in the preview and the STEP, the footprint is drawn, label and method go in the README. */
346
+ export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string }
347
+
348
+ /**
349
+ * What `routedPart` accepts: `sheetPart`'s stock, profile and pose, plus pockets,
350
+ * drills and extra ops at depth. `score`/`engrave`/`process` are refused at load.
351
+ */
352
+ export interface RoutedPartSpec<P = ResolvedParams, D = Derived> {
353
+ material: string | ((p: P, d: D) => string);
354
+ /** The MEASURED stock thickness in mm. */
355
+ thickness: number | ((p: P, d: D) => number);
356
+ /** The THROUGH cut: outline plus through holes, seen from the machined face. */
357
+ profile: (k: GeometryKernel, p: P, d: D) => Shape2D | ProfileInput;
358
+ pockets?: ((k: GeometryKernel, p: P, d: D) => (RoutedPocket | null)[] | null) | null;
359
+ drills?: ((p: P, d: D) => (RoutedDrill | null)[] | null) | null;
360
+ extra?: ((k: GeometryKernel, p: P, d: D) => (RoutedExtra | null)[] | null) | null;
361
+ /** Centerlines and layout marks: drawn on REFERENCE, cut nothing. Same entries as `score`. */
362
+ reference?: ((k: GeometryKernel, p: P, d: D) => SheetScoreEntry[] | null) | null;
363
+ pose?: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
364
+ label?: string;
365
+ views: string[] | Record<string, true | ((s: Solid, p: P, d: D) => Solid)>;
366
+ display?: SubPartDefinition<P, D>["display"];
367
+ export?: { name: string };
368
+ enabled?: (p: P) => unknown;
369
+ exportable?: boolean;
370
+ place?: (solid: Solid, ctx: PlaceContext<P, D>) => Solid;
371
+ }
372
+
373
+ /** A sub-part CNC-routed from flat stock: an ordinary sub-part with `build`, `place` and a `sheet` record of process `"cnc-router"`. */
374
+ export function routedPart<P = ResolvedParams, D = Derived>(spec: RoutedPartSpec<P, D>): SubPartDefinition<P, D>;
375
+
333
376
  /** A metric screw size `tSlots` knows. */
334
377
  export type ScrewSize = "M2.5" | "M3" | "M4";
335
378
 
package/types/index.d.ts CHANGED
@@ -51,8 +51,8 @@ export interface ExportableSheetInfo {
51
51
  * starting `"cut kit options:"` for anything it cannot honour.
52
52
  */
53
53
  export interface KitOptions {
54
- /** `"own-laser"` (default): SVG, laid out on sheets. `"service"`: DXF, one cut-only file per piece. */
55
- destination?: "own-laser" | "service";
54
+ /** `"own-laser"` (default): SVG, laid out on sheets. `"service"`: DXF, one cut-only file per piece. `"cnc-router"`: DXF, one layered file per routed piece; kerf must be 0. */
55
+ destination?: "own-laser" | "service" | "cnc-router";
56
56
  /** Derived from `destination`; when given it must match. */
57
57
  cutFormat?: "svg" | "dxf";
58
58
  /** mm, 0–0.5: the cut lines move out by half of it. Default 0 (nominal geometry). */
package/types/part.d.ts CHANGED
@@ -556,6 +556,12 @@ export interface SubPartExpectations {
556
556
  sheetPieces?: Expectation;
557
557
  /** Sheet parts with a custom `build` only: % volume drift from profile area × thickness minus the marks (volunteered check: `<=2`). */
558
558
  sheetSolidMatch?: Expectation;
559
+ /** Routed parts only: pocket, drill or extra-op footprint regions outside the profile (volunteered check: `0`). */
560
+ routedOutside?: Expectation;
561
+ /** Routed parts only: pockets whose region resolved empty (volunteered check: `0`). */
562
+ routedEmpty?: Expectation;
563
+ /** Routed parts only: separate regions in the cut profile (volunteered check: `1`). */
564
+ routedPieces?: Expectation;
559
565
  }
560
566
 
561
567
  /**
@@ -340,14 +340,33 @@ export interface SheetFacts {
340
340
  marksOutside: number | null;
341
341
  /** Custom builds only: `100·|volume − (area·thickness − 0.2·marksArea)| / (area·thickness − 0.2·marksArea)`. */
342
342
  solidMatchPct: number | null;
343
- at2d: { bridge: [number, number] | null; gap: [number, number] | null; marks: [number, number] | null };
344
- at: { bridge: number[] | null; gap: number[] | null; marks: number[] | null };
343
+ /** `outside` is a routed part's (the 2-D spot of the first op found outside the profile); a laser part's facts leave it out. */
344
+ at2d: { bridge: [number, number] | null; gap: [number, number] | null; marks: [number, number] | null; outside?: [number, number] | null };
345
+ /** `outside`: the 3-D place of `at2d.outside` when the part's pose is trusted, else `null`. */
346
+ at: { bridge: number[] | null; gap: number[] | null; marks: number[] | null; outside: [number, number, number] | null };
345
347
  /**
346
348
  * Why a reading is `null` although `evaluated` is true — the geometry engine refused
347
349
  * the profile, or the reading threw — or `null` for each one that was taken.
348
350
  */
349
351
  readErrors: { bridge: string | null; gap: string | null; marks: string | null; marksArea: string | null };
350
352
  evaluated: boolean;
353
+ /** A routed part's (`process: "cnc-router"`) operations table; absent on a laser part. */
354
+ routed?: RoutedFacts;
355
+ }
356
+
357
+ /** A routed part's operations table, as the README's ROUTER OPERATIONS block prints it. Sizes in mm to two decimals. */
358
+ export interface RoutedFacts {
359
+ profile: { w: number; h: number };
360
+ /** One row per non-empty pocket. */
361
+ pockets: Array<{ label: string | null; depth: number; w: number; h: number; area: number }>;
362
+ /** One row per tool setting (d, depth, through), labels joined with ", ". */
363
+ drills: Array<{ label: string | null; d: number; depth: number; through: boolean; count: number }>;
364
+ /** One row per extra op with a non-empty footprint; `maxDepth` is how far its solid reaches below the machined face, within the stock. */
365
+ extra: Array<{ label: string; method: string; w: number; h: number; maxDepth: number }>;
366
+ /** How many pockets, drills and extra footprints leave the profile. */
367
+ outside: number;
368
+ /** How many pockets and extra footprints resolved to an empty region. */
369
+ empty: number;
351
370
  }
352
371
 
353
372
  export interface SubPartFacts {