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
@@ -393,7 +393,9 @@ meta: { title: "…", units: "mm", environment: "studio" },
393
393
  marked tintable are normally coloured this way (anodizing, plastic, paint);
394
394
  the others have an intrinsic colour (brass) but still accept one. (A viewer
395
395
  user PICKING an untintable preset drops an authored tint for that pick — see
396
- the runtime section below.)
396
+ the runtime section below.) On a TEXTURED preset (`oak`, `walnut`, `plywood`,
397
+ `pine-plywood`) `color` multiplies the scanned wood and dulls its grain — leave
398
+ it unset unless you mean a stain (lint warns: `material-tint-textured`).
397
399
  - Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
398
400
  `clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
399
401
  Transmission, index of refraction, sheen and iridescence come only from a preset.
@@ -434,10 +436,10 @@ modelled in its print orientation, not the material. Wood, carbon fibre and SLS
434
436
  fixed to the sub-part, so they never slide when the camera or an animation
435
437
  moves.
436
438
 
437
- **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
439
+ **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `pine-plywood`, `oak` or
438
440
  `walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
439
441
  — shows what the laser did: its cut edges are charred, darker on thicker stock (on
440
- `plywood` the plies show through), and its engraving and score lines are scorched, while its
442
+ `plywood` and `pine-plywood` the plies show through), and its engraving and score lines are scorched, while its
441
443
  faces stay wood. There is nothing to set, and it is still one material: the char follows
442
444
  from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
443
445
  with a custom `build` or a `views` pose of its own shows plain wood. The CAD view and every export are
@@ -451,7 +453,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
451
453
  | --- | --- | --- | --- |
452
454
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
453
455
  | SLS grain | `nylon-sls` | grain size | 0.15 |
454
- | wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
456
+ | wood | `oak`, `walnut`, `plywood`, `pine-plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`), 1000 (`pine-plywood`, sized for whole 4 × 8 sheets) |
455
457
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
456
458
 
457
459
  Presets without a pattern ignore it.
@@ -488,6 +490,7 @@ the viewer's blue-grey.
488
490
  | `oak` | Oak | — | Light oak with open grain. |
489
491
  | `walnut` | Walnut | — | Dark oiled walnut. |
490
492
  | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
493
+ | `pine-plywood` | Sanded pine plywood | — | Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on laser-cut edges. |
491
494
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
492
495
 
