partforge 0.156.0 → 0.157.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.
@@ -2037,7 +2037,7 @@ the assembled view, and `minWall` and the overhang check skip sheet parts.
2037
2037
 
2038
2038
  ### The kit
2039
2039
 
2040
- 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:`.
2040
+ Sheet parts download as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, a `parts/` file per distinct piece (plus `-marks.dxf` for a cutting service), `print/` for printed parts. Identical pieces share one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"`, `"service"`, `"cnc-router"`), kerf, stock size and a two-sided routed piece's flip and file count are chosen at download: draw nominal, bind `thickness` to the measured control. A choice it cannot honour fails with `cut kit options:`.
2041
2041
 
2042
2042
  ### Limits
2043
2043
 
@@ -2179,8 +2179,8 @@ renders and STEP all come from the one declaration — so model it once.
2179
2179
 
2180
2180
  ### When a sub-part is a routed part
2181
2181
 
2182
- When it is machined from one face of a flat board: an outline, pockets with flat floors,
2183
- holes, all straight down. A sculpted surface, a part machined from both faces, or
2182
+ When it is machined from one or both faces of a flat board: an outline, pockets with
2183
+ flat floors, holes, all straight down from whichever face. A sculpted surface or
2184
2184
  anything that is not flat stock is an ordinary `build` (export it as STEP). A laser-cut
2185
2185
  plate is `sheetPart`.
2186
2186
 
