partforge 0.133.3 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/docs/AUTHORING-PARTS.md +80 -19
  2. package/docs/KERNEL-CONTRACT.md +1 -1
  3. package/package.json +1 -1
  4. package/src/app-materials.js +3 -1
  5. package/src/framework/export/bundle.js +413 -0
  6. package/src/framework/export/drawing.js +356 -0
  7. package/src/framework/export/dxf.js +150 -0
  8. package/src/framework/export/formats.js +142 -0
  9. package/src/framework/export/layout.js +92 -0
  10. package/src/framework/export/placement.js +14 -0
  11. package/src/framework/export/readme.js +103 -0
  12. package/src/framework/export/svg.js +97 -0
  13. package/src/framework/export-controller.js +7 -2
  14. package/src/framework/export-rows.js +34 -0
  15. package/src/framework/jobs.js +16 -0
  16. package/src/framework/lint/rules-materials.js +9 -2
  17. package/src/framework/materials/assets/SOURCES.md +10 -4
  18. package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
  19. package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
  20. package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
  21. package/src/framework/materials/assets.js +3 -0
  22. package/src/framework/materials/patterns.js +103 -0
  23. package/src/framework/materials/physical.js +5 -2
  24. package/src/framework/materials/presets.js +11 -5
  25. package/src/framework/materials/resolve.js +4 -2
  26. package/src/framework/materials/sheet-look.js +204 -0
  27. package/src/framework/mount.js +74 -37
  28. package/src/framework/process/exporters.js +18 -0
  29. package/src/framework/process/laser/export.js +53 -0
  30. package/src/framework/sheet/part.js +32 -17
  31. package/src/framework/viewer.js +53 -29
  32. package/src/index.js +4 -0
  33. package/src/parts/laser-box.js +1 -1
  34. package/src/parts/material-swatches.js +58 -2
  35. package/types/index.d.ts +72 -5
  36. package/types/part.d.ts +4 -0
  37. package/types/testing.d.ts +7 -3
@@ -334,9 +334,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
334
334
  - `material` — a preset id from the table below. Without one (or with one the
335
335
  library does not know) the sub-part keeps the CAD view's look — its `color`,
336
336
  else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