493
496
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -1906,7 +1909,7 @@ plate: sheetPart({
1906
1909
  It returns an ordinary sub-part: `build`, the pose's placement and a plain-data `sheet`. A field
1907
1910
  needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
1908
1911
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1909
- `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1912
+ `process` (default `"laser"`; `"cnc-router"` is `routedPart`, below) are optional; `label`, `views`, `display`, `export`,
1910
1913
  `enabled`, `exportable`, `reference` pass through; your `views` poses apply after the `pose`, in the viewer only. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1911
1914
  `quantity`; each error names the fix.
1912
1915
 
@@ -2020,7 +2023,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
2020
2023
 
2021
2024
  ### The kit
2022
2025
 
2023
- A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"` or `"service"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
2026
+ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"`, `"service"` or `"cnc-router"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
2024
2027
 
2025
2028
  ### Limits
2026
2029
 
@@ -2150,6 +2153,112 @@ export default {
2150
2153
  };
2151
2154
  ```
2152
2155
 
2156
+ ## Routed parts
2157
+
2158
+ CNC-routed parts from flat stock — hardwood, plywood, MDF, acrylic — for a 2.5D router
2159
+ CAM (Vectric VCarve, Carbide Create, Easel, Carveco): a through cut, flat-floored
2160
+ pockets at a depth, drilled holes at a depth, and operations done with another tool
2161
+ afterwards. Build each piece with `routedPart()` from `partforge/geometry`; the cut &
2162
+ print kit's **CNC router** destination writes one DXF per piece with one layer per depth,
2163
+ and a README table of every operation. A routed part is also the 3D sub-part — assembly,
2164
+ renders and STEP all come from the one declaration — so model it once.
2165
+
2166
+ ### When a sub-part is a routed part
2167
+
2168
+ When it is machined from one face of a flat board: an outline, pockets with flat floors,
2169
+ holes, all straight down. A sculpted surface, a part machined from both faces, or
2170
+ anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
2171
+ plate is `sheetPart`.
2172
+
2173
+ ### routedPart
2174
+
2175
+ ```js
2176
+ import { routedPart, roundedRectProfile } from "partforge/geometry";
2177
+
2178
+ plinth: routedPart({
2179
+ label: "Plywood base", views: { assembly: true, base: true }, display: { material: "pine-plywood" },
2180
+ material: "pine plywood", // stock label: groups pieces in the kit
2181
+ thickness: (p) => p.baseThickness, // the MEASURED board, from a control
2182
+ profile: (k, p) => k.shape2d(roundedRectProfile(p.baseLength, p.baseWidth, 10)), // the THROUGH cut
2183
+ pockets: (k, p, d) => [
2184
+ { region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
2185
+ { region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
2186
+ ],
2187
+ drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
2188
+ extra: (k, p, d) => [{
2189
+ solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
2190
+ footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
2191
+ label: "domino rack slot", method: `table saw, blade tilted ${p.rackTilt}°, 9.5 mm deep`,
2192
+ }],
2193
+ reference: (k, p) => [[[-p.baseLength / 2 + 10, 0], [p.baseLength / 2 - 10, 0]]], // a centerline
2194
+ }),
2195
+ ```
2196
+
2197
+ `material`, `thickness` and `profile` are required; `pockets`, `drills`, `extra`,
2198
+ `reference` and `pose` are optional; `label`, `views`, `display`, `export`, `enabled`,
2199
+ `exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
2200
+ `(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
2201
+ `engrave` and `process`; each error names the fix. The drawing is in XY with the
2202
+ machined face at z = `thickness`; `pose` places it exactly as a sheet part's does.
2203
+
2204
+ ### Pockets, drills, extra ops and reference lines
2205
+
2206
+ - **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined `depth`
2207
+ mm down from the machined face, flat-floored. `depth` may equal `thickness` (a
2208
+ through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
2209
+ two decimals share a DXF layer.
2210
+ - **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
2211
+ Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
2212
+ lint error. Written as a DXF `CIRCLE`, which is what lets
2213
+ the CAM offer a drilling toolpath instead of a profile cut.
2214
+ - **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router cannot
2215
+ do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is cut in
2216
+ the preview and the STEP; `footprint` (its outline on the machined face) is drawn on
2217
+ the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
2218
+ operations table so the operator knows what to make and how ("core box bit, 20 mm
2219
+ radius"). Do not put a flat-floored pocket here to avoid stating its depth.
2220
+ - **`reference`** — the same entries as a sheet part's `score`: two points is a line,
2221
+ anything else a shape whose boundaries are drawn. Centerlines and layout marks, on
2222
+ the `REFERENCE` layer; they cut nothing (the preview grooves them 0.2 mm so they show).
2223
+
2224
+ Keep these functions chainable — no branching on `isEmpty()`/`area()` — so pose-only
2225
+ sliders stay fast. `null` entries are skipped; an empty pocket region draws nothing and
2226
+ is counted by `routedEmpty`, as is an extra op whose footprint is empty.
2227
+
2228
+ ### The drawing
2229
+
2230
+ One DXF per piece, R12, millimetres: `CUT` (colour 1) is the through cut — the outline and
2231
+ the profile's own holes; `POCKET_<depth>MM` one layer per distinct depth (colours 30, 40, 50, 20,
2232
+ 10); `DRILL_D<dia>_<depth>MM` or `DRILL_D<dia>_THRU` per diameter
2233
+ and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints;
2234
+ `REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
2235
+ are assigned per kit — pockets shallowest depth first, drills in the order they appear —
2236
+ so one layer name has one colour in every file. A palette cycles past five depths; the
2237
+ README's LAYERS legend (layer, colour, depth) is the authority. Kerf
2238
+ never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
2239
+ destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
2240
+ extra op with its depth and method.
2241
+
2242
+ ### What lint and verify check
2243
+
2244
+ Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defaults.
2245
+ Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
2246
+ laser's thickness range does not. A pocket deeper than the stock is a build error.
2247
+
2248
+ Verify volunteers three warnings on every routed part, none counting toward
2249
+ `declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
2250
+ profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`).
2251
+ Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
2252
+
2253
+ ### Limits
2254
+
2255
+ - One machined face. A part worked from both faces is two routed parts or a `build`.
2256
+ - No tool-radius, dogbone or inside-corner checks: the CAM owns those.
2257
+ - No depth per extra op beyond its footprint and the solid's deepest point; the
2258
+ `method` text is where the operator's instructions go.
2259
+ - A routed part is a sheet part of process `cnc-router` everywhere else: the sheet
2260
+ checks' budget, stock grouping and the kit's layout apply unchanged.
2261
+
2153
2262
  ## Convex hull
2154
2263
 
2155
2264
  `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
@@ -3147,8 +3256,9 @@ returns instead:
3147
3256
  stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
3148
3257
  `print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
3149
3258
  `destination: "service"` each piece is a cut-only `.dxf` plus a `-marks.dxf` for its
3150
- score and engrave lines, and the DXF sheets are for reference. Every `options` key is
3151
- optional: `destination` (`"own-laser"`, the default, or `"service"`), `kerf` (0–0.5 mm,
3259
+ score and engrave lines, and the DXF sheets are for reference. With `destination: "cnc-router"` every routed piece is one DXF carrying all its layers
3260
+ (depth per layer), laser pieces are written as for a service, and kerf must be 0. Every `options` key is
3261
+ optional: `destination` (`"own-laser"`, the default, `"service"` or `"cnc-router"`), `kerf` (0–0.5 mm,
3152
3262
  applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
3153
3263
  `sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
3154
3264
  mm when `stock` is omitted), `margin` (5 mm),
@@ -3858,7 +3968,10 @@ default to their stock"), `unknown-environment`
3858
3968
  (`meta.environment` not known; realistic mode uses `studio`),
3859
3969
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3860
3970
  or one of the six overrides; ignored), `material-override-clamped` (an override
3861
- outside its range, or not a number; clamped).
3971
+ outside its range, or not a number; clamped), `material-tint-textured` (a
3972
+ `display.color` on a textured preset that is not tintable — `oak`, `walnut`,
3973
+ `plywood`, `pine-plywood`: the colour multiplies the scanned wood and dulls its
3974
+ grain; drop it unless a stain is intended).
3862
3975
 
3863
3976
  **Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
3864
3977
  name the part's `imports` field doesn't declare — this throws at build time;
@@ -3958,6 +4071,9 @@ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `bui
3958
4071
  at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
3959
4072
  way.
3960
4073
 
4074
+ **Routed parts** — `routed-drill-depth` (error): a `routedPart` drill deeper than
4075
+ the stock at the defaults. It carries `pattern: "routed-parts"`.
4076
+
3961
4077
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
3962
4078
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
3963
4079
 
@@ -765,13 +765,15 @@ the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
765
765
  `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
766
766
  facet at mesh LOD on Manifold.
767
767
  - **Sheet parts** — `sheetPart` wraps a laser-cut piece into an ordinary sub-part
768
- (a generated `build` and `place` plus a plain-data `sheet` declaration); the
768
+ (a generated `build` and `place` plus a plain-data `sheet` declaration), and
769
+ `routedPart` is the same wrapper for a CNC-routed piece (process `cnc-router`); the
769
770
  joinery helpers `fingers`, `tabs`, `tSlots`, `sheetPanel`, `matchingSlots`,
770
771
  `fingerBox`, `printedTab`, `sheetHole` and the `JOINERY_SCREWS` table are pure
771
772
  functions returning plain data in this contract's input format; `sheetToWorld` /
772
773
  `worldToSheet` convert between a posed sheet's drawing and world coordinates. The
773
774
  generated build calls only ops from the tables above (`shape2d`, `extrude`,
774
- `offset`, `cut`, `cutAll`, `translate`, `rotate`), so a sheet part is portable by
775
+ `offset`, `cut`, `cutAll`, `translate`, `rotate`, plus `cylinder` for a routed part's
776
+ drills), so a sheet part is portable by
775
777
  construction too. None of these is a kernel op.