@@ -2197,8 +2197,12 @@ plinth: routedPart({
2197
2197
  pockets: (k, p, d) => [
2198
2198
  { region: k.shape2d(roundedRectProfile(d.vaultW, d.vaultL, 2)).translate([0, -9]), depth: 11.43, label: "peg vault" },
2199
2199
  { region: k.shape2d(roundedRectProfile(d.pocketW, d.pocketL, 4)).translate([-58, -10]), depth: p.inlayThickness, label: "inlay pocket" },
2200
+ { region: k.shape2d(roundedRectProfile(40, 20, 3)).translate([60, 0]), depth: 4, face: "far", label: "felt recess" },
2201
+ ],
2202
+ drills: (p, d) => [
2203
+ ...d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
2204
+ { at: [-60, 30], d: 8, depth: 6, face: "far", label: "foot socket" }, // from the underside
2200
2205
  ],
2201
- drills: (p, d) => d.pegHoles.map(([x, y]) => ({ at: [x, y], d: p.pegHoleDia, depth: 10 })),
2202
2206
  extra: (k, p, d) => [{
2203
2207
  solid: k.box({ size: [d.rackL, 12, 14] }).rotateX(-p.rackTilt).at([0, d.rackY, p.baseThickness - 9.5]),
2204
2208
  footprint: k.shape2d(roundedRectProfile(d.rackL, 12, 2)).translate([0, d.rackY]),
@@ -2213,21 +2217,22 @@ plinth: routedPart({
2213
2217
  `exportable` pass through. A field needing the kernel is `(k, p, d) => …`; `drills` is
2214
2218
  `(p, d) => …`. `build` is supplied — passing one throws, as do `kerf`, `score`,
2215
2219
  `engrave` and `process`; each error names the fix. The drawing is in XY with the
2216
- machined face at z = `thickness`; `pose` places it exactly as a sheet part's does.
2220
+ near face at z = `thickness` and the far face at z = 0; `pose` places it exactly as a
2221
+ sheet part's does.
2217
2222
 
2218
2223
  ### Pockets, drills, extra ops and reference lines
2219
2224
 
2220
- - **`pockets`** — `[{ region, depth, label? }]`: a Shape2D or profile machined `depth`
2221
- mm down from the machined face, flat-floored. `depth` may equal `thickness` (a
2225
+ - **`pockets`** — `[{ region, depth, label?, face? }]`: a Shape2D or profile machined
2226
+ `depth` mm into the stock from its face, flat-floored. `depth` may equal `thickness` (a
2222
2227
  through pocket); deeper is a build error naming the pocket. Pockets whose depths agree to
2223
2228
  two decimals share a DXF layer.
2224
- - **`drills`** — `[{ at: [x, y], d, depth, label? }]`: a round hole of diameter `d`.
2229
+ - **`drills`** — `[{ at: [x, y], d, depth, label?, face? }]`: a round hole of diameter `d`.
2225
2230
  Set `depth` to the thickness to drill through — deeper is a `routed-drill-depth`
2226
2231
  lint error. Written as a DXF `CIRCLE`, which is what lets
2227
2232
  the CAM offer a drilling toolpath instead of a profile cut.
2228
- - **`extra`** — `[{ solid, footprint, label, method }]`: an operation the router cannot
2229
- do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is cut in
2230
- the preview and the STEP; `footprint` (its outline on the machined face) is drawn on
2233
+ - **`extra`** — `[{ solid, footprint, label, method, face? }]`: an operation the router
2234
+ cannot do straight down — a tilted slot, a scooped thumb hold, an undercut. `solid` is
2235
+ cut in the preview and the STEP; `footprint` (its outline on its face) is drawn on
2231
2236
  the `SECONDARY` layer; `label` and `method` are REQUIRED and go in the README's
2232
2237
  operations table so the operator knows what to make and how ("core box bit, 20 mm
2233
2238
  radius"). Do not put a flat-floored pocket here to avoid stating its depth.
@@ -2248,10 +2253,27 @@ and depth (colours 3, 4, 2, 140, 170); `SECONDARY` (6) the extra ops' footprints
2248
2253
  `REFERENCE` (8) layout lines. Depth rides both the layer name and the colour, and colours
2249
2254
  are assigned per kit — pockets shallowest depth first, drills in the order they appear —
2250
2255
  so one layer name has one colour in every file. A palette cycles past five depths; the
2251
- README's LAYERS legend (layer, colour, depth) is the authority. Kerf
2256
+ README's LAYERS legend (layer, face, colour, depth) is the authority. Kerf
2252
2257
  never applies — the CAM applies the tool offset, and the kit refuses a kerf for this
2253
2258
  destination. The README's ROUTER OPERATIONS table lists every pocket, drill group and
2254
- extra op with its depth and method.
2259
+ extra op with its face, depth and method.
2260
+
2261
+ ### Both faces
2262
+
2263
+ Every pocket, drill and extra op takes `face`: `"near"` (the default, machined down
2264
+ from z = `thickness`) or `"far"` (machined up from z = 0 — the board turned over); any
2265
+ other value is a build error. `depth` is measured from the op's own face. Model the part
2266
+ in place, as assembled; the kit does the flipping. Far ops go on their own layers —
2267
+ `FAR_POCKET_<depth>MM`, `FAR_DRILL_D<dia>_…`, `FAR_SECONDARY` — mirrored about the
2268
+ piece's centre so they read as the router sees the far face. Two kit options apply to a
2269
+ piece with far work: `flip` — `"left-right"` (the default, VCarve's Flip Direction =
2270
+ Horizontal) or `"end-over-end"` (Vertical) — sets the mirror axis; `twoSided` —
2271
+ `"one-file"` (the default: both faces' layers in the piece's one DXF) or `"two-files"`
2272
+ (the far layers in `parts/<name>-far.dxf`, behind the mirrored outline on `REFERENCE`
2273
+ for alignment). In the CAM, line the far vectors' outline up on the piece's outline as
2274
+ seen from the bottom; the README says so and orders the work far side first, through
2275
+ cut last. `routedBreakthrough` counts a near and a far pocket that overlap and together
2276
+ go deeper than the stock.
2255
2277
 
2256
2278
  ### What lint and verify check
2257
2279
 
@@ -2259,14 +2281,17 @@ Lint: `routed-drill-depth` (error) — a drill deeper than the stock at the defa
2259
2281
  Every sheet rule applies too (`sheet-thickness-literal`, `sheet-custom-build`, …); the
2260
2282
  laser's thickness range does not. A pocket deeper than the stock is a build error.
2261
2283
 
2262
- Verify volunteers three warnings on every routed part, none counting toward
2284
+ Verify volunteers four warnings on every routed part, none counting toward
2263
2285
  `declared`/`evaluated`: `routedOutside` (pocket, drill or footprint regions outside the
2264
- profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`).
2286
+ profile, `0`), `routedEmpty` (pockets that resolved empty, `0`), `routedPieces` (`1`),
2287
+ `routedBreakthrough` (a near-face and a far-face pocket that overlap and together go
2288
+ deeper than the stock, `0`).
2265
2289
  Each measured row carries `sheet.routed` — the operations table — beside the stock facts.
2266
2290
 
2267
2291
  ### Limits
2268
2292
 
2269
- - One machined face. A part worked from both faces is two routed parts or a `build`.
2293
+ - Far-side `reference` lines and far-side work in the sheets/ layouts are not drawn;
2294
+ two-sided jobs are per-piece files.
2270
2295
  - No tool-radius, dogbone or inside-corner checks: the CAM owns those.
2271
2296
  - No depth per extra op beyond its footprint and the solid's deepest point; the
2272
2297
  `method` text is where the operator's instructions go.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.156.0",
3
+ "version": "0.157.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,15 +19,17 @@ 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, routedPalette } from "./drawing.js";
22
+ import { canonicalDrawingKey, routedPalette, splitFaces } 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";
26
26
  import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
27
27
 
28
28
  // Every entry name the kit may write (spec D.10). test/kit-bundle.test.js pins every
29
- // kit it builds against it, and buildBundle asserts it again before zipping.
30
- export const KIT_ENTRY_RE = /^(README\.txt|parts\.csv|sheets\/[a-z0-9][a-z0-9._-]*\/sheet-\d+-of-\d+\.(svg|dxf)|parts\/[a-z0-9][a-z0-9._-]*(-x\d+)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
29
+ // kit it builds against it, and buildBundle asserts it again before zipping. A piece's
30
+ // files are `<name>[-x<n>]` plus `-far` (a two-sided routed piece's far-face file, in
31
+ // the two-files mode) or `-marks` (a laser piece's score and engrave lines).
32
+ export const KIT_ENTRY_RE = /^(README\.txt|parts\.csv|sheets\/[a-z0-9][a-z0-9._-]*\/sheet-\d+-of-\d+\.(svg|dxf)|parts\/[a-z0-9][a-z0-9._-]*(-x\d+)?(-far)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
31
33
 
32
34
  // A path segment from an author string. safeName already lowercases, collapses runs and
33
35
  // refuses a leading dot or dash; folding every run of dots on top means no entry name
@@ -192,27 +194,32 @@ function sheetMetaOrThrow(kernel, sp, p, d, name) {
192
194
  throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
193
195
  }
194
196
 
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);
197
+ // The README's layer legend: every routed layer name in the kit, once, with its face,
198
+ // colour (ACI in a DXF kit, hex in an SVG one) and depth — REFERENCE, SECONDARY, then
199
+ // drills in palette order, then pockets shallowest first; the far face's layers follow
200
+ // in the same order. The palette is kit-wide, so a name has one colour; the legend is
201
+ // the authority when a palette cycles. It reads each piece's whole drawing, so the
202
+ // far file's alignment outline (splitFaces' `far-outline`) is never listed.
203
+ const LEGEND_RANK = { reference: 0, secondary: 1, drill: 2, pocket: 3, "far-secondary": 4, "far-drill": 5, "far-pocket": 6 };
204
+ const legendKind = (l) => {
205
+ const m = /^(far-)?(drill|pocket)-/.exec(l.id);
206
+ return m ? `${m[1] ?? ""}${m[2]}` : l.id;
207
+ };
201
208
  function routedLegend(pieces, cutExt) {
202
209
  const rows = new Map();
203
210
  for (const pc of pieces) {
204
211
  for (const l of pc.drawing.layers) {
205
212
  if (l.name === undefined || rows.has(l.name)) continue;
206
213
  const kind = legendKind(l);
207
- rows.set(l.name, { name: l.name, kind, order: rows.size,
214
+ rows.set(l.name, { name: l.name, kind, face: l.face === "far" ? "far" : "near", order: rows.size,
208
215
  colour: cutExt === "svg" ? l.svgColor : String(l.color),
209
216
  depth: l.through ? "through" : typeof l.depth === "number" ? `${l.depth.toFixed(2)} mm` : "—", mm: l.depth ?? 0 });
210
217
  }
211
218
  }
212
219
  return [...rows.values()]
213
220
  .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 }));
221
+ || (a.kind.endsWith("pocket") ? a.mm - b.mm : 0) || a.order - b.order)
222
+ .map(({ name, face, colour, depth }) => ({ name, face, colour, depth }));
216
223
  }
217
224
 
218
225
  // One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
@@ -292,11 +299,14 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
292
299
  // and the op labels, are not in its drawing — only the README carries them, so two
293
300
  // pieces that draw alike but machine differently must stay two files.
294
301
  const sameOps = (a, b) => JSON.stringify(a.facts?.routed ?? null) === JSON.stringify(b.facts?.routed ?? null);
302
+ // A routed piece with far-face work is two-sided: its sheets carry the near face only
303
+ // (`faces.near`), and the two-files mode writes its far face to a file of its own.
295
304
  const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group && sameOps(a, b))
296
- .map(({ item, members }) => ({
297
- ...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
298
- hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
299
- }));
305
+ .map(({ item, members }) => {
306
+ const faces = splitFaces(item.drawing);
307
+ return { ...item, labels: members.map((m) => m.label), qty: members.length * o.sets, faces, twoSided: faces.far !== null,
308
+ hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave") };
309
+ });
300
310
 
301
311
  // Names, in definition order. A name that was taken moves to name-2; the README's
302
312
  // RENAMED FILES says which file it was and who held the name (spec D.10). A piece
@@ -321,10 +331,12 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
321
331
  const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
322
332
  // A routed piece is never split: its depths are one layered file for the CAM.
323
333
  const marks = (service || router) && pc.hasMarks && !pc.routed;
324
- const name = claim(kitName(pc.fileKey),
325
- (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
334
+ const far = pc.twoSided && o.twoSided === "two-files";
335
+ const name = claim(kitName(pc.fileKey), (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []),
336
+ ...(far ? [`parts/${n}${sx}-far.${cutExt}`] : []), `parts/${n}/`], pc.labels);
326
337
  pc.file = `parts/${name}${sx}.${cutExt}`;
327
338
  pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
339
+ pc.farFile = far ? `parts/${name}${sx}-far.${cutExt}` : null;
328
340
  }
329
341
 
330
342
  // Lay out each stock group. An own laser needs every piece to fit (layoutGroup
@@ -339,7 +351,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
339
351
  const stock = stockFor.get(g.group);
340
352
  const laid = layoutGroup({
341
353
  group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !(service || router),
342
- pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
354
+ pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.faces.near, qty: pc.qty })),
343
355
  });
344
356
  if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
345
357
  throw new Error(`${KIT_OPTIONS_ERROR} ${g.material} ${fmtMm(g.thickness)} mm needs ${laid.sheets.length} sheets — at most ${KIT_LIMITS.sheetsPerGroup} per material; use larger stock or fewer sets`);
@@ -394,7 +406,8 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
394
406
  // The files (spec D.8): own laser — an SVG per sheet and per piece; service and CNC
395
407
  // router — a cut-only DXF per laser piece, a -marks.dxf where it has marks, and
396
408
  // reference DXF sheets. A routed piece is one file with every layer, whatever the
397
- // destination.
409
+ // destination — or, two-sided in the two-files mode, its near face and its far face
410
+ // (behind a REFERENCE outline) as two.
398
411
  say("writing cut files");
399
412
  const title = cleanLabel(part.meta?.title ?? fileBase);
400
413
  const files = [];
@@ -404,8 +417,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
404
417
  put(sh.file, cutExt === "dxf" ? renderDxf(doc) : renderSvg(doc));
405
418
  }
406
419
  for (const pc of pieces) {
407
- const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
408
- placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
420
+ const docOf = (drawing) => ({ title: pc.labels.join(", "), size: sizeOf(drawing.bounds),
421
+ placements: [{ drawing, at: [0, 0], rotated: false }] });
422
+ const render = cutExt === "svg" ? renderSvg : renderDxf;
423
+ if (pc.farFile) { put(pc.file, render(docOf(pc.faces.near))); put(pc.farFile, render(docOf(pc.faces.far))); continue; }
424
+ const doc = docOf(pc.drawing);
409
425
  if (cutExt === "svg") { put(pc.file, renderSvg(doc)); continue; }
410
426
  if (pc.routed) { put(pc.file, renderDxf(doc)); continue; } // every layer, one file
411
427
  put(pc.file, renderDxf(doc, { include: ["cut"] }));
@@ -416,15 +432,17 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
416
432
  const controls = collectControls(part.parameters);
417
433
  const readme = renderReadme({
418
434
  title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
435
+ flip: o.flip, twoSided: o.twoSided, anyTwoSided: pieces.some((pc) => pc.twoSided),
419
436
  stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
420
437
  control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
421
438
  scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
422
439
  sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
423
440
  size: sh.stock, pieces: sh.placements.length, used: sh.used })),
424
- pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
441
+ pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, farFile: pc.farFile, labels: pc.labels, material: groups.get(pc.group).material,
425
442
  thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
426
443
  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 })),
444
+ labels: pc.labels, material: groups.get(pc.group).material, thickness: groups.get(pc.group).thickness, ...pc.facts.routed,
445
+ twoSided: pc.twoSided, farFile: pc.farFile })),
428
446
  layers: routedLegend(pieces.filter((pc) => pc.routed), cutExt),
429
447
  printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
430
448
  clearances: controls.filter(isClearance)
@@ -434,8 +452,11 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
434
452
  oversized, renamed,
435
453
  });
436
454
  const csv = renderPartsCsv([
437
- ...pieces.map((pc) => ({ file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
438
- thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty })),
455
+ ...pieces.flatMap((pc) => {
456
+ const row = { file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
457
+ thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty };
458
+ return pc.farFile ? [row, { ...row, file: pc.farFile, kind: "far" }] : [row];
459
+ }),
439
460
  ...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
440
461
  width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
441
462
  ]);
@@ -359,11 +359,44 @@ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } =
359
359
  // Depth in millimetres as a layer-name token: up to two decimals, trailing zeros
360
360
  // dropped (11.43 → "11.43", 3 → "3"). Two depths that round alike share a layer.
361
361
  export const depthToken = (mm) => String(Number(mm.toFixed(2)));
362
+ // A far-face op's layer carries a FAR_ prefix; near names are unchanged.
362
363
  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`;
364
+ const far = op.face === "far" ? "FAR_" : "";
365
+ if (kind === "pocket") return `${far}POCKET_${depthToken(op.depth)}MM`;
366
+ if (kind === "drill") return far + (op.through ? `DRILL_D${depthToken(op.d)}_THRU` : `DRILL_D${depthToken(op.d)}_${depthToken(op.depth)}MM`);
365
367
  throw new Error(`routedLayerName: unknown kind ${JSON.stringify(kind)}`);
366
368
  }
369
+ // How a piece is turned over to cut its far face, as the mirror axis of its drawing.
370
+ export const FLIP_AXIS = Object.freeze({ "left-right": "x", "end-over-end": "y" });
371
+ // A ring mirrored about the line `axis = c` ("x": x -> 2c - x; "y": y -> 2c - y), every
372
+ // point mapped (start, to, via, c1, c2), then walked backwards so a CCW ring stays CCW:
373
+ // an arc keeps its mirrored via, a cubic swaps its mirrored c1/c2. Other fields (closed)
374
+ // carry over. Returns a new ring; the input is not touched.
375
+ export function mirrorRing(ring, axis, c) {
376
+ const m = axis === "x" ? ([x, y]) => [2 * c - x, y] : ([x, y]) => [x, 2 * c - y];
377
+ const pts = [ring.start, ...ring.segments.map((s) => s.to)].map(m);
378
+ const segments = [];
379
+ for (let i = ring.segments.length - 1; i >= 0; i--) {
380
+ const s = ring.segments[i], to = pts[i];
381
+ if (s.via) segments.push({ to, via: m(s.via) });
382
+ else if (s.c1) segments.push({ to, c1: m(s.c2), c2: m(s.c1) });
383
+ else segments.push({ to });
384
+ }
385
+ return { ...ring, start: pts[pts.length - 1], segments };
386
+ }
387
+ export const mirrorRings = (rings, axis, c) => rings.map((r) => mirrorRing(r, axis, c));
388
+ // A two-sided piece's drawing split by face. `near` keeps every layer not marked
389
+ // face "far" (the input itself for a one-sided piece); `far` is null for a one-sided
390
+ // piece, else the far layers behind a REFERENCE layer of `drawing.farOutline` (the
391
+ // mirrored outer rings the exporter sets), both with the input's bounds/nominal/kerf.
392
+ export function splitFaces(drawing) {
393
+ const farLayers = drawing.layers.filter((l) => l.face === "far");
394
+ if (farLayers.length === 0) return { near: drawing, far: null };
395
+ const { bounds, nominal, kerf } = drawing;
396
+ const near = { layers: drawing.layers.filter((l) => l.face !== "far"), bounds, nominal, kerf };
397
+ const outline = { id: "far-outline", name: "REFERENCE", ...ROUTED_COLORS.reference, face: "far", paths: drawing.farOutline };
398
+ return { near, far: { layers: [outline, ...farLayers], bounds, nominal, kerf } };
399
+ }
367
400
  // A drill's tool setting as a key: diameter and depth (or THRU), two-decimal tokens —
368
401
  // one layer per key, and the kit-wide palette's drill key.
369
402
  export const drillKey = (dr) => `D${depthToken(dr.d)}-${dr.through ? "THRU" : depthToken(dr.depth)}`;
@@ -44,9 +44,10 @@ export const KIT_LIMITS = Object.freeze({
44
44
  export const KIT_DEFAULTS = Object.freeze({
45
45
  destination: "own-laser", kerf: 0, colors: "lightburn", stock: null,
46
46
  margin: 5, spacing: 3, printFormat: "stl", sets: 1,
47
+ flip: "left-right", twoSided: "one-file",
47
48
  });
48
49
 
49
- const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets"];
50
+ const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets", "flip", "twoSided"];
50
51
 
51
52
  const fail = (message) => new Error(`${KIT_OPTIONS_ERROR} ${message}`);
52
53
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
@@ -91,7 +92,14 @@ export function validateKitOptions(o) {
91
92
  if (!Number.isInteger(sets) || sets < KIT_LIMITS.sets[0] || sets > KIT_LIMITS.sets[1])
92
93
  throw fail(`sets must be a whole number from ${KIT_LIMITS.sets[0]} to ${KIT_LIMITS.sets[1]}, got ${JSON.stringify(sets)}`);
93
94
 
94
- return { destination, cutFormat, kerf, colors, stock, margin, spacing, printFormat, sets };
95
+ const flip = pick("flip");
96
+ if (flip !== "left-right" && flip !== "end-over-end")
97
+ throw fail(`flip must be "left-right" or "end-over-end", got ${JSON.stringify(flip)}`);
98
+ const twoSided = pick("twoSided");
99
+ if (twoSided !== "one-file" && twoSided !== "two-files")
100
+ throw fail(`twoSided must be "one-file" or "two-files", got ${JSON.stringify(twoSided)}`);
101
+
102
+ return { destination, cutFormat, kerf, colors, stock, margin, spacing, printFormat, sets, flip, twoSided };
95
103
  }
96
104
 
97
105
  // null/undefined → null ("every group gets DEFAULT_STOCK_SIZE"); otherwise fresh
@@ -15,7 +15,16 @@ export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,heigh
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
+ 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. FAR_* layers are the same operations on the far face, drawn mirrored (see the flip note under each two-sided piece). Laser pieces keep CUT/SCORE/ENGRAVE.";
19
+ // A two-sided routed piece: how its far layers were mirrored, how to line them up, and
20
+ // where its through cut is made.
21
+ const FLIP_NOTE = {
22
+ "left-right": "Far-side layers (FAR_*) are drawn as seen after flipping the board left-right, matching a VCarve two-sided job with Flip Direction = Horizontal.",
23
+ "end-over-end": "Far-side layers (FAR_*) are drawn as seen after flipping the board end over end, matching a VCarve two-sided job with Flip Direction = Vertical.",
24
+ };
25
+ const ALIGN_NOTE = "On the bottom side, move the imported far-side vectors so their outline sits on the piece's outline as the CAM shows it from that side. Shortcut: make the job's material the piece's size, then centre the imported vectors on the material (VCarve: Align Selected Objects → Centre); because the far layers are mirrored about the piece's own centre, they then land exactly.";
26
+ const ONE_FILE_NOTE = "On the bottom side the CUT outline is for alignment only — cut the piece out from the near side.";
27
+ const TWO_SIDED_ORDER = "Two-sided pieces: machine the far side first, flip, then the near side and the through cut last, so the board holds the piece until the end. Sheet layouts in sheets/ carry near-side work only; far-side work is in the per-piece files.";
19
28
  const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
20
29
 
21
30
  // C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
@@ -48,6 +57,7 @@ function settingValue(v) {
48
57
 
49
58
  const table = (header, rows) => (rows.length ? [header.join(" | "), ...rows.map((r) => r.join(" | "))] : []);
50
59
  const mm2 = ([w, h]) => `${w.toFixed(2)} × ${h.toFixed(2)} mm`;
60
+ const faceOf = (op) => (op.face === "far" ? "far" : "near");
51
61
  const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
52
62
 
53
63
  export function renderReadme(m) {
@@ -72,6 +82,7 @@ export function renderReadme(m) {
72
82
  ...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
73
83
  kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
74
84
  router ? ROUTER_LEGEND : m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
85
+ ...(m.anyTwoSided ? [TWO_SIDED_ORDER] : []),
75
86
  ...(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(", ")}.`] : []),
76
87
  // A service kit whose every piece is oversized writes no sheets/ at all.
77
88
  ...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
@@ -83,16 +94,18 @@ export function renderReadme(m) {
83
94
  PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
84
95
  pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
85
96
  "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)]),
97
+ `${labelsOf(r.labels)} (${cleanLabel(r.material)} ${r.thickness.toFixed(2)} mm) — depths from each op's own face:`,
98
+ ...table(["op", "face", "label", "size", "depth", "method"], [
99
+ ...r.pockets.map((pk) => ["pocket", faceOf(pk), pk.label ? cleanLabel(pk.label) : "—", mm2([pk.w, pk.h]), `${pk.depth.toFixed(2)} mm`, "mill"]),
100
+ ...r.drills.map((dr) => ["drill", faceOf(dr), dr.label ? cleanLabel(dr.label) : "—", `Ø${fmtMm(dr.d)} mm × ${dr.count}`, dr.through ? "through" : `${dr.depth.toFixed(2)} mm`, "drill"]),
101
+ ...r.extra.map((x) => ["extra", faceOf(x), cleanLabel(x.label), mm2([x.w, x.h]), `to ${x.maxDepth.toFixed(2)} mm`, cleanLabel(x.method)]),
91
102
  ]),
103
+ ...(r.twoSided ? [FLIP_NOTE[m.flip] ?? FLIP_NOTE["left-right"], ALIGN_NOTE,
104
+ r.farFile ? `Far-side work is in ${r.farFile}; its REFERENCE outline is for alignment, not cutting.` : ONE_FILE_NOTE] : []),
92
105
  ]),
93
106
  // The layer legend: one colour per layer name across the whole kit.
94
107
  ...((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]))]
108
+ ? ["LAYERS (one colour per layer across the kit):", ...table(["layer", "face", "colour", "depth"], m.layers.map((l) => [l.name, faceOf(l), l.colour, l.depth]))]
96
109
  : [])],
97
110
  "PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
98
111
  "CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
@@ -1,4 +1,5 @@
1
- // CNC routing — process module #2. Flat stock machined from ONE face: through cuts
1
+ // CNC routing — process module #2. Flat stock machined from either face — the near
2
+ // face (z = t, the default) or the far face (z = 0, per op `face: "far"`): through cuts
2
3
  // (profile), flat-floored pockets at a depth, drilled holes at a depth, and `extra`
3
4
  // operations the operator does with another tool after the flat milling. The
4
5
  // drawing is one layered DXF per piece, depth on the layer name and colour.
@@ -12,8 +13,9 @@ import {
12
13
  } from "../../sheet/constants.js";
13
14
  import { err } from "../../lint/finding.js";
14
15
 
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 —
16
+ // The preview: the profile extruded to the thickness, each pocket cut `depth` into the
17
+ // stock from its face (down from z = t on the near face, up from z = 0 on the far
18
+ // face), each drill as a cylinder from its face to its depth —
17
19
  // through holes and through pockets (depth = thickness) clear BOTH faces by MARK_OVERCUT so no cap is coplanar with a face
18
20
  // (OCCT's coplanar booleans are fragile) — each extra solid subtracted as given, and
19
21
  // the reference grooves cut like a laser score. A tool whose region turned out empty
@@ -29,10 +31,14 @@ function routedPreview(k, s) {
29
31
  const tools = [
30
32
  ...pockets.map((pk) => orEmpty(() => (pk.depth >= t
31
33
  ? 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])))),
34
+ : pk.face === "far"
35
+ ? pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
36
+ : pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
33
37
  ...drills.map((dr) => (dr.through
34
38
  ? 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]))),
39
+ : dr.face === "far"
40
+ ? k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
41
+ : k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
36
42
  ...extra.map((x) => x.solid),
37
43
  ...s.grooves.map((g) => orEmpty(() => g.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH]))),
38
44
  ].filter(Boolean);
@@ -74,21 +80,21 @@ function routedFacts(s) {
74
80
  for (const pk of pockets) {
75
81
  if (pk.region.isEmpty()) { empty++; continue; }
76
82
  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()) });
83
+ pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()), face: pk.face });
78
84
  noteOutside(pk.region);
