partforge 0.150.1 → 0.152.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.
Files changed (32) hide show
  1. package/docs/AUTHORING-PARTS.md +125 -9
  2. package/docs/KERNEL-CONTRACT.md +4 -2
  3. package/package.json +1 -1
  4. package/src/framework/export/bundle.js +57 -14
  5. package/src/framework/export/drawing.js +38 -0
  6. package/src/framework/export/dxf.js +45 -8
  7. package/src/framework/export/formats.js +7 -4
  8. package/src/framework/export/readme.js +24 -7
  9. package/src/framework/export/svg.js +14 -2
  10. package/src/framework/geometry/polygon.js +1 -0
  11. package/src/framework/lint/rules-materials.js +18 -1
  12. package/src/framework/materials/assets/SOURCES.md +6 -2
  13. package/src/framework/materials/assets/pattern-pine-plywood-color.jpg +0 -0
  14. package/src/framework/materials/assets/pattern-pine-plywood-normal.jpg +0 -0
  15. package/src/framework/materials/assets/pattern-pine-plywood-rough.jpg +0 -0
  16. package/src/framework/materials/assets.js +3 -0
  17. package/src/framework/materials/presets.js +8 -0
  18. package/src/framework/oracle/measure.js +2 -2
  19. package/src/framework/process/cnc-router/descriptor.js +174 -0
  20. package/src/framework/process/cnc-router/export.js +74 -0
  21. package/src/framework/process/exporters.js +1 -0
  22. package/src/framework/process/registry.js +2 -1
  23. package/src/framework/sheet/constants.js +7 -0
  24. package/src/framework/sheet/part.js +19 -8
  25. package/src/framework/sheet/resolve.js +13 -2
  26. package/src/framework/sheet/routed-resolve.js +62 -0
  27. package/src/framework/sheet/routed.js +44 -0
  28. package/src/parts/material-swatches.js +2 -2
  29. package/types/geometry.d.ts +44 -1
  30. package/types/index.d.ts +2 -2
  31. package/types/part.d.ts +6 -0
  32. package/types/testing.d.ts +21 -2
@@ -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";
@@ -27,10 +27,27 @@ export const MATERIAL_RULES = [
27
27
  const fallback = stock ? `in realistic mode \`${stock}\`${why}` : "a PLA print in realistic mode";
28
28
  return warn("unknown-material",
29
29
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
30
- `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
30
+ `${near ? `Did you mean "${near}"? ` : ""}It is drawn as if it named none (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
31
31
  `parts.${name}.display.material`);
32
32
  }),
33
33
  },