776
778
 
777
779
  **Profile validation on the way in** (0.112). `prism`, `extrude`, `revolve`, `sweep`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.150.1",
3
+ "version": "0.152.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@ import { resolveSheet } from "../sheet/resolve.js";
19
19
  import { processById, SHEET_METRICS } from "../process/registry.js";
20
20
  import { loadExporter } from "../process/exporters.js";
21
21
  import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
22
- import { canonicalDrawingKey } from "./drawing.js";
22
+ import { canonicalDrawingKey, routedPalette } from "./drawing.js";
23
23
  import { renderSvg } from "./svg.js";
24
24
  import { renderDxf } from "./dxf.js";
25
25
  import { layoutGroup, marginFitsStock } from "./layout.js";
@@ -140,7 +140,6 @@ const wordsOf = (s) => String(s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").re
140
140
  // are listed, and "display", "outfit" and "profit" are not.
141
141
  const CLEARANCE_WORDS = new Set(["clearance", "clearances", "fit", "play"]);
142
142
  const isClearance = (c) => [...wordsOf(c.key), ...wordsOf(c.label)].some((w) => CLEARANCE_WORDS.has(w));
143
- const ALL_LAYERS = ["cut", "score", "engrave"];
144
143
 
145
144
  // Every control that owns a parameter key, whatever the section shape: authored
146
145
  // `controls` (groups nest), and the legacy `advanced`, `toggles`, `features` and a
@@ -193,6 +192,29 @@ function sheetMetaOrThrow(kernel, sp, p, d, name) {
193
192
  throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
194
193
  }
195
194
 
195
+ // The README's layer legend: every routed layer name in the kit, once, with its colour
196
+ // (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then drills in
197
+ // palette order, then pockets shallowest first. The palette is kit-wide, so a name has
198
+ // one colour; the legend is the authority when a palette cycles.
199
+ const LEGEND_RANK = { reference: 0, secondary: 1, drill: 2, pocket: 3 };
200
+ const legendKind = (l) => (l.id.startsWith("drill-") ? "drill" : l.id.startsWith("pocket-") ? "pocket" : l.id);
201
+ function routedLegend(pieces, cutExt) {
202
+ const rows = new Map();
203
+ for (const pc of pieces) {
204
+ for (const l of pc.drawing.layers) {
205
+ if (l.name === undefined || rows.has(l.name)) continue;
206
+ const kind = legendKind(l);
207
+ rows.set(l.name, { name: l.name, kind, order: rows.size,
208
+ colour: cutExt === "svg" ? l.svgColor : String(l.color),
209
+ depth: l.through ? "through" : typeof l.depth === "number" ? `${l.depth.toFixed(2)} mm` : "—", mm: l.depth ?? 0 });
210
+ }
211
+ }
212
+ return [...rows.values()]
213
+ .sort((a, b) => (LEGEND_RANK[a.kind] ?? 9) - (LEGEND_RANK[b.kind] ?? 9)
214
+ || (a.kind === "pocket" ? a.mm - b.mm : 0) || a.order - b.order)
215
+ .map(({ name, colour, depth }) => ({ name, colour, depth }));
216
+ }
217
+
196
218
  // One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
197
219
  // definition order — and `posed`/`label`/`exportName` are jobs.js's own, so a kit's
198
220
  // printed parts are built exactly the way the STL and 3MF exports build them.
@@ -201,7 +223,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
201
223
  // not wait behind a build (spec D.3). An absent options object means every default.
202
224
  const o = validateKitOptions(msg.options ?? {});
203
225
  const service = o.destination === "service";
204
- const cutExt = service ? "dxf" : "svg";
226
+ const router = o.destination === "cnc-router";
227
+ const cutExt = o.cutFormat; // svg for own-laser, dxf otherwise
205
228
  const say = (phase) => onProgress(clipPhase(phase));
206
229
  const nameOf = (name) => cleanLabel(label(name));
207
230
  if (names.length === 0) throw new Error("no exportable parts selected");
@@ -240,23 +263,36 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
240
263
 
241
264
  // Draw every sheet part flat, from its 2-D declaration, with its 2-D facts under ONE
242
265
  // budget for the whole kit.
266
+ // Resolved first, drawn second: a routed layer's colour comes from a palette over
267
+ // EVERY routed piece's depths (drawing.js routedPalette), so one depth is one colour
268
+ // in every file of the kit and on every sheet.
243
269
  const deadline = Date.now() + SHEET_CHECK_BUDGET_MS;
244
- const drawn = [];
270
+ const resolved = [];
245
271
  for (const name of sheetNames) {
246
272
  say(`building ${nameOf(name)}`);
247
273
  const s = resolveSheet(kernel, part.parts[name], p, d);
248
274
  const desc = processById(s.process);
249
275
  if (!desc) throw new Error(`sheet part "${nameOf(name)}": unknown process "${s.process}"`);
250
- const facts = desc.facts(s, { deadline });
276
+ resolved.push({ name, s, facts: desc.facts(s, { deadline }) });
277
+ }
278
+ const palette = routedPalette(resolved.filter((x) => x.s.process === "cnc-router").map((x) => x.s));
279
+ const drawn = [];
280
+ for (const { name, s, facts } of resolved) {
251
281
  const exporter = await loadExporter(s.process);
252
- const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts });
282
+ const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts, palette });
283
+ // A routed piece is known by its process, never guessed from its layers: one with
284
+ // only a profile draws no named layer and is still routed.
253
285
  drawn.push({ name, label: nameOf(name), fileKey: exportName(name), group: groupOf.get(name),
254
- drawing, facts, key: canonicalDrawingKey(drawing) });
286
+ drawing, facts, key: canonicalDrawingKey(drawing), routed: s.process === "cnc-router" });
255
287
  }