79
85
  }
80
- // Drills grouped by (d, depth, through): one row per tool setting, labels joined.
86
+ // Drills grouped by (d, depth, through, face): one row per tool setting, labels joined.
81
87
  const drillGroups = new Map();
82
88
  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 };
89
+ const key = `${dr.d}|${dr.depth}|${dr.through}|${dr.face}`;
90
+ const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, face: dr.face, count: 0 };
85
91
  g.count++;
86
92
  if (dr.label && !g.labels.includes(dr.label)) g.labels.push(dr.label);
87
93
  drillGroups.set(key, g);
88
94
  noteOutside(dr.shape);
89
95
  }
90
- const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count }) =>
91
- ({ label: labels.length ? labels.join(", ") : null, d, depth, through, count }));
96
+ const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count, face }) =>
97
+ ({ label: labels.length ? labels.join(", ") : null, d, depth, through, count, face }));
92
98
  // An extra op whose footprint resolved to nothing draws nothing: counted under
93
99
  // `empty` with the empty pockets, never a row (its size has no bounding box).
94
100
  const extraRows = [];
@@ -96,12 +102,25 @@ function routedFacts(s) {
96
102
  if (x.footprint.isEmpty()) { empty++; continue; }
97
103
  const [w, h] = sizeOf(x.footprint);
98
104
  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.
105
+ // How deep the op reaches into the stock from its face — below z = t on the near
106
+ // face, above z = 0 on the far face — within the stock. An empty solid has no
107
+ // bounding box and reaches nothing.
101
108
  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))) });