34
+ {
35
+ // A `color` on a scanned-texture preset that is not tintable MULTIPLIES the scan
36
+ // (physical.js), so the grain goes muddy — usually an agent "helping" a wood look
37
+ // with a brown. A warning, not an error: a deliberate stain is legitimate.
38
+ id: "material-tint-textured",
39
+ run: ({ part }) => subParts(part).flatMap(([name, sp]) => {
40
+ const d = sp.display;
41
+ if (!d || typeof d !== "object" || d.color == null || typeof d.material !== "string") return [];
42
+ if (!Object.hasOwn(PRESETS, d.material)) return [];
43
+ const preset = PRESETS[d.material];
44
+ if (!preset.textures || preset.tintable) return [];
45
+ return [warn("material-tint-textured",
46
+ `sub-part "${name}" sets display.color on "${d.material}", a textured material: the colour multiplies the scanned ${preset.label.toLowerCase()} and dulls its grain`,
47
+ `Drop display.color to show ${d.material}'s own colour and grain — the preset already carries it, in the CAD view too. Keep color only for a deliberate stain.`,
48
+ `parts.${name}.display.color`)];
49
+ }),
50
+ },
34
51
  {
35
52
  id: "unknown-environment",
36
53
  run: ({ part }) => {
@@ -69,6 +69,9 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
69
69
  | `pattern-plywood-color.jpg` | Poly Haven `plywood` (colour; API key `Diffuse`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_diff_1k.jpg | 2026-09-30 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture plywood_diff_1k.jpg pattern-plywood-color.jpg --modulate 1.85,0.9 --flatten 0.55 --quality 85` (lightened 1.85x and saturation 0.9 from the scan's mid brown to a pale birch; grain contrast 0.55, since lightening doubles it). One continuous veneer, fine even grain running across the image (`grain: "u"`). Chosen 2026-09-30 from five approved CC0 candidates: ambientCG Wood090A, Wood090B, Wood091A and Wood091B (https://ambientcg.com/a/<id>) are edge-glued strip panels with visible parallel joints, not a veneer face; Wood087/088/089 (checked earlier the same day) show plywood edges. |
70
70
  | `pattern-plywood-normal.jpg` | Poly Haven `plywood` (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_nor_gl_1k.jpg | 2026-09-30 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture plywood_nor_gl_1k.jpg pattern-plywood-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint). The map leans slightly (mean red 118/255, a ~4° tilt along the grain) as the source does; kept as authored. |
71
71
  | `pattern-plywood-rough.jpg` | Poly Haven `plywood` (roughness; API key `Rough`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_rough_1k.jpg | 2026-09-30 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture plywood_rough_1k.jpg pattern-plywood-rough.jpg --gray` (mean 191/255 = 0.749, the preset's `roughnessMean`). |
72
+ | `pattern-pine-plywood-color.jpg` | ambientCG "Wood 048" (Color, `Wood048_1K-JPG_Color.jpg` from the 1K-JPG zip) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_Color.jpg pattern-pine-plywood-color.jpg --modulate 1.25,1.3 --flatten 0.9 --quality 85` (lightened 1.25x and saturation 1.3 from the scan's dull olive-tan toward a sanded pine/fir sheet's warm yellow; grain contrast 0.9 keeps the bold figure). Mean `224,183,125` (`0xe0b77d`, the preset's `color`). Scanned tile 800 x 800 mm (ambientCG `dimensionX/Y`). One continuous softwood face with bold cathedral/flame figure running across the image (`grain: "u"`), seamless. Chosen 2026-10-15 as the hardware-store sheet look (the birch `plywood` set is pale and fine-grained). Rejected: ambientCG Wood047 (closest rotary-fir figure, but an 80 x 40 cm 2:1 scan the square bake would crop in half), Wood050 (same family, pinkish cast), Wood096 and Wood031 (knotty board, not a sanded face); Poly Haven `coated_pine`/`coated_pine_02` (glossy varnished knotty pine), `kitchen_wood` (grey, fine), `okoume_veneer`/`angli_veneer` (pink-brown, near-featureless hardwood); the plywood/Chipboard rows ambientCG tags `plywood` show plywood edges or particle board, not a face. |
73
+ | `pattern-pine-plywood-normal.jpg` | ambientCG "Wood 048" (NormalGL, `Wood048_1K-JPG_NormalGL.jpg`) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_NormalGL.jpg pattern-pine-plywood-normal.jpg --data --quality 88` (4:4:4 chroma, no grayscale/tint). q88, not the other wood sets' q95: this map carries about twice birch's relief (stdev 6.8 vs 3.4 /255), so its 8x8 blocks are far below the grain, and q95 (~640 KB) and q90 (~412 KB) both broke the 400 KB pattern budget; q88 is 37.7 dB PSNR against the source. |
74
+ | `pattern-pine-plywood-rough.jpg` | ambientCG "Wood 048" (Roughness, `Wood048_1K-JPG_Roughness.jpg`) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_Roughness.jpg pattern-pine-plywood-rough.jpg --gray` (mean 141/255 = 0.552, the preset's `roughnessMean`). |
72
75
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
73
76
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
74
77
  | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
@@ -80,7 +83,8 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
80
83
 
81
84
  ambientCG publishes every asset under CC0 (https://ambientcg.com/, "CC0 Public Domain
82
85
  license", checked 2026-09-24; https://ambientcg.com/license, "Creative Commons CC0 1.0
83
- Universal License", checked again 2026-09-30 for the plywood candidates). The wood sets
86
+ Universal License", checked again 2026-09-30 for the plywood candidates and 2026-10-15 for
87
+ `pattern-pine-plywood-*`, Wood048). The wood sets
84
88
  (`pattern-oak-*`, `pattern-walnut-*`, `pattern-plywood-*`, `ground-maple-*`) are Poly Haven
85
89
  textures, CC0 like its HDRIs above (https://polyhaven.com/license, checked 2026-09-24, and
86
90
  again 2026-09-30 for `plywood`); the exact 1K JPG URLs came
@@ -92,5 +96,5 @@ The presets tile them smaller than the scanned size — see the comment on `oak`
92
96
 
93
97
  Environments stay under 1.5 MB (largest: `env-workshop.jpg` at ~398 KB); ground/pattern
94
98
  textures stay under 400 KB (largest: `ground-concrete-normal.jpg` at ~380 KB; the largest
95
- pattern map is `pattern-plywood-normal.jpg` at ~337 KB). All committed
99
+ pattern map is `pattern-pine-plywood-normal.jpg` at ~353 KB). All committed
96
100
  outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.
@@ -28,6 +28,9 @@ const URLS = {
28
28
  "pattern-plywood-color.jpg": new URL("./assets/pattern-plywood-color.jpg", import.meta.url).href,
29
29
  "pattern-plywood-normal.jpg": new URL("./assets/pattern-plywood-normal.jpg", import.meta.url).href,
30
30
  "pattern-plywood-rough.jpg": new URL("./assets/pattern-plywood-rough.jpg", import.meta.url).href,
31
+ "pattern-pine-plywood-color.jpg": new URL("./assets/pattern-pine-plywood-color.jpg", import.meta.url).href,
32
+ "pattern-pine-plywood-normal.jpg": new URL("./assets/pattern-pine-plywood-normal.jpg", import.meta.url).href,
33
+ "pattern-pine-plywood-rough.jpg": new URL("./assets/pattern-pine-plywood-rough.jpg", import.meta.url).href,
31
34
  "pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
32
35
  };
33
36
 
@@ -90,6 +90,14 @@ export const PRESETS = {
90
90
  plywood: P("plywood", "Birch plywood", "natural", "Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges.",
91
91
  { color: 0xe1c1a1, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 150, laminated: true,
92
92
  textures: { color: "pattern-plywood-color.jpg", normal: "pattern-plywood-normal.jpg", roughness: "pattern-plywood-rough.jpg", roughnessMean: 0.749, normalScale: 1, grain: "u" } }),
93
+ // Hardware-store 4 x 8 sheet plywood, sanded pine/fir face (ambientCG Wood048). Its
94
+ // textureScale is set for whole sheets, not hand-sized parts: one tile per metre, so the
95
+ // bold cathedral figure does not visibly repeat across a 1220 mm panel (the scan itself
96
+ // is 800 mm; 1000 is the top of OVERRIDE_RANGES.textureScale). Plies show on laser-cut
97
+ // walls only — the ply bands ride the laser burn pass (sheet-look.js burnsFor).
98
+ "pine-plywood": P("pine-plywood", "Sanded pine plywood", "natural", "Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on laser-cut edges.",
99
+ { color: 0xe0b77d, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 1000, laminated: true,
100
+ textures: { color: "pattern-pine-plywood-color.jpg", normal: "pattern-pine-plywood-normal.jpg", roughness: "pattern-pine-plywood-rough.jpg", roughnessMean: 0.552, normalScale: 1, grain: "u" } }),
93
101
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
94
102
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
95
103
  };
@@ -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
+ }