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.
@@ -1906,7 +1906,7 @@ plate: sheetPart({
1906
1906
  It returns an ordinary sub-part: `build`, the pose's placement and a plain-data `sheet`. A field
1907
1907
  needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
1908
1908
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1909
- `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1909
+ `process` (default `"laser"`; `"cnc-router"` is `routedPart`, below) are optional; `label`, `views`, `display`, `export`,
1910
1910
  `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
1911
  `quantity`; each error names the fix.
1912
1912
 
@@ -2020,7 +2020,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
2020
2020
 
2021
2021
  ### The kit
2022
2022
 
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:`.
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"`, `"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
2024
 
2025
2025
  ### Limits
2026
2026
 
@@ -2150,6 +2150,112 @@ export default {
2150
2150
  };
2151
2151
  ```
2152
2152
 
2153
+ ## Routed parts
2154
+
2155
+ CNC-routed parts from flat stock — hardwood, plywood, MDF, acrylic — for a 2.5D router
2156
+ CAM (Vectric VCarve, Carbide Create, Easel, Carveco): a through cut, flat-floored
2157
+ pockets at a depth, drilled holes at a depth, and operations done with another tool
2158
+ afterwards. Build each piece with `routedPart()` from `partforge/geometry`; the cut &
2159
+ print kit's **CNC router** destination writes one DXF per piece with one layer per depth,
2160
+ and a README table of every operation. A routed part is also the 3D sub-part — assembly,
2161
+ renders and STEP all come from the one declaration — so model it once.
2162
+
2163
+ ### When a sub-part is a routed part
2164
+
2165
+ When it is machined from one face of a flat board: an outline, pockets with flat floors,
2166
+ holes, all straight down. A sculpted surface, a part machined from both faces, or
2167
+ anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
2168
+ plate is `sheetPart`.
2169
+
2170
+ ### routedPart
2171
+
2172
+ ```js
2173
+ import { routedPart, roundedRectProfile } from "partforge/geometry";
2174
+
2175
+ plinth: routedPart({
2176
+ label: "Hardwood base", views: { assembly: true, base: true }, display: { color: 0x6e3d23 },
2177
+ material: "walnut", // stock label: groups pieces in the kit
2178
+ thickness: (p) => p.baseThickness, // the MEASURED board, from a control
2179
+ profile: (k, p) => k.shape2d(roundedRectProfile(p.baseLength, p.baseWidth, 10)), // the THROUGH cut
2180
+ pockets: (k, p, d) => [
2181
+ { region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
2182
+ { region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
2183
+ ],
2184
+ drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
2185
+ extra: (k, p, d) => [{
2186
+ solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
2187
+ footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
2188
+ label: "domino rack slot", method: `table saw, blade tilted ${p.rackTilt}°, 9.5 mm deep`,
2189
+ }],
2190
+ reference: (k, p) => [[[-p.baseLength / 2 + 10, 0], [p.baseLength / 2 - 10, 0]]], // a centerline
2191
+ }),
2192
+ ```
2193
+
2194
+ `material`, `thickness` and `profile` are required; `pockets`, `drills`, `extra`,
2195
+ `reference` and `pose` are optional; `label`, `views`, `display`, `export`, `enabled`,
2196
+ `exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
2197
+ `(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
2198
+ `engrave` and `process`; each error names the fix. The drawing is in XY with the
2199
+ machined face at z = `thickness`; `pose` places it exactly as a sheet part's does.
2200
+
2201
+ ### Pockets, drills, extra ops and reference lines
2202
+
2203
+ - **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined `depth`
2204
+ mm down from the machined face, flat-floored. `depth` may equal `thickness` (a
2205
+ through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
2206
+ two decimals share a DXF layer.
2207
+ - **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
2208
+ Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
2209
+ lint error. Written as a DXF `CIRCLE`, which is what lets
2210
+ the CAM offer a drilling toolpath instead of a profile cut.
2211
+ - **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router cannot
2212
+ do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is cut in
2213
+ the preview and the STEP; `footprint` (its outline on the machined face) is drawn on
2214
+ the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
2215
+ operations table so the operator knows what to make and how ("core box bit, 20 mm
2216
+ radius"). Do not put a flat-floored pocket here to avoid stating its depth.
2217
+ - **`reference`** — the same entries as a sheet part's `score`: two points is a line,
2218
+ anything else a shape whose boundaries are drawn. Centerlines and layout marks, on
2219
+ the `REFERENCE` layer; they cut nothing (the preview grooves them 0.2 mm so they show).
2220
+
2221
+ Keep these functions chainable — no branching on `isEmpty()`/`area()` — so pose-only
2222
+ sliders stay fast. `null` entries are skipped; an empty pocket region draws nothing and
2223
+ is counted by `routedEmpty`, as is an extra op whose footprint is empty.
2224
+
2225
+ ### The drawing
2226
+
2227
+ One DXF per piece, R12, millimetres: `CUT` (colour 1) is the through cut — the outline and
2228
+ the profile's own holes; `POCKET_<depth>MM` one layer per distinct depth (colours 30, 40, 50, 20,
2229
+ 10); `DRILL_D<dia>_<depth>MM` or `DRILL_D<dia>_THRU` per diameter
2230
+ and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints;
2231
+ `REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
2232
+ are assigned per kit — pockets shallowest depth first, drills in the order they appear —
2233
+ so one layer name has one colour in every file. A palette cycles past five depths; the
2234
+ README's LAYERS legend (layer, colour, depth) is the authority. Kerf
2235
+ never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
2236
+ destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
2237
+ extra op with its depth and method.
2238
+
2239
+ ### What lint and verify check
2240
+
2241
+ Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defaults.
2242
+ Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
2243
+ laser's thickness range does not. A pocket deeper than the stock is a build error.
2244
+
2245
+ Verify volunteers three warnings on every routed part, none counting toward
2246
+ `declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
2247
+ profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`).
2248
+ Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
2249
+
2250
+ ### Limits
2251
+
2252
+ - One machined face. A part worked from both faces is two routed parts or a `build`.
2253
+ - No tool-radius, dogbone or inside-corner checks: the CAM owns those.
2254
+ - No depth per extra op beyond its footprint and the solid's deepest point; the
2255
+ `method` text is where the operator's instructions go.
2256
+ - A routed part is a sheet part of process `cnc-router` everywhere else: the sheet
2257
+ checks' budget, stock grouping and the kit's layout apply unchanged.
2258
+
2153
2259
  ## Convex hull
2154
2260
 
2155
2261
  `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
@@ -3147,8 +3253,9 @@ returns instead:
3147
3253
  stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
3148
3254
  `print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
3149
3255
  `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,
3256
+ 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
3257
+ (depth per layer), laser pieces are written as for a service, and kerf must be 0. Every `options` key is
3258
+ optional: `destination` (`"own-laser"`, the default, `"service"` or `"cnc-router"`), `kerf` (0–0.5 mm,
3152
3259
  applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
3153
3260
  `sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
3154
3261
  mm when `stock` is omitted), `margin` (5 mm),
@@ -3958,6 +4065,9 @@ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `bui
3958
4065
  at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
3959
4066
  way.
3960
4067
 
4068
+ **Routed parts** — `routed-drill-depth` (error): a `routedPart` drill deeper than
4069
+ the stock at the defaults. It carries `pattern: "routed-parts"`.
4070
+
3961
4071
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
3962
4072
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
3963
4073
 
@@ -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.151.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
  };
@@ -22,6 +22,7 @@ export const SVG_STROKE_MM = 0.025; // a hairline: laser software cuts it as a
22
22
  // The group order is the cut order — the same list as drawing.js's LAYER_ORDER, which
23
23
  // this leaf cannot import (drawing.js reaches paper); test/kit-svg.test.js pins them equal.
24
24
  const ORDER = Object.keys(SVG_COLORS);
25
+ const SVG_HEX = /^#[0-9A-F]{6}$/i;
25
26
 
26
27
  const num = (x) => { const v = Number(x.toFixed(4)); return String(v === 0 ? 0 : v); }; // -0 → "0"
27
28
  const escapeXml = (s) => String(s)
@@ -74,13 +75,24 @@ export function renderSvg(doc) {
74
75
  `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${num(W)}mm" height="${num(H)}mm" viewBox="0 0 ${num(W)} ${num(H)}">`,
75
76
  `<title>${escapeXml(doc.title)}</title>`,
76
77
  ];
77
- for (const id of ORDER) {
78
+ // Group order mirrors dxf.js: engrave, score, every custom (named) layer in first-seen
79
+ // order across the placements, then cut-inner and cut-outer.
80
+ const custom = [];
81
+ for (const placement of doc.placements) for (const layer of placement.drawing.layers)
82
+ if (layer.name !== undefined && !custom.includes(layer.id)) custom.push(layer.id);
83
+ const ids = [...ORDER.filter((id) => !id.startsWith("cut")), ...custom, ...ORDER.filter((id) => id.startsWith("cut"))];
84
+ for (const id of ids) {
78
85
  const paths = [];
79
86
  for (const placement of doc.placements) {
80
87
  const layer = placement.drawing.layers.find((l) => l.id === id);
81
88
  if (!layer || layer.paths.length === 0) continue;
82
89
  const place = placer(placement);
83
- if (id === "engrave") {
90
+ if (layer.name !== undefined) {
91
+ // a routed layer: stroked in its own colour, never filled
92
+ if (!SVG_HEX.test(layer.svgColor ?? "")) throw new Error(`renderSvg: layer ${JSON.stringify(layer.id)} needs an svgColor`);
93
+ for (const p of layer.paths)
94
+ paths.push(`<path d="${pathData(p, place, H)}" fill="none" stroke="${layer.svgColor}" stroke-width="${SVG_STROKE_MM}"/>`);
95
+ } else if (id === "engrave") {
84
96
  // one filled path per piece: all its engrave rings, even-odd, so a glyph's
85
97
  // counter stays open whatever the rings' winding
86
98
  const d = layer.paths.map((p) => pathData(p, place, H)).join(" ");
@@ -625,5 +625,6 @@ export { translateProfile, rotateProfile, scaleProfile, mirrorProfile,
625
625
  // the drawing ↔ world converters. Plain JS over the contour IR — not kernel ops, so
626
626
  // they add nothing to the kernel contract's op lists.
627
627
  export { sheetPart } from "../sheet/part.js";
628
+ export { routedPart } from "../sheet/routed.js";
628
629
  export { fingers, tabs, tSlots, sheetPanel, matchingSlots, fingerBox, printedTab, sheetHole, JOINERY_SCREWS } from "../sheet/joinery.js";
629
630
  export { sheetToWorld, worldToSheet } from "../sheet/pose.js";
@@ -166,7 +166,7 @@ function sheetRowFacts(kernel, part, name, view, { p, d }, budget, volume) {
166
166
  const expected = f.area * f.thickness - MARK_DEPTH * (f.marksArea ?? 0);
167
167
  return {
168
168
  ...f,
169
- at: { bridge: lift(f.at2d.bridge), gap: lift(f.at2d.gap), marks: lift(f.at2d.marks) },
169
+ at: { bridge: lift(f.at2d.bridge), gap: lift(f.at2d.gap), marks: lift(f.at2d.marks), outside: lift(f.at2d.outside ?? null) },
170
170
  solidMatchPct: f.customBuild && f.marksArea != null && expected > 1e-9
171
171
  ? (100 * Math.abs(volume - expected)) / expected
172
172
  : null,
@@ -192,7 +192,7 @@ function unknownProcessFacts(kernel, sp, p, d) {
192
192
  process: sp.sheet.process, material: meta?.material ?? null, thickness: meta?.thickness ?? null, group: meta?.group ?? null,
193
193
  flat, area, pieces, customBuild: sp.build !== sp.sheet.generatedBuild,
194
194
  marksArea: null, bridge: null, bridgeCapped: false, gap: null, gapCapped: false, marksOutside: null, solidMatchPct: null,
195
- at2d: { bridge: null, gap: null, marks: null }, at: { bridge: null, gap: null, marks: null },
195
+ at2d: { bridge: null, gap: null, marks: null, outside: null }, at: { bridge: null, gap: null, marks: null, outside: null },
196
196
  readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
197
197
  evaluated: false,
198
198
  };