109
+ const bound = x.face === "far" ? bb?.max?.[2] : bb?.min?.[2];
110
+ const reach = Number.isFinite(bound) ? (x.face === "far" ? bound : t - bound) : 0;
111
+ extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))), face: x.face });
112
+ }
113
+
114
+ // Breakthrough: a near pocket and a far pocket whose regions overlap and whose depths
115
+ // together exceed the stock — the router breaks through between them. Depths adding
116
+ // up to exactly the thickness are a deliberate opening and never counted.
117
+ const live = pockets.filter((pk) => !pk.region.isEmpty());
118
+ const near = live.filter((pk) => pk.face !== "far"), far = live.filter((pk) => pk.face === "far");
119
+ let breakthrough = 0;
120
+ for (const a of near) {
121
+ for (const b of far) {
122
+ if (a.depth + b.depth > t + 1e-6 && a.region.intersect(b.region).area() > LOSS_TOL_MM2) breakthrough++;
123
+ }
105
124
  }
106
125
 
107
126
  return {
@@ -112,7 +131,7 @@ function routedFacts(s) {
112
131
  at: { bridge: null, gap: null, marks: null, outside: null },
113
132
  readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
114
133
  evaluated: true,
115
- routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty },
134
+ routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty, breakthrough },
116
135
  };