337
- (`pla-print`'s finish and layer lines) in that same colour. So an untouched
338
- part looks printed in realistic mode; name a material when it is made some
339
- other way. The PLA look is realistic-only: the CAD view, export colours and
337
+ (`pla-print`'s finish and layer lines) in that same colour — except a laser
338
+ `sheetPart`, which shows its stock instead (below). So an untouched part looks
339
+ printed in realistic mode; name a material when it is made some other way. The
340
+ PLA look is realistic-only: the CAD view, export colours and
340
341
  `declaresMaterials` are unaffected, and a part that names no material is not
341
342
  treated as declaring one.
342
343
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
@@ -349,6 +350,23 @@ meta: { title: "…", units: "mm", environment: "studio" },
349
350
  - **One material per sub-part.** Something that needs two finishes (a knurled
350
351
  grip in rubber on an aluminium body) is two sub-parts.
351
352
 
353
+ **Sheet parts default to their stock.** A laser `sheetPart` that names no material (or
354
+ one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
355
+ sheet its stock label names — the first row with a stem that starts a word of the label,
356
+ ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
357
+
358
+ | A word of the stock label starts with | Realistic look |
359
+ | --- | --- |
360
+ | `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
361
+ | anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
362
+
363
+ Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
364
+ look too, charred edges and all. A `color` tints the look, as it tints any preset (a
365
+ stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
366
+ string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
367
+ `display.material` to choose the look yourself — for such stock, or any other. Like the
368
+ PLA look, this is realistic-only and never makes a part declare a material.
369
+
352
370
  **Where it shows.** The CAD view (with feature lines) shows each material's
353
371
  colour, flattened so dark materials stay readable. **Realistic** mode — the
354
372
  viewer's toggle — shows the full material under environment lighting, with a
@@ -365,6 +383,15 @@ displayed. If the lines run the wrong way, fix the export pose with `place()`
365
383
  fixed to the sub-part, so they never slide when the camera or an animation
366
384
  moves.
367
385
 
386
+ **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
387
+ `walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
388
+ — shows what the laser did: its cut edges are charred, darker on thicker stock (on
389
+ `plywood` the plies show through), and its engraving and score lines are scorched, while its
390
+ faces stay wood. There is nothing to set, and it is still one material: the char follows
391
+ from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
392
+ with a custom `build` or its own `place` shows plain wood. The CAD view and every export are
393
+ unchanged.
394
+
368
395
  **`textureScale` is millimetres, and what it measures depends on the preset's
369
396
  pattern** — so a value copied from one preset family is wrong on another (0.2
370
397
  on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
@@ -373,7 +400,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
373
400
  | --- | --- | --- | --- |
374
401
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
375
402
  | SLS grain | `nylon-sls` | grain size | 0.15 |
376
- | wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
403
+ | wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
377
404
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
378
405
 
379
406
  Presets without a pattern ignore it.
@@ -409,6 +436,7 @@ the viewer's blue-grey.
409
436
  | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
410
437
  | `oak` | Oak | — | Light oak with open grain. |
411
438
  | `walnut` | Walnut | — | Dark oiled walnut. |
439
+ | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
412
440
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
413
441
 
414
442
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -1808,8 +1836,8 @@ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d)
1808
1836
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1809
1837
  `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1810
1838
  `enabled`, `exportable`, `reference` pass through; your own `place` runs after the
1811
- pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` (use
1812
- `profile`) and `quantity` (the kit counts identical pieces).
1839
+ pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1840
+ `quantity`; each error names the fix.
1813
1841
 
1814
1842
  ### Cut, score and engrave
1815
1843
 
@@ -1898,7 +1926,7 @@ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1898
1926
  | `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
1899
1927
  | `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
1900
1928
 
1901
- **Verify** runs the laser checks on every sheet part. They are
1929
+ **Verify** runs the laser checks on every sheet part, as
1902
1930
  *volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
1903
1931
  `verify.ok` true on its own. Declare one in `expect` to make it count:
1904
1932
 
@@ -1920,16 +1948,21 @@ unevaluated. In a forge mixing sheet and printed
1920
1948
  parts, the profile's bed fits each **printed** part in its print (export) pose, not
1921
1949
  the assembled view, and `minWall` and the overhang check skip sheet parts.
1922
1950
 
1951
+ ### The kit
1952
+
1953
+ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"` or `"service"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
1954
+
1923
1955
  ### Limits
1924
1956
 
1925
1957
  - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1926
1958
  same sheet.
1927
- - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. A
1928
- panel at an angle is an author `place` after the pose, or a printed part.
1959
+ - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. An
1960
+ angled panel is an author `place` after the pose (realistic mode then shows
1961
+ no laser burns), or a printed part.
1929
1962
  - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1930
1963
  `grain` are reserved keys and throw.
1931
- - Cut and score are vector lines; engraving is filled regions. There is no raster
1932
- image engraving.
1964
+ - Cut and score are vector lines; engraving is filled regions, never a raster
1965
+ image.
1933
1966
  - The laser checks are warnings. Sharp-corner shrinking can over-report at acute
1934
1967
  tips, and a web within 0.05 mm of the floor can read as passing.
1935
1968
 
@@ -1982,7 +2015,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
1982
2015
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
1983
2016
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
1984
2017
 
1985
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
2018
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
1986
2019
  const panel = (name, label, extra = {}) => sheetPart({
1987
2020
  ...PLY, label,
1988
2021
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -2995,15 +3028,41 @@ view-bound export UI. An embedder that wants its own export UI (e.g. a "pick whi
2995
3028
  pick a format" modal) can skip those buttons and drive export off the handle `mount()`
2996
3029
  returns instead:
2997
3030
 
2998
- - `runtime.listExportableParts() → [{ name, label }]` — every exportable sub-part
3031
+ - `runtime.listExportableParts() → [{ name, label, sheet? }]` — every exportable sub-part
2999
3032
  (excludes any `exportable: false` part, respects each part's `enabled(params)`),
3000
- **independent of the active view**. Use it to populate an export checklist.
3001
- - `runtime.exportParts({ parts, format, quality?, onProgress }) → Promise<void>` — build
3002
- the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf"`), streaming
3033
+ **independent of the active view**. Use it to populate an export checklist. A sheet
3034
+ part's row also carries `sheet: { process, material, thickness, group }`, evaluated at
3035
+ the current params (omitted when that throws) — enough to tag it in the list and to
3036
+ name its stock group in the kit's `stock` option.
3037
+ - `runtime.listExportFormats() → [{ id, label, ext, mime, needsSheet }]` — the formats
3038
+ `exportParts` writes in this version, as fresh copies of `EXPORT_FORMATS`. `needsSheet`
3039
+ marks the one that needs a sheet part checked: `"bundle"`, the cut & print kit.
3040
+ - `runtime.exportParts({ parts, format, quality?, onProgress, options? }) → Promise<void>` — build
3041
+ the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf" | "bundle"`), streaming
3003
3042
  phase strings to `onProgress(phase)`. Resolves once the file is written (handed to your
3004
3043
  `onDownload` sink, or downloaded directly if you don't supply one); rejects on
3005
3044
  build/export failure or an empty selection. Placement uses the current
3006
- view. STEP is routed to OCCT automatically.
3045
+ view. STEP is routed to OCCT automatically. Only `"bundle"` reads `options`.
3046
+ - **The cut & print kit** (`format: "bundle"`) writes one `<title>-kit.zip`: `README.txt`
3047
+ (the thickness each material assumes, the kerf applied, a scale check per piece, the
3048
+ colour or DXF-layer legend, every 2-D check that warned), `parts.csv`,
3049
+ `sheets/<material>-<t>mm/sheet-<i>-of-<n>.svg` (the pieces laid out on the user's
3050
+ stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
3051
+ `print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
3052
+ `destination: "service"` each piece is a cut-only `.dxf` plus a `-marks.dxf` for its
3053
+ score and engrave lines, and the DXF sheets are for reference. Every `options` key is
3054
+ optional: `destination` (`"own-laser"`, the default, or `"service"`), `kerf` (0–0.5 mm,
3055
+ applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
3056
+ `sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
3057
+ mm when `stock` is omitted), `margin` (5 mm),
3058
+ `spacing` (3 mm), `printFormat` (`"stl"` or `"3mf"`) and `sets` (1–20). The main entry
3059
+ exports `EXPORT_FORMATS`, `KIT_DEFAULTS`, `validateKitOptions` and `KIT_OPTIONS_ERROR`
3060
+ for a host that draws its own options screen. An option the kit cannot honour — an
3061
+ unknown key, a kerf that closes a slot, a piece bigger than an own laser's sheet, a
3062
+ margin that leaves no room on the sheet, a stock entry naming no group, more than 200
3063
+ pieces or 50 sheets of one material —
3064
+ rejects with a message starting `cut kit options:`: the user's setting to change, not
3065
+ the part's code.
3007
3066
  - `runtime.warmExportKernel() → Promise<boolean>` — pay OCCT's cold boot *before* an
3008
3067
  export needs it. Because STEP is pinned to OCCT and OCCT's ~11 MB WASM loads on its
3009
3068
  first job, a part whose preview ran on Manifold pays that whole boot inside its first
@@ -3147,7 +3206,8 @@ addition to) the generated view style button above:
3147
3206
  revert too.
3148
3207
  - `runtime.declaresMaterials` — `true` when any sub-part names a
3149
3208
  `display.material`. A part with none still supports realistic mode (every
3150
- sub-part renders as a PLA print in its CAD colour), so use this to decide
3209
+ sub-part renders as a PLA print in its CAD colour, a laser sheet part as its
3210
+ stock — "Sheet parts default to their stock"), so use this to decide
3151
3211
  whether to surface your own realistic control at all, not whether it works.
3152
3212
  Feature lines are not a preference: they draw whenever `runtime.renderMode`
3153
3213
  reads `"cad"` and never while it reads `"realistic"` — there is no switch
@@ -3635,7 +3695,8 @@ trust handling.
3635
3695
 
3636
3696
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3637
3697
  library does not know; the viewer draws it as if it named none — a PLA print
3638
- in realistic mode), `unknown-environment`
3698
+ in realistic mode, or for a laser sheet part its stock's look, per "Sheet parts
3699
+ default to their stock"), `unknown-environment`
3639
3700
  (`meta.environment` not known; realistic mode uses `studio`),
3640
3701
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3641
3702
  or one of the six overrides; ignored), `material-override-clamped` (an override
@@ -852,7 +852,7 @@ maps that protocol onto `setPart` itself.
852
852
  readiness exactly as it would on a freshly spawned worker.
853
853
 
854
854
  **Epoch guard.** Generates supersede each other; exports (`export-stl`/`export-step`/
855
- `export-3mf`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
855
+ `export-3mf`/`export-bundle`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
856
856
  export because an edit landed would be wrong. A generate that is stale by the time the
857
857
  job pump reaches it is skipped and never builds at all. A generate already running
858
858
  re-checks staleness at each sub-part boundary and, if it has been superseded, stops
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.133.3",
3
+ "version": "0.135.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",
@@ -12,7 +12,9 @@ import { mount } from "./framework/index.js";
12
12
  // open /materials.html. Not part of the production build — check materials.html
13
13
  // by eye after any preset or shader change (see AGENTS.md "Architecture").
14
14
  // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
15
- // the embedding contract (runtime.captureCurrent) the way an embedder would.
15
+ // the embedding contract (runtime.captureCurrent) the way an embedder would, and
16
+ // scripts/capture-contact-sheet.mjs can switch views and environments and call
17
+ // runtime.renderViews — removing it breaks both.
16
18
  window.__pfRuntime = mount(materialSwatchesPart, {
17
19
  createWorker: (name) =>
18
20
  new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
@@ -0,0 +1,413 @@
1
+ // The cut & print kit (spec D): one ZIP per forge — every checked sheet part drawn flat
2
+ // and laid out on the user's stock, every checked printed part as STL or 3MF, a README
3
+ // and a parts list.
4
+ //
5
+ // jobs.js loads this module LAZILY, on the first export-bundle job (`loadBundle`), never
6
+ // at worker boot (test/worker-layering.test.js). And nothing here may reach ./oracle/*
7
+ // (test/kit-layering.test.js mocks every oracle module to throw): the mesh oracle has
8
+ // OOM-killed iOS Safari, and the kit needs none of it — a sheet is drawn from its 2-D
9
+ // declaration and checked by its own process's facts()/checks().
10
+ import { zipSync, strToU8 } from "fflate";
11
+ import { safeName } from "../safe-name.js";
12
+ import { meshTo3MF } from "../geometry/threemf.js";
13
+ import { h } from "../geometry/solid-hash.js";
14
+ import { printColor } from "../materials/resolve.js";
15
+ import {
16
+ isSheetPart, sheetMeta, fmtMm, SHEET_CHECK_BUDGET_MS, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT,
17
+ } from "../sheet/constants.js";
18
+ import { resolveSheet } from "../sheet/resolve.js";
19
+ import { processById, SHEET_METRICS } from "../process/registry.js";
20
+ import { loadExporter } from "../process/exporters.js";
21
+ import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
22
+ import { canonicalDrawingKey } from "./drawing.js";
23
+ import { renderSvg } from "./svg.js";
24
+ import { renderDxf } from "./dxf.js";
25
+ import { layoutGroup, marginFitsStock } from "./layout.js";
26
+ import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
27
+
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))$/;
31
+
32
+ // A path segment from an author string. safeName already lowercases, collapses runs and
33
+ // refuses a leading dot or dash; folding every run of dots on top means no entry name
34
+ // can ever carry "..". safeName keeps a leading underscore, which KIT_ENTRY_RE (copied
35
+ // verbatim by the cloud) refuses, so it comes off here too — a sub-part keyed `_left`
36
+ // is `left`, never a kit that fails at its last step — and a name that was nothing but
37
+ // separators falls back to "part".
38
+ export const kitName = (s) => safeName(s, "part").replace(/\.{2,}/g, ".").replace(/^[._-]+/, "") || "part";
39
+
40
+ // The first of base, base-2, base-3, … whose files are all free, claimed in `taken`.
41
+ // `filesOf(name)` lists every path a name produces — a piece's cut file and its -marks
42
+ // file — so a name is free only when all of them are, and "front" ×2 can never land on
43
+ // the file of a piece literally named "front-x2". Called in definition order, so a
44
+ // forge always gets the same names.
45
+ export function uniqueName(base, taken, filesOf = (name) => [name]) {
46
+ let name = base;
47
+ for (let i = 2; filesOf(name).some((f) => taken.has(f)); i++) name = `${base}-${i}`;
48
+ for (const f of filesOf(name)) taken.add(f);
49
+ return name;
50
+ }
51
+
52
+ // Merge equal items, first-seen order kept. `hashOf` only NOMINATES candidates — the kit
53
+ // hashes with solid-hash.js's 32-bit FNV, which can collide — and `same(a, b)` confirms.
54
+ // Returns [{ item, members }], `item` being the first member.
55
+ export function dedupe(items, hashOf, same) {
56
+ const groups = [];
57
+ const byHash = new Map();
58
+ for (const item of items) {
59
+ const key = hashOf(item);
60
+ const candidates = byHash.get(key) ?? [];
61
+ const hit = candidates.find((g) => same(g.item, item));
62
+ if (hit) { hit.members.push(item); continue; }
63
+ const group = { item, members: [item] };
64
+ candidates.push(group);
65
+ byHash.set(key, candidates);
66
+ groups.push(group);
67
+ }
68
+ return groups;
69
+ }
70
+
71
+ // verify's wording for the three expression forms a process's checks() emits — ">=N",
72
+ // "<=N" and a bare number (equality) — restated from oracle/assert-dsl.js, because the
73
+ // kit must never load ./oracle/*. test/kit-bundle-helpers.test.js holds the two equal.
74
+ // Any other form throws: it needs a line here, not a silent pass.
75
+ const EPS = 1e-6;
76
+ export function checkMessage(expr, actual) {
77
+ const m = /^(>=|<=)?\s*([-+]?[0-9]*\.?[0-9]+)$/.exec(String(expr).trim());
78
+ if (!m) throw new Error(`cut kit: unsupported sheet check "${expr}"`);
79
+ const value = Number(m[2]);
80
+ if (m[1] === ">=") {
81
+ const pass = actual >= value - EPS;
82
+ return { pass, message: `${actual} ${pass ? ">=" : "not >="} ${value}` };
83
+ }
84
+ if (m[1] === "<=") {
85
+ const pass = actual <= value + EPS;
86
+ return { pass, message: `${actual} ${pass ? "<=" : "not <="} ${value}` };
87
+ }
88
+ const pass = Math.abs(actual - value) <= EPS + EPS * Math.abs(value);
89
+ return { pass, message: `${actual} ${pass ? "==" : "!="} ${value}` };
90
+ }
91
+
92
+ // SHEET_CHECKS_NOTICE.message ("2-D sheet checks not evaluated (time budget)") is
93
+ // oracle/verify.js's own wording for the same state, imported from sheet/constants.js —
94
+ // the one place it lives, since the kit must never load ./oracle/*. The kit appends its
95
+ // own reason to the shared prefix rather than restating the whole line.
96
+ const NOT_EVALUATED = `${SHEET_CHECKS_NOTICE.message} — the kit ran out of time checking this piece for narrow webs, narrow gaps and stray marks; look it over before cutting`;
97
+
98
+ // The README's CHECKS lines for one piece: every volunteered check its process runs
99
+ // that fails, as `${label}: ${message} — ${hint}` with the metric's own hint. A metric
100
+ // with no reading (budget-gated and not evaluated, or a custom build's volume match,
101
+ // which needs a solid the kit never builds) is skipped; an unevaluated piece gets one
102
+ // line saying so instead. A metric the geometry engine refused to read (readErrors) gets
103
+ // its own line, worded like verify's: `not measured — <reason>`, with SHEET_READ_ERROR_HINT
104
+ // (sheet/constants.js, imported above — the kit must never load ./oracle/* itself, so it
105
+ // reads the one shared text rather than a copy) — never the metric's own hint, which
106
+ // describes a value that was never taken.
107
+ export function sheetWarnings(label, facts) {
108
+ const desc = facts ? processById(facts.process) : null;
109
+ if (!desc) return [];
110
+ const out = [];
111
+ for (const [metric, expr] of Object.entries(desc.checks(facts))) {
112
+ const reg = SHEET_METRICS[metric];
113
+ const actual = reg?.extract({ sheet: facts });
114
+ if (actual === null || actual === undefined) {
115
+ const why = reg?.readError?.({ sheet: facts });
116
+ if (why) out.push(`${label}: not measured — ${why} — ${SHEET_READ_ERROR_HINT}`);
117
+ continue;
118
+ }
119
+ const { pass, message } = checkMessage(expr, actual);
120
+ if (!pass) out.push(`${label}: ${message} — ${reg.hint}`);
121
+ }
122
+ if (facts.evaluated === false) out.push(`${label}: ${NOT_EVALUATED}`);
123
+ return out;
124
+ }
125
+
126
+ // ─── buildBundle ────────────────────────────────────────────────────────────
127
+
128
+ // cl:src/sandbox/protocol.js clips a progress phase at 120 characters; the kit keeps
129
+ // its own phases under that, so a long label is cut here, once, with an ellipsis.
130
+ const PHASE_MAX = 120;
131
+ const clipPhase = (s) => (s.length > PHASE_MAX ? `${s.slice(0, PHASE_MAX - 1)}…` : s);
132
+ const sizeOf = (b) => [b.max[0] - b.min[0], b.max[1] - b.min[1]];
133
+ const near = (a, b) => Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
134
+ // The words of a control's key or label: camelCase, snake_case, digits and spaces all
135
+ // split, lowercased — "fingerClearance" → finger, clearance.
136
+ const wordsOf = (s) => String(s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2")
137
+ .toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
138
+ // Controls the README lists as design fit rather than kerf (spec D.9): a WORD of the key
139
+ // or label is clearance, fit or play — so "Finger clearance", "tab_play" and "pressFit"
140
+ // are listed, and "display", "outfit" and "profit" are not.
141
+ const CLEARANCE_WORDS = new Set(["clearance", "clearances", "fit", "play"]);
142
+ const isClearance = (c) => [...wordsOf(c.key), ...wordsOf(c.label)].some((w) => CLEARANCE_WORDS.has(w));
143
+ const ALL_LAYERS = ["cut", "score", "engrave"];
144
+
145
+ // Every control that owns a parameter key, whatever the section shape: authored
146
+ // `controls` (groups nest), and the legacy `advanced`, `toggles`, `features` and a
147
+ // feature's own `sliders`. Groups and presets own no key.
148
+ function collectControls(parameters) {
149
+ const out = [];
150
+ const visit = (list) => {
151
+ for (const c of Array.isArray(list) ? list : []) {
152
+ if (!c || typeof c !== "object") continue;
153
+ if (typeof c.key === "string" && c.type !== "group" && c.type !== "preset") out.push(c);
154
+ visit(c.controls);
155
+ visit(c.sliders);
156
+ }
157
+ };
158
+ for (const sec of Array.isArray(parameters) ? parameters : []) {
159
+ visit(sec?.controls); visit(sec?.advanced); visit(sec?.toggles); visit(sec?.features);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // The control that sets a sheet part's thickness, so the README can name the setting to
165
+ // change when the user's sheet measures differently. Of the controls the thickness
166
+ // function reads, the one whose value IS the thickness it returned (a
167
+ // `p.material === "acrylic" ? p.acrylicT : p.plyT` reads Material first, and Material
168
+ // is not the thickness); failing a single such control, the one read whose key or label
169
+ // says "thick"; else null — the generic wording, never a guess. Null too for a literal
170
+ // thickness or one that reads no control.
171
+ function thicknessControlLabel(controls, sp, p, d) {
172
+ const t = sp.sheet.thickness;
173
+ if (typeof t !== "function") return null;
174
+ const reads = new Set();
175
+ const spy = new Proxy(p, { get: (target, key) => { if (typeof key === "string") reads.add(key); return target[key]; } });
176
+ let value;
177
+ try { value = t(spy, d); } catch { return null; }
178
+ const read = controls.filter((x) => reads.has(x.key));
179
+ const holds = read.filter((x) => typeof p[x.key] === "number" && Math.abs(p[x.key] - value) <= 1e-9);
180
+ const pool = holds.length > 1 ? holds : read;
181
+ const named = pool.filter((x) => [...wordsOf(x.key), ...wordsOf(x.label)].some((w) => w.startsWith("thick")));
182
+ const c = holds.length === 1 ? holds[0] : named.length === 1 ? named[0] : null;
183
+ return c ? String(c.label ?? c.key) : null;
184
+ }
185
+
186
+ // A sheet part's stock group, or the part's own named error: sheetMeta answers null
187
+ // when material or thickness cannot be evaluated, and resolveSheet is what knows how to
188
+ // say why.
189
+ function sheetMetaOrThrow(kernel, sp, p, d, name) {
190
+ const meta = sheetMeta(sp, p, d);
191
+ if (meta) return meta;
192
+ resolveSheet(kernel, sp, p, d);
193
+ throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
194
+ }
195
+
196
+ // One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
197
+ // definition order — and `posed`/`label`/`exportName` are jobs.js's own, so a kit's
198
+ // printed parts are built exactly the way the STL and 3MF exports build them.
199
+ export async function buildBundle({ kernel, part, msg, p, d, posed, label, exportName, names, onProgress, fileBase }) {
200
+ // Options first, before anything is built: a bad option is the user's to fix and must
201
+ // not wait behind a build (spec D.3). An absent options object means every default.
202
+ const o = validateKitOptions(msg.options ?? {});
203
+ const service = o.destination === "service";
204
+ const cutExt = service ? "dxf" : "svg";
205
+ const say = (phase) => onProgress(clipPhase(phase));
206
+ const nameOf = (name) => cleanLabel(label(name));
207
+ if (names.length === 0) throw new Error("no exportable parts selected");
208
+ const sheetNames = names.filter((name) => isSheetPart(part.parts[name]));
209
+ const printNames = names.filter((name) => !isSheetPart(part.parts[name]));
210
+ if (sheetNames.length === 0) {
211
+ throw new Error(`${KIT_OPTIONS_ERROR} none of the checked parts is a sheet part — check a sheet part or export STL/3MF instead`);
212
+ }
213
+ // The cut-piece count is sheet sub-parts × sets — exactly what the merged pieces sum
214
+ // to — so it is checked before the builds it would otherwise waste. The remedy named
215
+ // is one that can work: past the cap at a single set, only fewer parts will do.
216
+ const cutCount = sheetNames.length * o.sets;
217
+ if (cutCount > KIT_LIMITS.pieces) {
218
+ const remedy = sheetNames.length > KIT_LIMITS.pieces ? "check fewer sheet parts" : "lower sets";
219
+ throw new Error(`${KIT_OPTIONS_ERROR} the kit has ${cutCount} pieces — at most ${KIT_LIMITS.pieces}; ${remedy}`);
220
+ }
221
+
222
+ // Stock groups from each part's material and thickness alone, so a stock entry that
223
+ // names no group in the kit fails before any drawing is made.
224
+ const groups = new Map();
225
+ const groupOf = new Map();
226
+ for (const name of sheetNames) {
227
+ const meta = sheetMetaOrThrow(kernel, part.parts[name], p, d, nameOf(name));
228
+ if (!groups.has(meta.group)) {
229
+ const material = cleanLabel(meta.material);
230
+ groups.set(meta.group, { group: meta.group, material, thickness: meta.thickness,
231
+ label: `${material} ${fmtMm(meta.thickness)} mm`, first: name });
232
+ }
233
+ groupOf.set(name, meta.group);
234
+ }
235
+ const stockFor = resolveStock(o.stock, [...groups.values()].map((g) => ({ group: g.group, label: g.label })));
236
+ // A margin that leaves a group's stock with no inside at all is known right here —
237
+ // check it before any piece is drawn (spec D.3), not only once layoutGroup reaches
238
+ // that group after every sheet part has been built.
239
+ for (const g of groups.values()) marginFitsStock(stockFor.get(g.group), o.margin, g.label);
240
+
241
+ // Draw every sheet part flat, from its 2-D declaration, with its 2-D facts under ONE
242
+ // budget for the whole kit.
243
+ const deadline = Date.now() + SHEET_CHECK_BUDGET_MS;
244
+ const drawn = [];
245
+ for (const name of sheetNames) {
246
+ say(`building ${nameOf(name)}`);
247
+ const s = resolveSheet(kernel, part.parts[name], p, d);
248
+ const desc = processById(s.process);
249
+ if (!desc) throw new Error(`sheet part "${nameOf(name)}": unknown process "${s.process}"`);
250
+ const facts = desc.facts(s, { deadline });
251
+ const exporter = await loadExporter(s.process);
252
+ const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts });
253
+ drawn.push({ name, label: nameOf(name), fileKey: exportName(name), group: groupOf.get(name),
254
+ drawing, facts, key: canonicalDrawingKey(drawing) });
255
+ }
256
+
257
+ // Identical pieces become one file ×N (spec D.4): the hash nominates; the canonical
258
+ // drawing and the stock group confirm.
259
+ const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group)
260
+ .map(({ item, members }) => ({
261
+ ...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
262
+ hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
263
+ }));
264
+
265
+ // Names, in definition order. A name that was taken moves to name-2; the README's
266
+ // RENAMED FILES says which file it was and who held the name (spec D.10). A piece
267
+ // claims its bare name as well as its files (the `parts/<name>/` key, which no file
268
+ // path can equal): `-x<n>` is a QUANTITY, so a second, different piece with the
269
+ // same name must be name-2 — never name.svg beside name-x2.svg, which reads as one
270
+ // piece at two quantities.
271
+ const taken = new Set();
272
+ const owners = new Map();
273
+ const renamed = [];
274
+ const claim = (base, filesOf, labels) => {
275
+ const name = uniqueName(base, taken, filesOf);
276
+ const file = filesOf(name)[0];
277
+ if (name !== base) {
278
+ const holder = filesOf(base).map((f) => owners.get(f)).find(Boolean);
279
+ renamed.push(`${file}: ${labels.join(", ")}${holder ? ` (renamed: ${holder.file} is ${holder.labels.join(", ")})` : ""}`);
280
+ }
281
+ for (const f of filesOf(name)) owners.set(f, { file, labels });
282
+ return name;
283
+ };
284
+ for (const pc of pieces) {
285
+ const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
286
+ const marks = service && pc.hasMarks;
287
+ const name = claim(kitName(pc.fileKey),
288
+ (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
289
+ pc.file = `parts/${name}${sx}.${cutExt}`;
290
+ pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
291
+ }
292
+
293
+ // Lay out each stock group. An own laser needs every piece to fit (layoutGroup
294
+ // throws "stock too small"); for a cutting service the sheets are reference only, so
295
+ // an oversized piece is listed in the README instead (spec D.5).
296
+ const sheets = [];
297
+ const oversized = [];
298
+ for (const g of groups.values()) {
299
+ const inGroup = pieces.filter((pc) => pc.group === g.group);
300
+ const count = inGroup.reduce((n, pc) => n + pc.qty, 0);
301
+ say(`laying out ${g.material} ${fmtMm(g.thickness)} mm (${count} pieces)`);
302
+ const stock = stockFor.get(g.group);
303
+ const laid = layoutGroup({
304
+ group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !service,
305
+ pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
306
+ });
307
+ if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
308
+ 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`);
309
+ }
310
+ const dir = claim(kitName(`${g.material}-${fmtMm(g.thickness)}mm`), (n) => [`sheets/${n}/`], [g.label]);
311
+ laid.sheets.forEach((sh, i) => sheets.push({
312
+ file: `sheets/${dir}/sheet-${i + 1}-of-${laid.sheets.length}.${cutExt}`,
313
+ group: g, stock, placements: sh.placements, used: sh.used, index: i + 1, count: laid.sheets.length,
314
+ }));
315
+ for (const key of laid.oversized) {
316
+ const pc = inGroup.find((x) => x.file === key);
317
+ const [w, ht] = sizeOf(pc.drawing.bounds);
318
+ oversized.push(`${pc.labels.join(", ")}: ${w.toFixed(1)} × ${ht.toFixed(1)} mm does not fit a ${fmtMm(stock[0])} × ${fmtMm(stock[1])} mm sheet after its ${fmtMm(o.margin)} mm margin (${g.label}) — cut it from ${pc.file}`);
319
+ }
320
+ }
321
+
322
+ // Printed parts, posed for export exactly as the STL/3MF exports pose them. Identical
323
+ // prints merge: the solid hash nominates; volume, bounding box, triangle count and
324
+ // print colour confirm (spec D.4).
325
+ const quality = msg.quality ?? "print";
326
+ const builtPrints = [];
327
+ for (const name of printNames) {
328
+ say(`building ${nameOf(name)}`);
329
+ const solid = posed(name, "export", onProgress);
330
+ const box = solid.boundingBox();
331
+ const display = part.parts[name]?.display;
332
+ const pp = {
333
+ name, label: nameOf(name), fileKey: exportName(name), hash: String(solid._hash ?? ""),
334
+ size: [0, 1, 2].map((i) => box.max[i] - box.min[i]), volume: solid.volume(),
335
+ color: printColor(display), material: typeof display?.material === "string" ? display.material : "",
336
+ };
337
+ if (o.printFormat === "stl") {
338
+ pp.bytes = new Uint8Array(await solid.toSTL({ quality }));
339
+ pp.triangles = new DataView(pp.bytes.buffer, pp.bytes.byteOffset, pp.bytes.byteLength).getUint32(80, true);
340
+ } else {
341
+ const { positions, indices } = solid.toIndexedMesh({ quality });
342
+ pp.mesh = { positions, indices };
343
+ pp.triangles = indices.length / 3;
344
+ }
345
+ builtPrints.push(pp);
346
+ }
347
+ const prints = dedupe(builtPrints, (x) => x.hash, (a, b) => a.triangles === b.triangles && a.color === b.color
348
+ && near(a.volume, b.volume) && a.size.every((x, i) => near(x, b.size[i])))
349
+ .map(({ item, members }) => ({ ...item, labels: members.map((m) => m.label), qty: members.length * o.sets }));
350
+ for (const pp of prints) {
351
+ const sx = pp.qty > 1 ? `-x${pp.qty}` : "";
352
+ const filesOf = (n) => [`print/${n}${sx}.${o.printFormat}`, `print/${n}/`];
353
+ pp.file = filesOf(claim(kitName(pp.fileKey), filesOf, pp.labels))[0];
354
+ if (!pp.bytes) pp.bytes = new Uint8Array(meshTo3MF([{ name: pp.fileKey, ...pp.mesh, color: pp.color }]));
355
+ }
356
+
357
+ // The files (spec D.8): own laser — an SVG per sheet and per piece; service — a
358
+ // cut-only DXF per piece, a -marks.dxf where it has marks, and reference DXF sheets.
359
+ say("writing cut files");
360
+ const title = cleanLabel(part.meta?.title ?? fileBase);
361
+ const files = [];
362
+ const put = (file, text) => files.push({ file, data: strToU8(text) });
363
+ for (const sh of sheets) {
364
+ const doc = { title: `${title} — ${sh.group.label} — sheet ${sh.index} of ${sh.count}`, size: sh.stock, placements: sh.placements };
365
+ put(sh.file, service ? renderDxf(doc, { include: ALL_LAYERS }) : renderSvg(doc));
366
+ }
367
+ for (const pc of pieces) {
368
+ const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
369
+ placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
370
+ if (!service) { put(pc.file, renderSvg(doc)); continue; }
371
+ put(pc.file, renderDxf(doc, { include: ["cut"] }));
372
+ if (pc.marksFile) put(pc.marksFile, renderDxf(doc, { include: ["score", "engrave"] }));
373
+ }
374
+ for (const pp of prints) files.push({ file: pp.file, data: pp.bytes });
375
+
376
+ const controls = collectControls(part.parameters);
377
+ const readme = renderReadme({
378
+ title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
379
+ stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
380
+ control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
381
+ scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
382
+ sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
383
+ size: sh.stock, pieces: sh.placements.length, used: sh.used })),
384
+ pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
385
+ thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
386
+ printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
387
+ clearances: controls.filter(isClearance)
388
+ .map((c) => ({ label: c.label ?? c.key, value: p[c.key], unit: c.unit ?? "" })),
389
+ settings: Object.keys(p).map((key) => ({ key, value: p[key] })),
390
+ checks: pieces.flatMap((pc) => sheetWarnings(pc.labels.join(", "), pc.facts)),
391
+ oversized, renamed,
392
+ });
393
+ const csv = renderPartsCsv([
394
+ ...pieces.map((pc) => ({ file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
395
+ thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty })),
396
+ ...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
397
+ width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
398
+ ]);
399
+
400
+ // Every planned file must land under its own name: a path two files shared would
401
+ // silently overwrite one of them, so the count is asserted, and every name is held to
402
+ // the entry pattern once more on the way in.
403
+ const planned = [{ file: "README.txt", data: strToU8(readme) }, { file: "parts.csv", data: strToU8(csv) }, ...files];
404
+ const entries = {};
405
+ for (const { file, data } of planned) {
406
+ if (!KIT_ENTRY_RE.test(file) || file.includes("..")) throw new Error(`cut kit: bad entry name ${JSON.stringify(file)}`);
407
+ entries[file] = data;
408
+ }
409
+ const built = Object.keys(entries).length;
410
+ if (built !== planned.length) throw new Error(`cut kit: planned ${planned.length} files but built ${built}`);
411
+ say("zipping kit");
412
+ return { data: zipSync(entries, { level: 6 }), filename: `${fileBase}-kit.zip` };
413
+ }