partforge 0.156.0 → 0.158.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.158.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) =>
@@ -91,3 +91,23 @@ export function rotateNormals(normals, m) {
91
91
  normals[i + 2] = m[2] * x + m[6] * y + m[10] * z;
92
92
  }
93
93
  }
94
+
95
+ // Whether a column-major mat4 is a rigid motion within `tol`: finite, affine
96
+ // (last row 0 0 0 1), an orthonormal 3x3 block (RᵀR = I) with det = +1 — no
97
+ // scale, shear or mirror. The preview GLB's encoder refuses any other pose, and
98
+ // the sampler (preview-animation.js) applies the same test so its output never
99
+ // trips that refusal.
100
+ export function isRigid(m, tol = 1e-6) {
101
+ if (!Array.isArray(m) && !ArrayBuffer.isView(m)) return false;
102
+ if (m.length !== 16 || !Array.prototype.every.call(m, Number.isFinite)) return false;
103
+ if (Math.abs(m[3]) > tol || Math.abs(m[7]) > tol || Math.abs(m[11]) > tol || Math.abs(m[15] - 1) > tol) return false;
104
+ const col = (c) => [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
105
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
106
+ const cols = [col(0), col(1), col(2)];
107
+ for (let i = 0; i < 3; i++) {
108
+ for (let j = 0; j < 3; j++) if (Math.abs(dot(cols[i], cols[j]) - (i === j ? 1 : 0)) > tol) return false;
109
+ }
110
+ const [a, b, c] = cols;
111
+ const det = a[0] * (b[1] * c[2] - b[2] * c[1]) - b[0] * (a[1] * c[2] - a[2] * c[1]) + c[0] * (a[1] * b[2] - a[2] * b[1]);
112
+ return Math.abs(det - 1) <= tol;
113
+ }
@@ -24,9 +24,25 @@
24
24
  // which the viewer reads as "no feature edges" (never undefined, which would
25
25
  // make it derive lines by angle).
26
26
  //
27
+ // The baked animation (encoder version 4) comes back as `animation`, null for a
28
+ // file without the partforgeAnimation extra (every version 1–3 file):
29
+ // { ...the extra (name, label, loop, duration, cues, opacity, steps, animated),
30
+ // tracks: { <sub>: { times, translations, rotations } }, rest: { <sub>: mat16 } }.
31
+ // `times` are seconds; `translations` xyz triples and `rotations` xyzw quads are
32
+ // the node's TRS keyframes, which the writer expresses in the root's LOCAL frame
33
+ // — the part's Z-up MODEL frame — so they need no conversion (positions do: they
34
+ // go through the node's world matrix, root rotation included). Only sub-parts
35
+ // that move have a track; `rest[name]` is the column-major mat4 of a track's
36
+ // first keyframe (t = 0), the pose already baked into that sub-part's
37
+ // positions — meshes decode at the OPENING pose. Channels are read off the raw
38
+ // glTF JSON and resolved through the parser, never by AnimationClip track name
39
+ // (three sanitizes node names). An animated sub-part's mesh sits on an unnamed
40
+ // child of its node (quantize() moves it there), so a mesh's sub-part is found
41
+ // by walking up to the nearest node carrying partforgeSubPart.
42
+ //
27
43
  // Rejects on anything that is not a decodable GLB; the callers (mount's
28
44
  // `preview`, mountPreview) decide what a failure means for them.
29
- import { Matrix3, Matrix4, Vector3 } from "three";
45
+ import { Matrix3, Matrix4, Quaternion, Vector3 } from "three";
30
46
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
31
47
  import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
32
48
 
@@ -64,16 +80,72 @@ function metaOf(extras) {
64
80
  return Object.keys(out).length ? out : null;
65
81
  }
66
82
 
67
- // The object standing for a glTF node: the object itself when its node had one
68
- // primitive, else the Group GLTFLoader made for a multi-primitive mesh — the
69
- // one carrying the node's extras / name (a primitive's own Mesh has neither).
83
+ // The object standing for a glTF node: the nearest of the object and its
84
+ // ancestors (below the scene root) carrying the writer's partforgeSubPart — an
85
+ // animated sub-part's mesh sits one or two levels under it (quantize()'s unnamed
86
+ // child, then GLTFLoader's Group for a multi-primitive mesh). Failing that, the
87
+ // object itself when its node had one primitive, else the Group GLTFLoader made
88
+ // for a multi-primitive mesh — the one carrying the node's extras / name (a
89
+ // primitive's own Mesh has neither).
70
90
  function nodeObjectOf(obj) {
91
+ for (let o = obj; o && o.parent; o = o.parent) {
92
+ if (typeof o.userData?.partforgeSubPart === "string") return o;
93
+ }
71
94
  const own = obj.userData ?? {};
72
95
  if ("partforgeSubPart" in own || "name" in own) return obj;
73
96
  const parent = obj.parent;
74
97
  return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
75
98
  }
76
99
 
100
+ // The sub-part key a node object stands for: the writer's own record of the key,
101
+ // then the unsanitized glTF node name.
102
+ function subPartNameOf(node) {
103
+ const { partforgeSubPart, name } = node.userData ?? {};
104
+ return typeof partforgeSubPart === "string" ? partforgeSubPart : (name ?? node.name);
105
+ }
106
+
107
+ // An accessor's elements as a Float32Array of `size` components each, through
108
+ // getX/getY/getZ/getW so a normalized output would be denormalized (this writer
109
+ // stores the channels as plain float32; the guard is for any other writer).
110
+ function readComponents(attr, size) {
111
+ const out = new Float32Array(attr.count * size);
112
+ const get = [attr.getX, attr.getY, attr.getZ, attr.getW].slice(0, size);
113
+ for (let i = 0; i < attr.count; i++) for (let k = 0; k < size; k++) out[i * size + k] = get[k].call(attr, i);
114
+ return out;
115
+ }
116
+
117
+ // The baked animation (see the header): the partforgeAnimation extra plus the
118
+ // first glTF animation's channels as model-frame keyframes per sub-part.
119
+ async function readAnimation(gltf) {
120
+ const extra = gltf.userData?.partforgeAnimation;
121
+ if (!extra || typeof extra !== "object") return null;
122
+ const tracks = {};
123
+ const rest = {};
124
+ const def = gltf.parser.json.animations?.[0];
125
+ for (const channel of def?.channels ?? []) {
126
+ const path = channel.target?.path;
127
+ if ((path !== "translation" && path !== "rotation") || channel.target.node === undefined) continue;
128
+ const sampler = def.samplers[channel.sampler];
129
+ const node = await gltf.parser.getDependency("node", channel.target.node);
130
+ const [input, output] = await Promise.all([
131
+ gltf.parser.getDependency("accessor", sampler.input),
132
+ gltf.parser.getDependency("accessor", sampler.output),
133
+ ]);
134
+ const track = (tracks[subPartNameOf(nodeObjectOf(node))] ??= { times: null, translations: null, rotations: null });
135
+ track.times ??= readComponents(input, 1);
136
+ if (path === "translation") track.translations = readComponents(output, 3);
137
+ else track.rotations = readComponents(output, 4);
138
+ }
139
+ for (const [name, track] of Object.entries(tracks)) {
140
+ const t = track.translations ?? new Float32Array(3);
141
+ const r = track.rotations ?? Float32Array.of(0, 0, 0, 1);
142
+ rest[name] = new Matrix4()
143
+ .compose(new Vector3(t[0], t[1], t[2]), new Quaternion(r[0], r[1], r[2], r[3]).normalize(), new Vector3(1, 1, 1))
144
+ .toArray();
145
+ }
146
+ return { ...extra, tracks, rest };
147
+ }
148
+
77
149
  // Line segments as endpoint pairs in model frame: an indexed LINES primitive
78
150
  // expanded back to pairs. Version 3's position attribute is the SURFACE's
79
151
  // (GLTFLoader hands both objects the same BufferAttribute), so only the
@@ -137,10 +209,9 @@ export async function decodePreviewGlb(bytes) {
137
209
  const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
138
210
  const count = surfaceVertexCount(position, geometry.index);
139
211
  const node = nodeObjectOf(obj);
140
- const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
212
+ const { partforgeDisplay } = node.userData;
141
213
  const payload = {
142
- // The writer's own record of the key, then the unsanitized glTF node name.
143
- name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
214
+ name: subPartNameOf(node),
144
215
  positions: readVec3(position, toModel, false, count),
145
216
  normals: normal ? readVec3(normal, normalMatrix, true, count) : null,
146
217
  indices,
@@ -166,5 +237,6 @@ export async function decodePreviewGlb(bytes) {
166
237
  view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
167
238
  meta: metaOf(extras),
168
239
  meshes,
240
+ animation: await readAnimation(gltf),
169
241
  };
170
242
  }