117
136
  }
118
137
 
@@ -130,10 +149,13 @@ const METRICS = {
130
149
  routedPieces: { kind: "warn", doc: ROUTED_DOC_ID,
131
150
  extract: (s) => s.sheet?.pieces ?? null,
132
151
  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" },
152
+ routedBreakthrough: { kind: "warn", doc: ROUTED_DOC_ID,
153
+ extract: (s) => s.sheet?.routed?.breakthrough ?? null,
154
+ hint: "a pocket on the near face and one on the far face overlap and together go deeper than the stock, so the router breaks through between them — make the overlap a through cut in `profile`, or lower one depth; if the opening is intended, set the two depths to add up to exactly the thickness" },
133
155
  };
134
156
 
135
157
  // ── 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.";
158
+ const DRILL_DEPTH_HINT = "A drill's depth is measured from the op's own face (near or far); 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
159
  const ROUTER_RULES = [{
138
160
  id: "routed-drill-depth",
139
161
  run: ({ part, p, d, deriveError }) => {
@@ -168,7 +190,7 @@ export const ROUTER = {
168
190
  preview: routedPreview,
169
191
  destinations: [{ id: "cnc-router", cutFormat: "dxf" }],
170
192
  facts: routedFacts,
171
- checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1" }),
193
+ checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1", routedBreakthrough: "0" }),
172
194
  metrics: METRICS,
173
195
  lintRules: ROUTER_RULES,
174
196
  };
@@ -6,10 +6,11 @@
6
6
  //
7
7
  // Colours: `ctx.palette` (drawing.js routedPalette, built by the kit over EVERY routed
8
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,
9
+ // call — routedPalette over the piece alone. Each named layer also carries its `depth` (mm,
10
10
  // two decimals) and drills `through`, which the README's layer legend reads.
11
11
  import {
12
12
  refitRing, ringsOf, cutRings, drawingBounds, routedLayerName, depthToken, drillKey, ROUTED_COLORS,
13
+ routedPalette, FLIP_AXIS, mirrorRings,
13
14
  } from "../../export/drawing.js";
14
15
  import { KIT_OPTIONS_ERROR } from "../../export/formats.js";
15
16
 
@@ -18,57 +19,79 @@ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.out
18
19
  const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
19
20
  const ringsOfShape = (shape) => allRings(refitRegions(shape.toContours())).map(closedPath);
20
21
 
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
-
22
+ // One face's operation layers: SECONDARY, then drills grouped by (d, depth, through) in
23
+ // first-seen order, then pockets grouped by two-decimal depth, shallowest first. `place`
24
+ // maps each layer's paths (identity for the near face, the flip's mirror for the far);
25
+ // a far layer's id is prefixed `far-` and it carries `face: "far"` — a near layer has
26
+ // no face key, so a one-sided piece draws exactly as before two-sided parts existed.
27
+ function opLayers({ pockets, drills, extra }, palette, place) {
28
+ const tag = place ? { face: "far" } : {};
29
+ const id = (base) => (place ? `far-${base}` : base);
30
+ const at = place ?? ((paths) => paths);
29
31
  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
32
  const secondary = extra.flatMap((x) => ringsOfShape(x.footprint));
36
- if (secondary.length) layers.push({ id: "secondary", name: "SECONDARY", ...ROUTED_COLORS.secondary, paths: secondary });
33
+ if (secondary.length) layers.push({ id: id("secondary"), name: place ? "FAR_SECONDARY" : "SECONDARY", ...ROUTED_COLORS.secondary, ...tag, paths: at(secondary) });
37
34
 
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;
35
+ // Each hole a closed ring of two exact arcs, which dxf.js writes as a CIRCLE.
41
36
  const drillGroups = new Map();
42
37
  drills.forEach((dr) => {
43
38
  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));
39
+ const g = drillGroups.get(key) ?? { id: id(`drill-${key}`), name: routedLayerName("drill", dr), ...palette.drill.get(key),
40
+ depth: Number(depthToken(dr.depth)), through: dr.through, ...tag, paths: [] };
41
+ g.paths.push(...at(ringsOfShape(dr.shape)));
48
42
  drillGroups.set(key, g);
49
43
  });
50
44
  layers.push(...drillGroups.values());
51
45
 
52
- // Pockets grouped by two-decimal depth, shallowest layer first; an empty region draws nothing.
46
+ // An empty region draws nothing.
53
47
  const pocketGroups = new Map();
54
48
  for (const pk of pockets) {
55
49
  const paths = ringsOfShape(pk.region);
56
50
  if (paths.length === 0) continue;
57
51
  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);
52
+ const g = pocketGroups.get(key) ?? { id: id(`pocket-${key}`), name: routedLayerName("pocket", pk), depth: Number(key), ...tag, paths: [] };
53
+ g.paths.push(...at(paths));
60
54
  pocketGroups.set(key, g);
61
55
  }
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
- });
56
+ [...pocketGroups.values()].sort((a, b) => a.depth - b.depth)
57
+ .forEach((g) => layers.push({ ...g, ...palette.pocket.get(depthToken(g.depth)) }));
58
+ return layers;
59
+ }
60
+
61
+ function drawing(s, o, k, ctx = {}) {
62
+ const label = ctx.label ?? s.label ?? "routed part";
63
+ 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}")`);
64
+ const nominal = refitRegions(s.profile.toContours());
65
+ if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
66
+ const cut = cutRings(nominal);
67
+ const { pockets, drills, extra } = s.routed;
68
+
69
+ const layers = [];
70
+ const reference = [
71
+ ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
72
+ ...s.score.shapes.flatMap(ringsOfShape),
73
+ ];
74
+ if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
75
+ // Near ops as they are; far ops (cut after the piece is turned over by the kit's flip)
76
+ // on their own `far-` layers, every ring mirrored about the piece's centre so the far
77
+ // face is drawn as the router sees it. One palette for both faces: a depth or drill
78
+ // setting is one colour whichever face it is on.
79
+ const palette = ctx.palette ?? routedPalette([s]);
80
+ const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
81
+ const axis = FLIP_AXIS[o?.flip ?? "left-right"];
82
+ const centre = axis === "x" ? (nb.min[0] + nb.max[0]) / 2 : (nb.min[1] + nb.max[1]) / 2;
83
+ const isFar = (op) => op.face === "far";
84
+ const near = opLayers({ pockets: pockets.filter((op) => !isFar(op)), drills: drills.filter((op) => !isFar(op)), extra: extra.filter((op) => !isFar(op)) }, palette);
85
+ const far = opLayers({ pockets: pockets.filter(isFar), drills: drills.filter(isFar), extra: extra.filter(isFar) }, palette,
86
+ (paths) => mirrorRings(paths, axis, centre));
87
+ layers.push(...near, ...far);
66
88
 