256
288
 
257
289
  // Identical pieces become one file ×N (spec D.4): the hash nominates; the canonical
258
290
  // drawing and the stock group confirm.
259
- const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group)
291
+ // A routed piece's operations table is confirmed too: an extra op's depth and method,
292
+ // and the op labels, are not in its drawing — only the README carries them, so two
293
+ // pieces that draw alike but machine differently must stay two files.
294
+ const sameOps = (a, b) => JSON.stringify(a.facts?.routed ?? null) === JSON.stringify(b.facts?.routed ?? null);
295
+ const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group && sameOps(a, b))
260
296
  .map(({ item, members }) => ({
261
297
  ...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
262
298
  hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
@@ -283,7 +319,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
283
319
  };
284
320
  for (const pc of pieces) {
285
321
  const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
286
- const marks = service && pc.hasMarks;
322
+ // A routed piece is never split: its depths are one layered file for the CAM.
323
+ const marks = (service || router) && pc.hasMarks && !pc.routed;
287
324
  const name = claim(kitName(pc.fileKey),
288
325
  (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
289
326
  pc.file = `parts/${name}${sx}.${cutExt}`;
@@ -301,7 +338,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
301
338
  say(`laying out ${g.material} ${fmtMm(g.thickness)} mm (${count} pieces)`);
302
339
  const stock = stockFor.get(g.group);
303
340
  const laid = layoutGroup({
304
- group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !service,
341
+ group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !(service || router),
305
342
  pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
306
343
  });
307
344
  if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
@@ -354,20 +391,23 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
354
391
  if (!pp.bytes) pp.bytes = new Uint8Array(meshTo3MF([{ name: pp.fileKey, ...pp.mesh, color: pp.color }]));
355
392
  }
356
393
 
357
- // The files (spec D.8): own laser — an SVG per sheet and per piece; service — a
358
- // cut-only DXF per piece, a -marks.dxf where it has marks, and reference DXF sheets.
394
+ // The files (spec D.8): own laser — an SVG per sheet and per piece; service and CNC
395
+ // router — a cut-only DXF per laser piece, a -marks.dxf where it has marks, and
396
+ // reference DXF sheets. A routed piece is one file with every layer, whatever the
397
+ // destination.
359
398
  say("writing cut files");
360
399
  const title = cleanLabel(part.meta?.title ?? fileBase);
361
400
  const files = [];
362
401
  const put = (file, text) => files.push({ file, data: strToU8(text) });
363
402
  for (const sh of sheets) {
364
403
  const doc = { title: `${title} — ${sh.group.label} — sheet ${sh.index} of ${sh.count}`, size: sh.stock, placements: sh.placements };
365
- put(sh.file, service ? renderDxf(doc, { include: ALL_LAYERS }) : renderSvg(doc));
404
+ put(sh.file, cutExt === "dxf" ? renderDxf(doc) : renderSvg(doc));
366
405
  }
367
406
  for (const pc of pieces) {
368
407
  const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
369
408
  placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
370
- if (!service) { put(pc.file, renderSvg(doc)); continue; }
409
+ if (cutExt === "svg") { put(pc.file, renderSvg(doc)); continue; }
410
+ if (pc.routed) { put(pc.file, renderDxf(doc)); continue; } // every layer, one file
371
411
  put(pc.file, renderDxf(doc, { include: ["cut"] }));
372
412
  if (pc.marksFile) put(pc.marksFile, renderDxf(doc, { include: ["score", "engrave"] }));
373
413
  }
@@ -383,6 +423,9 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
383
423
  size: sh.stock, pieces: sh.placements.length, used: sh.used })),
384
424
  pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
385
425
  thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
426
+ router: pieces.filter((pc) => pc.routed && pc.facts?.routed).map((pc) => ({
427
+ labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed })),
428
+ layers: routedLegend(pieces.filter((pc) => pc.routed), cutExt),
386
429
  printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
387
430
  clearances: controls.filter(isClearance)
388
431
  .map((c) => ({ label: c.label ?? c.key, value: p[c.key], unit: c.unit ?? "" })),
@@ -354,3 +354,41 @@ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } =
354
354
  if (arcCount(out) < arcCount(regions)) throw new Error(intact);