67
89
  if (cut.inner.length) layers.push({ id: "cut-inner", paths: cut.inner.map(closedPath) });
68
90
  layers.push({ id: "cut-outer", paths: cut.outer.map(closedPath) });
69
91
 
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 };
92
+ const out = { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
93
+ if (far.length) out.farOutline = mirrorRings(ringsOf(nominal).outer, axis, centre).map(closedPath);
94
+ return out;
72
95
  }
73
96
 
74
97
  export default { drawing };
@@ -16,6 +16,13 @@ const depthOf = (sp, entry, who) => {
16
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
17
  return entry.depth;
18
18
  };
19
+ // Which face an op is machined from: "near" (z = thickness, the default) or "far" (z = 0).
20
+ const faceOf = (sp, entry, who) => {
21
+ const f = entry.face;
22
+ if (f === undefined || f === null) return "near";
23
+ if (f === "near" || f === "far") return f;
24
+ throw fail(sp, `${who} face must be "near" or "far", got ${JSON.stringify(f)}`);
25
+ };
19
26
  const labelOf = (entry) => (typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : null);
20
27
  const list = (sp, fn, args, what, shape) => {
21
28
  if (!fn) return [];
@@ -26,7 +33,9 @@ const list = (sp, fn, args, what, shape) => {
26
33
  };
27
34
 
28
35
  // `base` is resolveSheet's ResolvedSheet so far (profile, thickness, score…); the
29
- // return value is merged onto it as `routed`.
36
+ // return value is merged onto it as `routed`. Every op carries `face`: "near" (machined
37
+ // from z = thickness, the default) or "far" (from z = 0, the stock flipped over); depth
38
+ // is measured from that face either way.
30
39
  export function resolveRouted(k, sp, p, d, base) {
31
40
  const s = sp.sheet;
32
41
  const t = base.thickness;
@@ -35,8 +44,9 @@ export function resolveRouted(k, sp, p, d, base) {
35
44
  const who = name(e, i, "pocket");
36
45
  if (!e || typeof e !== "object" || e.region === null || e.region === undefined) throw fail(sp, `${who} needs a region (a Shape2D or profile)`);
37
46
  const depth = depthOf(sp, e, who);
47
+ const face = faceOf(sp, e, who);
38
48
  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) };
49
+ return { region: k.shape2d(e.region), depth, label: labelOf(e), face };
40
50
  });
41
51
 
42
52
  const drills = list(sp, s.drills, [p, d], "drills(p, d)", "{ at, d, depth }").map((e, i) => {
@@ -44,18 +54,20 @@ export function resolveRouted(k, sp, p, d, base) {
44
54
  if (!e || typeof e !== "object" || !isPoint(e.at)) throw fail(sp, `${who} needs at: [x, y]`);
45
55
  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
56
  const depth = depthOf(sp, e, who);
57
+ const face = faceOf(sp, e, who);
47
58
  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) };
59
+ 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), face };
49
60
  });
50
61
 
51
62
  const extra = list(sp, s.extra, [k, p, d], "extra(k, p, d)", "{ solid, footprint, label, method }").map((e, i) => {
52
63
  const label = labelOf(e);
53
64
  if (!label) throw fail(sp, `extra op ${i + 1} needs a label — say what it is ("12° domino slot")`);
54
65
  const who = `extra op "${label}"`;
66
+ const face = faceOf(sp, e, who);
55
67
  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
68
  if (!isSolid(e.solid)) throw fail(sp, `${who} needs a solid — the geometry it removes, for the preview and the STEP`);
57
69
  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() };
70
+ return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim(), face };
59
71
  });
60
72
 
61
73
  return { pockets, drills, extra };