355
355
  return out;
356
356
  }
357
+
358
+ // ── routed layers (process/cnc-router) ────────────────────────────────────────
359
+ // Depth in millimetres as a layer-name token: up to two decimals, trailing zeros
360
+ // dropped (11.43 → "11.43", 3 → "3"). Two depths that round alike share a layer.
361
+ export const depthToken = (mm) => String(Number(mm.toFixed(2)));
362
+ export function routedLayerName(kind, op) {
363
+ if (kind === "pocket") return `POCKET_${depthToken(op.depth)}MM`;
364
+ if (kind === "drill") return op.through ? `DRILL_D${depthToken(op.d)}_THRU` : `DRILL_D${depthToken(op.d)}_${depthToken(op.depth)}MM`;
365
+ throw new Error(`routedLayerName: unknown kind ${JSON.stringify(kind)}`);
366
+ }
367
+ // A drill's tool setting as a key: diameter and depth (or THRU), two-decimal tokens —
368
+ // one layer per key, and the kit-wide palette's drill key.
369
+ export const drillKey = (dr) => `D${depthToken(dr.d)}-${dr.through ? "THRU" : depthToken(dr.depth)}`;
370
+ // Palettes: ACI colours for DXF, hex for SVG. Drills cycle; pockets are assigned by
371
+ // ascending depth (shallowest first). In a kit the assignment is kit-wide
372
+ // (routedPalette), so one depth is one colour in every file.
373
+ export const ROUTED_COLORS = Object.freeze({
374
+ reference: { color: 8, svgColor: "#808080" },
375
+ secondary: { color: 6, svgColor: "#FF00FF" },
376
+ drill: Object.freeze([{ color: 3, svgColor: "#00AA00" }, { color: 4, svgColor: "#00AAAA" }, { color: 2, svgColor: "#AAAA00" }, { color: 140, svgColor: "#0055AA" }, { color: 170, svgColor: "#5500AA" }]),
377
+ pocket: Object.freeze([{ color: 30, svgColor: "#E07000" }, { color: 40, svgColor: "#E0A000" }, { color: 50, svgColor: "#C0C000" }, { color: 20, svgColor: "#A06000" }, { color: 10, svgColor: "#804000" }]),
378
+ });
379
+
380
+ // The kit-wide palette over every routed piece's resolved operations, so a layer name
381
+ // carries one colour in every file of the kit: pocket depth tokens ranked ascending
382
+ // (shallowest first), drill keys in first-seen order, each cycling its palette.
383
+ // `sheets` are resolved routed sheets, in definition order.
384
+ export function routedPalette(sheets) {
385
+ const pocketTokens = new Set(), drillKeys = new Set();
386
+ for (const s of sheets) {
387
+ for (const pk of s.routed.pockets) if (!pk.region.isEmpty()) pocketTokens.add(depthToken(pk.depth));
388
+ for (const dr of s.routed.drills) drillKeys.add(drillKey(dr));
389
+ }
390
+ const pocket = new Map([...pocketTokens].sort((a, b) => Number(a) - Number(b))
391
+ .map((tok, i) => [tok, ROUTED_COLORS.pocket[i % ROUTED_COLORS.pocket.length]]));
392
+ const drill = new Map([...drillKeys].map((key, i) => [key, ROUTED_COLORS.drill[i % ROUTED_COLORS.drill.length]]));
393
+ return { pocket, drill };
394
+ }
@@ -111,13 +111,50 @@ function writeEntity(g, layer, path, place) {
111
111
  }