@@ -338,12 +338,14 @@ export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P
338
338
 
339
339
  // --- routed parts (docs/AUTHORING-PARTS.md "Routed parts") --------------------
340
340
 
341
- /** A flat-floored pocket machined `depth` mm down from the machined face. */
342
- export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string }
341
+ /** Which face of the stock an op is machined from: `"near"` (z = thickness, the default) or `"far"` (z = 0). */
342
+ export type RoutedFace = "near" | "far";
343
+ /** A flat-floored pocket machined `depth` mm into the stock from its face. */
344
+ export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string; face?: RoutedFace }
343
345
  /** A drilled hole; `depth` equal to the stock thickness drills through — deeper is a `routed-drill-depth` lint error. */
344
- export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string }
346
+ export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string; face?: RoutedFace }
345
347
  /** An operation done with another tool after the flat milling: the solid is cut in the preview and the STEP, the footprint is drawn, label and method go in the README. */
346
- export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string }
348
+ export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string; face?: RoutedFace }
347
349
 
348
350
  /**
349
351
  * What `routedPart` accepts: `sheetPart`'s stock, profile and pose, plus pockets,
package/types/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export interface KitOptions {
67
67
  printFormat?: "stl" | "3mf";
68
68
  /** Whole number, 1–20. Default 1. */
69
69
  sets?: number;
70
+ /** Which way a two-sided board is turned over between sides — the axis far-side layers are mirrored about. Default `"left-right"`. */
71
+ flip?: "left-right" | "end-over-end";
72
+ /** Two-sided pieces: one layered file per piece (`"one-file"`, default), or a near file plus a `-far` file (`"two-files"`). */
73
+ twoSided?: "one-file" | "two-files";
70
74
  }
71
75
 
72
76
  /**
@@ -830,6 +834,8 @@ export const KIT_DEFAULTS: Readonly<{
830
834
  spacing: number;
831
835
  printFormat: "stl";
832
836
  sets: number;
837
+ flip: "left-right";
838
+ twoSided: "one-file";
833
839
  }>;
834
840
 
835
841
  /** Normalise kit options — defaults filled, `cutFormat` derived — or throw a `"cut kit options: …"` error. */
package/types/part.d.ts CHANGED
@@ -576,6 +576,8 @@ export interface SubPartExpectations {
576
576
  routedEmpty?: Expectation;
577
577
  /** Routed parts only: separate regions in the cut profile (volunteered check: `1`). */
578
578
  routedPieces?: Expectation;
579
+ /** Routed parts only: overlapping near- and far-face pocket pairs whose depths together exceed the stock (volunteered check: `0`). */
580
+ routedBreakthrough?: Expectation;
579
581
  }
580
582
 
581
583
  /**