112
112
 
113
113
  // `include` picks the layers: a cutting service gets one cut-only file per piece and a
114
- // separate marks file (["score", "engrave"]). Only the included layers are declared.
115
- export function renderDxf(doc, { include = INCLUDES } = {}) {
116
- for (const inc of include)
114
+ // separate marks file (["score", "engrave"]). Only the written layers are declared.
115
+ // A layer carrying its own `name` (a routed layer) is written under that name and
116
+ // colour; one without is a laser layer under LAYER_FOR/DXF_LAYERS. `include`
117
+ // undefined writes every layer; a list writes only the named laser classes, so a
118
+ // cutting service's cut-only and marks files never carry a routed layer by accident.
119
+ const NAME_RE = /^[A-Z0-9_.]{1,31}$/;
120
+ function layerNameOf(layer) {
121
+ if (layer.name === undefined) return LAYER_FOR[layer.id];
122
+ if (!NAME_RE.test(layer.name) || !Number.isInteger(layer.color)) throw new Error(`renderDxf: layer ${JSON.stringify(layer.id)} needs a DXF name (A-Z, 0-9, _ .) and an integer colour`);
123
+ return layer.name;
124
+ }
125
+
126
+ export function renderDxf(doc, { include } = {}) {
127
+ const all = include === undefined;
128
+ for (const inc of include ?? [])
117
129
  if (!INCLUDES.includes(inc)) throw new Error(`renderDxf: include lists "cut", "score" and "engrave" only, got ${JSON.stringify(inc)}`);
118
- const want = new Set(include);
119
- const ids = ORDER.filter((id) => want.has(INCLUDE_FOR[id]));
120
- const names = [...new Set(ids.map((id) => LAYER_FOR[id]))];
130
+ const want = new Set(include ?? INCLUDES);
131
+ const selected = (layer) => (layer.name !== undefined ? all : want.has(INCLUDE_FOR[layer.id]));
132
+ // Entity order: engrave, score, every custom layer in first-seen order across the
133
+ // placements, then cut-inner and cut-outer — a routed piece's holes and pockets are
134
+ // milled while the sheet still holds it, and the outline frees it last.
135
+ const custom = [];
136
+ for (const placement of doc.placements) for (const layer of placement.drawing.layers)
137
+ if (layer.name !== undefined && selected(layer) && !custom.includes(layer.id)) custom.push(layer.id);
138
+ const ids = [...ORDER.filter((id) => !id.startsWith("cut") && want.has(INCLUDE_FOR[id])), ...custom, ...ORDER.filter((id) => id.startsWith("cut") && want.has(INCLUDE_FOR[id]))];
139
+ // The LAYER table, in the entity order. A laser-only document (no named layer written)
140
+ // declares every INCLUDED laser class whether or not a placement draws on it — the
141
+ // laser writer's bytes, unchanged. A document with a named (routed) layer declares
142
+ // only the layers actually present, laser classes included, so a router file carries
143
+ // no empty ENGRAVE/SCORE rows.
144
+ const present = (id) => doc.placements.some((pl) => pl.drawing.layers.some((l) => l.id === id));
145
+ const onlyPresent = custom.length > 0;
146
+ const names = [], colors = new Map();
147
+ const declare = (name, color) => { if (!names.includes(name)) { names.push(name); colors.set(name, color); } };
148
+ for (const id of ids) {
149
+ if (LAYER_FOR[id] !== undefined) {
150
+ if (!onlyPresent || present(id)) declare(LAYER_FOR[id], DXF_LAYERS[LAYER_FOR[id]]);
151
+ continue;
152
+ }
153
+ for (const placement of doc.placements) {
154
+ const layer = placement.drawing.layers.find((l) => l.id === id);
155
+ if (layer) declare(layerNameOf(layer), layer.color);
156
+ }
157
+ }
121
158
  const out = [];
122
159
  const g = (code, value) => { out.push(String(code).padStart(3), String(value)); };
123
160
 
@@ -131,7 +168,7 @@ export function renderDxf(doc, { include = INCLUDES } = {}) {
131
168
  g(0, "LTYPE"); g(2, "CONTINUOUS"); g(70, 0); g(3, "Solid line"); g(72, 65); g(73, 0); g(40, "0.0");
132
169
  g(0, "ENDTAB");
133
170
  g(0, "TABLE"); g(2, "LAYER"); g(70, names.length);
134
- for (const name of names) { g(0, "LAYER"); g(2, name); g(70, 0); g(62, DXF_LAYERS[name]); g(6, "CONTINUOUS"); }
171
+ for (const name of names) { g(0, "LAYER"); g(2, name); g(70, 0); g(62, colors.get(name)); g(6, "CONTINUOUS"); }
135
172
  g(0, "ENDTAB");
136
173
  g(0, "ENDSEC");
137
174
 
@@ -141,7 +178,7 @@ export function renderDxf(doc, { include = INCLUDES } = {}) {
141
178
  const layer = placement.drawing.layers.find((l) => l.id === id);
142
179
  if (!layer) continue;
143
180
  const place = placer(placement);
144
- for (const path of layer.paths) writeEntity(g, LAYER_FOR[id], path, place);
181
+ for (const path of layer.paths) writeEntity(g, layerNameOf(layer), path, place);
145
182
  }
146
183
  }
147
184
  g(0, "ENDSEC");
@@ -21,9 +21,10 @@ export const EXPORT_FORMATS = Object.freeze([
21
21
  Object.freeze({ id: "bundle", label: "Cut and print kit", ext: "zip", mime: "application/zip", needsSheet: true }),
22
22
  ]);
23
23
 
24
- // Destination → the cut-file format it writes. Pinned equal to the laser descriptor's
25
- // `destinations` by test/kit-formats.test.js — two spellings of one fact.
26
- export const DESTINATIONS = Object.freeze({ "own-laser": "svg", service: "dxf" });
24
+ // Destination → the cut-file format it writes. Pinned equal to the union of every
25
+ // process descriptor's `destinations` by test/routed-kit.test.js and
26
+ // test/kit-formats.test.js — one fact, two spellings.
27
+ export const DESTINATIONS = Object.freeze({ "own-laser": "svg", service: "dxf", "cnc-router": "dxf" });
27
28
 
28
29
  // 300 × 300 mm until the paying user's machine is known (design spec decision 12).
29
30
  export const DEFAULT_STOCK_SIZE = Object.freeze([300, 300]);
@@ -64,7 +65,7 @@ export function validateKitOptions(o) {
64
65
 
65
66
  const destination = pick("destination");
66
67
  if (typeof destination !== "string" || !Object.hasOwn(DESTINATIONS, destination))
67
- throw fail(`destination must be "own-laser" or "service", got ${JSON.stringify(destination)}`);
68
+ throw fail(`destination must be "own-laser", "service" or "cnc-router", got ${JSON.stringify(destination)}`);
68
69
  const cutFormat = DESTINATIONS[destination];
69
70
  if (opts.cutFormat !== undefined && opts.cutFormat !== cutFormat)
70
71
  throw fail(`cutFormat ${JSON.stringify(opts.cutFormat)} does not match destination "${destination}" (which writes ${cutFormat})`);
@@ -72,6 +73,8 @@ export function validateKitOptions(o) {
72
73
  const kerf = pick("kerf");
73
74
  if (!inRange(kerf, KIT_LIMITS.kerf))
74
75
  throw fail(`kerf must be a number from ${KIT_LIMITS.kerf[0]} to ${KIT_LIMITS.kerf[1]} mm, got ${JSON.stringify(kerf)}`);
76
+ if (destination === "cnc-router" && kerf > 0)
77
+ throw fail("kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0");
75
78
  const colors = pick("colors");
76
79
  if (colors !== "lightburn") throw fail(`colors must be "lightburn", got ${JSON.stringify(colors)}`);
77
80
  const stock = normalizeStock(opts.stock);
@@ -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
  };