partforge 0.132.1 → 0.133.1

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 (34) hide show
  1. package/bin/cli.js +12 -2
  2. package/docs/AUTHORING-PARTS.md +311 -6
  3. package/docs/KERNEL-CONTRACT.md +41 -11
  4. package/package.json +1 -1
  5. package/src/framework/geometry/arc-fit.js +169 -36
  6. package/src/framework/geometry/arc-math.js +15 -7
  7. package/src/framework/geometry/contour-offset.js +132 -5
  8. package/src/framework/geometry/kernel.js +1 -1
  9. package/src/framework/geometry/occt-backend.js +6 -2
  10. package/src/framework/geometry/occt-step.js +70 -0
  11. package/src/framework/geometry/polygon.js +7 -0
  12. package/src/framework/jobs.js +1 -1
  13. package/src/framework/lint/index.js +2 -1
  14. package/src/framework/lint/rules-schema.js +2 -1
  15. package/src/framework/lint/rules-shape.js +10 -3
  16. package/src/framework/lint/rules-sheet.js +172 -0
  17. package/src/framework/lint/rules-verify.js +10 -0
  18. package/src/framework/oracle/gates.js +13 -0
  19. package/src/framework/oracle/measure.js +129 -5
  20. package/src/framework/oracle/verify.js +113 -5
  21. package/src/framework/process/laser/descriptor.js +1089 -0
  22. package/src/framework/process/registry.js +24 -0
  23. package/src/framework/sheet/constants.js +104 -0
  24. package/src/framework/sheet/joinery.js +356 -0
  25. package/src/framework/sheet/part.js +98 -0
  26. package/src/framework/sheet/pose.js +114 -0
  27. package/src/framework/sheet/resolve.js +133 -0
  28. package/src/framework/verify-metrics.js +8 -1
  29. package/src/parts/laser-box.js +108 -0
  30. package/types/geometry.d.ts +115 -0
  31. package/types/lint.d.ts +4 -1
  32. package/types/oracle.d.ts +1 -1
  33. package/types/part.d.ts +57 -1
  34. package/types/testing.d.ts +81 -1
package/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import { pathToFileURL } from "node:url";
9
9
  import { resolve, dirname, basename } from "node:path";
10
10
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { detectBackend } from "../src/framework/backend-select.js";
12
+ import { SHEET_DOC_ID, fmtMm } from "../src/framework/sheet/constants.js";
12
13
  import { fontsFor } from "../src/framework/fonts.js";
13
14
  import { imagesFor } from "../src/framework/images.js";
14
15
  import { isNoImageSource } from "../src/framework/image-source.js";
@@ -31,6 +32,10 @@ const die = (msg) => { console.error(msg); process.exit(1); };
31
32
 
32
33
  const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick|ingest> …";
33
34
 
35
+ // Where a finding's `pattern` points: an ERROR-PATTERNS.md entry — or, for a sheet-part
36
+ // finding, the authoring guide's "Sheet parts" section, which rides the same field.
37
+ const patternRef = (pattern) => (pattern === SHEET_DOC_ID ? 'AUTHORING-PARTS.md "Sheet parts"' : `ERROR-PATTERNS.md#${pattern}`);
38
+
34
39
  // Crash contract (issue #27): with --json, a thrown error becomes structured
35
40
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
36
41
  // the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity: in --json
@@ -505,6 +510,11 @@ function printMeasure(r) {
505
510
  console.log(` ${s.name} bbox ${s.bbox.map((n) => n.toFixed(1)).join("×")} ` +
506
511
  `vol ${(s.volume / 1000).toFixed(2)}cm³ area ${(s.surfaceArea / 100).toFixed(1)}cm² ` +
507
512
  `tris ${s.triangleCount} ${wt} ${holes}`);
513
+ // A sheet part's stock and flat size — what the cut file will be.
514
+ if (s.sheet) {
515
+ console.log(` sheet ${s.sheet.material ?? "?"} ${s.sheet.thickness == null ? "?" : fmtMm(s.sheet.thickness)} mm, ` +
516
+ `flat ${s.sheet.flat.map((n) => n.toFixed(1)).join(" × ")} mm, ${s.sheet.pieces} piece${s.sheet.pieces === 1 ? "" : "s"}`);
517
+ }
508
518
  }
509
519
  const a = r.aggregate;
510
520
  console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
@@ -556,7 +566,7 @@ function printVerify(v) {
556
566
  if (ch.note) console.log(` note: ${ch.note}`);
557
567
  if (ch.status === "fail" || ch.status === "warn") {
558
568
  if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
559
- if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
569
+ if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (${patternRef(ch.pattern)})` : ""}`);
560
570
  }
561
571
  }
562
572
  }
@@ -589,7 +599,7 @@ function printLint(r) {
589
599
  const at = f.file ? ` ${f.file}:${f.line ?? "?"}` : "";
590
600
  console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}${at}`);
591
601
  console.log(` ${f.message}`);
592
- console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
602
+ console.log(` hint: ${f.hint}${f.pattern ? ` (${patternRef(f.pattern)})` : ""}`);
593
603
  }
594
604
  const e = r.errors.length, w = r.warnings.length, n = (r.notes ?? []).length;
595
605
  console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}${n ? `, ${n} note(s)` : ""}`);
@@ -319,7 +319,7 @@ and asks for the existing positional view argument
319
319
  ## Materials and appearance
320
320
 
321
321
  A sub-part's `display` block says how it LOOKS. Appearance never changes
322
- geometry, exports (other than 3MF colour, below), `measure` or `verify`, and a
322
+ geometry, exports (other than export colours, below), `measure` or `verify`, and a
323
323
  mistake in it never fails a build — lint warns and the viewer falls back.
324
324
 
325
325
  ```js
@@ -336,7 +336,7 @@ meta: { title: "…", units: "mm", environment: "studio" },
336
336
  else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
337
337
  (`pla-print`'s finish and layer lines) in that same colour. So an untouched
338
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, 3MF colours and
339
+ other way. The PLA look is realistic-only: the CAD view, export colours and
340
340
  `declaresMaterials` are unaffected, and a part that names no material is not
341
341
  treated as declaring one.
342
342
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
@@ -378,9 +378,11 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
378
378
 
379
379
  Presets without a pattern ignore it.
380
380
 
381
- **3MF export** carries each sub-part's colour (its `color`, else its preset's
382
- colour), so a multi-colour print opens in a slicer already split and coloured.
383
- A part with no `color` or `material` exports exactly as before.
381
+ **3MF and STEP export** carry each sub-part's colour (its `color`, else its
382
+ preset's colour), so a multi-colour print opens in a slicer already split and
383
+ coloured, and each STEP body opens in a CAD tool in its own colour. A part with
384
+ no `color` or `material` exports a 3MF exactly as before; its STEP bodies take
385
+ the viewer's blue-grey.
384
386
 
385
387
  | Preset | Name | Tintable | Use |
386
388
  | --- | --- | --- | --- |
@@ -1518,6 +1520,9 @@ returned `circlePolygon`'s points; it takes no `segs`.) For a round SOLID use `k
1518
1520
  and **use `k.torus({ rMajor, rMinor })` for a torus** — the primitive keeps real TORUS faces
1519
1521
  in STEP.
1520
1522
 
1523
+ **Laser-cut flat stock** (plywood, acrylic, MDF panels) is built differently — with
1524
+ `sheetPart()` and the joinery helpers; see [Sheet parts](#sheet-parts).
1525
+
1521
1526
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
1522
1527
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
1523
1528
 
@@ -1769,6 +1774,280 @@ that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(di
1769
1774
  opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`, `isEmpty()` (no
1770
1775
  regions left — see the vanishing-features rule above).
1771
1776
 
1777
+ ## Sheet parts
1778
+
1779
+ Laser-cut parts from flat stock — plywood, MDF, acrylic — for a laser cutter
1780
+ (LightBurn, xTool, Glowforge) or a cutting service (SendCutSend): flat-pack boxes,
1781
+ finger/box joints, tab and slot, T-slot screw joints, engraving and scoring. Build each
1782
+ piece cut from a sheet with `sheetPart()` from `partforge/geometry`; the cut & print
1783
+ kit turns them into SVG or DXF cut files, and kerf is chosen there, at download.
1784
+
1785
+ ### When a sub-part is a sheet part
1786
+
1787
+ When it is cut out of one flat sheet: a 2-D outline with holes, marks on its face, the
1788
+ stock as its third dimension. A 3 mm plate you **print** is not one, nor is anything
1789
+ with pockets, steps or fillets. Mixing is normal — plywood panels, printed hinges.
1790
+
1791
+ ### sheetPart
1792
+
1793
+ ```js
1794
+ import { sheetPart, sheetHole } from "partforge/geometry";
1795
+
1796
+ plate: sheetPart({
1797
+ label: "Plate", views: ["main"], display: { material: "clear-acrylic" },
1798
+ material: "clear acrylic", // stock label: groups pieces in the kit
1799
+ thickness: (p) => p.t, // the MEASURED thickness, from a control
1800
+ profile: (k, p) => k.shape2d([[0, 0], [p.w, 0], [p.w, p.h], [0, p.h]])
1801
+ .cut(sheetHole({ d: 5, at: [8, 8] })), // the cut layer
1802
+ pose: (p) => ({ face: "-Y", up: "+Z", at: [-p.w / 2, 0, 0] }),
1803
+ }),
1804
+ ```
1805
+
1806
+ It returns an ordinary sub-part: `build`, `place` and a plain-data `sheet`. A field
1807
+ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
1808
+ `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1809
+ `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1810
+ `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).
1813
+
1814
+ ### Cut, score and engrave
1815
+
1816
+ - **`profile`** is the CUT layer — outline plus holes, seen from the laser face, one
1817
+ piece. Round holes: `sheetHole({ d, at })` or `circleProfile(r, at)` — exact, not the
1818
+ 48-gon `circlePolygon`.
1819
+ - **`score`** returns an array: an entry of exactly two `[x, y]` points is a LINE;
1820
+ anything else is a shape whose boundaries are scored (`[[0, 0], [10, 0],
1821
+ [10, 10]]` is a triangle). `null` entries are skipped.
1822
+ - **`engrave`** returns filled regions — `k.text2d(…)`, `k.vector2d(…)`, a Shape2D —
1823
+ or `null`. No raster engraving.
1824
+
1825
+ The preview pockets engraving and scores a groove 0.2 mm deep, so marks show in
1826
+ renders; the cut files carry vectors. Empty marks are dropped. Keep these functions
1827
+ chainable — no branching on `isEmpty()`/`area()`/`boundingBox()` — so pose-only
1828
+ sliders stay fast.
1829
+
1830
+ ### Thickness, clearance and kerf
1831
+
1832
+ Three numbers, never mixed. **Thickness** is what you *measured* ("3 mm" plywood is
1833
+ often 2.7–3.3): bind it to a control, `thickness: (p) => p.t`; it is the extrusion and
1834
+ every joint's depth. **Clearance** is a finished joint's total play, on its own control
1835
+ (`fit`, 0–0.4 mm): fingers split it between the panels, slots take all of it. **Kerf**,
1836
+ what the beam burns away, never appears in the part: it is asked at download and
1837
+ applied once to the cut lines. Joinery refuses a `kerf` option; add no kerf control.
1838
+
1839
+ ### Placing panels: pose and worldToSheet
1840
+
1841
+ A sheet part is drawn in its own frame: drawing in XY, material over z ∈ [0, t], laser
1842
+ face at z = t. `pose: { face, up, at }` places it — `face` is the laser face's outward
1843
+ normal, `up` the drawing's +y (axis words `"+X"`, `"-Y"`, …, at right angles), `at`
1844
+ where drawing `[0, 0]` lands; the material runs back along −`face`. Drawing +x is
1845
+ `up × face`, so an engraving is never mirrored. The pose holds for display AND export:
1846
+ STEP/3MF/STL come out assembled. No pose: flat at the origin. A box's front panel,
1847
+ laser face out: `{ face: "-Y", up: "+Z", at: [-W / 2, -D / 2, 0] }`.
1848
+
1849
+ `worldToSheet(pose, [x, y, z])` → `[u, v]`: where a world point lands on the drawing —
1850
+ cut a slot where a printed tongue really is. `sheetToWorld(pose, [u, v], depth)` goes
1851
+ back.
1852
+
1853
+ ### Joinery helpers
1854
+
1855
+ Pure and kernel-free — call them in `derive()`. They draw NOMINAL material.
1856
+
1857
+ | Helper | Returns |
1858
+ |---|---|
1859
+ | `fingers({ thickness, clearance = 0.1, finger = 2t, side = "outer" })` | outer fingers protrude, inner ones notch |
1860
+ | `tabs({ thickness, count = 2, width = 3t })` | tongues protruding `thickness` |
1861
+ | `tSlots({ thickness, screw = "M3", screwLength = 12, at = [0.5], clearance = 0.2 })` | shank slot + nut trap (`JOINERY_SCREWS`) |
1862
+ | `sheetPanel({ width, height, edges: { bottom, right, top, left } })` | `{ outline, size }` |
1863
+ | `matchingSlots(joint, { line, clearance })` | holes the OTHER panel needs |
1864
+ | `fingerBox({ width, depth, height, thickness, clearance, finger })` | `{ bottom, front, back, left, right }` → `{ outline, size, pose }` |
1865
+
1866
+ `sheetPanel`'s nominal edges are the box `[0, W] × [0, H]`; protrusions lie outside
1867
+ (`size` includes them). Edges run CCW — bottom, right, top, left — and positions are
1868
+ measured from an edge's start. Finger counts are odd, cells ≥ 2t, and every corner has
1869
+ one owner. `thickness` is the mating sheet's. `matchingSlots`' `line` is the tabbed
1870
+ panel's mid-plane in the slotted panel's frame, from the tabbed edge's start.
1871
+
1872
+ ```js
1873
+ derive: (p) => ({ box: fingerBox({ width: p.w, depth: p.d, height: p.h, thickness: p.t, clearance: p.fit }) }),
1874
+ front: sheetPart({ ...PLY, profile: (k, p, d) => d.box.front.outline, pose: (p, d) => d.box.front.pose }),
1875
+ ```
1876
+
1877
+ ### Printed parts that key into sheets
1878
+
1879
+ `printedTab({ size: [w, h], thickness, clearance = 0.3 })` gives a slot and its printed
1880
+ tongue from one spec: `slot` is `(w + c) × (h + c)`, centred (all the play); `tongue`
1881
+ is `[w, h, thickness]` for `k.box({ size: tongue })`. Build the printed part in its
1882
+ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1883
+ `k.shape2d(tab.slot).translate(worldToSheet(panelPose, tongueCentre))`.
1884
+
1885
+ ### What lint and verify check
1886
+
1887
+ **Lint** (no kernel; judged at the part's defaults). Every finding carries
1888
+ `pattern: "sheet-parts"`:
1889
+
1890
+ | Rule | Tier | Fires when |
1891
+ |---|---|---|
1892
+ | `sheet-invalid` | error | a hand-written `sheet` declaration is malformed |
1893
+ | `sheet-thickness-invalid` | error | `thickness(p, d)` is not a finite number above 0, or throws |
1894
+ | `sheet-pose-invalid` | error | a pose uses a non-axis word, or `up` is not perpendicular to `face` |
1895
+ | `sheet-thickness-literal` | warning | thickness is a fixed number, not the measured-value control |
1896
+ | `sheet-kerf-control` | warning | a control's key or label says kerf |
1897
+ | `sheet-custom-build` | warning | `build` replaced the one `sheetPart` generated |
1898
+ | `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
1899
+ | `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
1900
+
1901
+ **Verify** runs the laser checks on every sheet part. They are
1902
+ *volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
1903
+ `verify.ok` true on its own. Declare one in `expect` to make it count:
1904
+
1905
+ | Metric | Reads | Checked at |
1906
+ |---|---|---|
1907
+ | `sheetBridge` | narrowest web or finger, mm | `>=` half the thickness, at least 0.5 mm |
1908
+ | `sheetGap` | narrowest hole, slot or notch, mm | the same floor |
1909
+ | `sheetMarks` | engrave/score regions outside the cut | `0` |
1910
+ | `sheetPieces` | regions in the profile | `1` |
1911
+ | `sheetSolidMatch` | a custom build's volume vs. profile area × thickness, % | `<=2` (custom builds only) |
1912
+
1913
+ Widths come from shrinking and regrowing the cut outline with sharp corners,
1914
+ bisected to 0.05 mm; nothing narrower than twice the floor reads as that ceiling,
1915
+ with a note. A finding's `location` is its spot in the assembly at mid-thickness
1916
+ (none when the pose cannot be traced). The 2-D checks share a 1.5 s budget per
1917
+ measurement: past it, or when a profile is plainly too complex for it, a sheet
1918
+ gets one `sheetChecks` warning instead, and a declared sheet check comes back
1919
+ unevaluated. In a forge mixing sheet and printed
1920
+ parts, the profile's bed fits each **printed** part in its print (export) pose, not
1921
+ the assembled view, and `minWall` and the overhang check skip sheet parts.
1922
+
1923
+ ### Limits
1924
+
1925
+ - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1926
+ 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.
1929
+ - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1930
+ `grain` are reserved keys and throw.
1931
+ - Cut and score are vector lines; engraving is filled regions. There is no raster
1932
+ image engraving.
1933
+ - The laser checks are warnings. Sharp-corner shrinking can over-report at acute
1934
+ tips, and a web within 0.05 mm of the floor can read as passing.
1935
+
1936
+ ### Worked example: laser-box.js
1937
+
1938
+ `src/parts/laser-box.js`, verbatim:
1939
+
1940
+ ```js
1941
+ // Finger-jointed plywood box: five laser-cut panels from fingerBox, a laser-cut lid,
1942
+ // and two print-in-place hinges whose tongues key into slots in the back panel and
1943
+ // the lid; an engraved label and a score line on the front. A hinge prints flat and
1944
+ // flat is 90° open, so the box is shown with its lid standing up.
1945
+ import { sheetPart, fingerBox, printedTab, worldToSheet } from "partforge/geometry";
1946
+
1947
+ // Hinge in its PRINT pose: leaves flat on the bed, knuckle axis along X at y = 0,
1948
+ // z = R, tongues up (+Z). Fixed leaf (y < 0) → back panel; lid leaf (y > 0) → lid.
1949
+ const HINGE = { width: 24, leaf: 18, leafT: 3, R: 3, gap: 0.4, pinR: 1.2, lift: 0.25,
1950
+ tongue: [8, 3], tongueX: 6, tongueY: 11 };
1951
+
1952
+ // ONE spec for a tongue and its slot; the slot carries the clearance.
1953
+ const tab = (p) => printedTab({ size: HINGE.tongue, thickness: p.t, clearance: p.printFit });
1954
+
1955
+ function hinge(k, p, d) {
1956
+ const { width: w, leaf, leafT, R, gap, pinR, tongueX } = HINGE, { tongueY } = d;
1957
+ const seg = (w - 2 * gap) / 3; // three knuckles, `gap` apart
1958
+ const x0 = -w / 2, x1 = x0 + seg, x2 = x1 + gap, x3 = x2 + seg, x4 = x3 + gap, x5 = w / 2;
1959
+ const box = (min, max) => k.box({ min, max });
1960
+ const barrel = (a, b, r = R) => k.cylinder({ r, h: b - a }).along("+X").at([a, 0, R]);
1961
+ const tongues = (y) => [-tongueX, tongueX].map((x) => k.box({ size: tab(p).tongue }).at([x, y, leafT]));
1962
+ const fixed = k.union([
1963
+ box([x0, -leaf, 0], [x5, -(R + gap), leafT]),
1964
+ box([x0, -(R + gap), 0], [x1, 0, leafT]), box([x4, -(R + gap), 0], [x5, 0, leafT]),
1965
+ barrel(x0, x1), barrel(x4, x5), barrel(x0, x5, pinR), // outer knuckles + pin
1966
+ ...tongues(-tongueY),
1967
+ ]).label("Fixed leaf");
1968
+ const moving = k.union([
1969
+ box([x0, R + gap, 0], [x5, leaf, leafT]), box([x2, 0, 0], [x3, R + gap, leafT]),
1970
+ barrel(x2, x3), ...tongues(tongueY),
1971
+ ]).cut(barrel(x2 - 1, x3 + 1, pinR + gap)).label("Lid leaf"); // running clearance on the pin
1972
+ return k.union([fixed, moving]);
1973
+ }
1974
+
1975
+ // Display: flat hinge against the back panel's outside face, knuckles above the wall.
1976
+ // Export: the print pose, untouched — both hinges export the same solid.
1977
+ const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
1978
+ : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
1979
+
1980
+ // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
1981
+ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
1982
+ [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
1983
+ k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
1984
+
1985
+ const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
1986
+ const panel = (name, label, extra = {}) => sheetPart({
1987
+ ...PLY, label,
1988
+ profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
1989
+ pose: (p, d) => d.box[name].pose, // fingerBox knows where it goes
1990
+ ...extra,
1991
+ });
1992
+
1993
+ export default {
1994
+ meta: { title: "Plywood box with printed hinges", units: "mm" },
1995
+ parameters: [
1996
+ { id: "box", title: "Box", description: "Outside size, lid excluded.", controls: [
1997
+ { key: "width", label: "Width", unit: "mm", min: 100, max: 400, step: 1, description: "Outside width." },
1998
+ { key: "depth", label: "Depth", unit: "mm", min: 80, max: 300, step: 1, description: "Hinges on the back." },
1999
+ { key: "height", label: "Height", unit: "mm", min: 50, max: 200, step: 1, description: "Wall height." },
2000
+ { key: "label", type: "text", label: "Label", description: "Engraved on the front; empty for none." },
2001
+ ] },
2002
+ { id: "stock", title: "Stock & fit", description: "Measure your sheet. Kerf is chosen when you download the kit.", controls: [
2003
+ { key: "t", label: "Sheet thickness", unit: "mm", min: 2, max: 6.5, step: 0.05, description: "MEASURED — '3 mm' ply is often 2.7–3.3." },
2004
+ { key: "fit", label: "Finger clearance", unit: "mm", min: 0, max: 0.4, step: 0.02, description: "Total play per finger joint." },
2005
+ { key: "printFit", label: "Hinge tab clearance", unit: "mm", min: 0, max: 0.8, step: 0.05, description: "Slot minus printed tongue." },
2006
+ ] },
2007
+ ],
2008
+ defaults: { width: 160, depth: 110, height: 80, label: "TOOLS", t: 3, fit: 0.1, printFit: 0.3 },
2009
+ derive: (p) => {
2010
+ const rise = Math.max(0, p.t - HINGE.R); // thick stock: the shut lid clears the walls
2011
+ const axisZ = p.height + HINGE.lift + HINGE.R + rise; // knuckle axis, above the back wall
2012
+ const lidZ = axisZ + HINGE.lift + HINGE.R; // hinge edge of the open lid
2013
+ return {
2014
+ box: fingerBox({ width: p.width, depth: p.depth, height: p.height, thickness: p.t, clearance: p.fit }),
2015
+ hingeX: [-(p.width / 2 - 25), p.width / 2 - 25],
2016
+ axisZ,
2017
+ tongueY: HINGE.tongueY + rise, // slots stay put
2018
+ // Open 90°: laser face to the back, the drawing's front edge (v = 0) on top.
2019
+ lidPose: { face: "+Y", up: "-Z", at: [-p.width / 2, p.depth / 2, lidZ + p.depth] },
2020
+ };
2021
+ },
2022
+ parts: {
2023
+ bottom: panel("bottom", "Bottom"),
2024
+ left: panel("left", "Left"),
2025
+ right: panel("right", "Right"),
2026
+ front: panel("front", "Front", { // u across, v up
2027
+ engrave: (k, p) => (p.label?.trim() ? k.text2d(p.label, { size: 14 }).translate([p.width / 2, p.height * 0.55]) : null),
2028
+ score: (k, p) => [[[12, p.height * 0.35], [p.width - 12, p.height * 0.35]]], // a two-point line
2029
+ }),
2030
+ back: panel("back", "Back", {
2031
+ profile: (k, p, d) => k.shape2d(d.box.back.outline)
2032
+ .cutAll(hingeSlots(k, p, d, d.box.back.pose, d.axisZ - d.tongueY)),
2033
+ }),
2034
+ lid: sheetPart({
2035
+ ...PLY, label: "Lid",
2036
+ profile: (k, p, d) => k.shape2d([[0, 0], [p.width, 0], [p.width, p.depth], [0, p.depth]])
2037
+ .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
2038
+ pose: (p, d) => d.lidPose,
2039
+ }),
2040
+ hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
2041
+ export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
2042
+ hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
2043
+ build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
2044
+ },
2045
+ views: { box: { label: "Box" } },
2046
+ // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.
2047
+ verify: { process: "fdm-pla", expect: { _view: { overlaps: 0 } } },
2048
+ };
2049
+ ```
2050
+
1772
2051
  ## Convex hull
1773
2052
 
1774
2053
  `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
@@ -3452,6 +3731,14 @@ rules judge the argument values the probe resolves under the part's default para
3452
3731
  same basis `import-unknown-name` uses; a call that only goes wrong for
3453
3732
  non-default params still fails correctly at build time.
3454
3733
 
3734
+ **Sheet parts** — `sheet-invalid`, `sheet-thickness-invalid`, `sheet-pose-invalid`
3735
+ (errors); `sheet-thickness-literal`, `sheet-kerf-control`, `sheet-custom-build`,
3736
+ `verify-process-sheets-only`, `laser-thickness-range` (warnings). Each carries
3737
+ `pattern: "sheet-parts"` and is described under "Sheet parts" → "What lint and
3738
+ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `build`
3739
+ at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
3740
+ way.
3741
+
3455
3742
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
3456
3743
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
3457
3744
 
@@ -3463,7 +3750,8 @@ carries:
3463
3750
 
3464
3751
  - `hint` — one self-contained corrective sentence (always present),
3465
3752
  - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
3466
- applies (follow it with `ERROR-PATTERNS.md#<id>`),
3753
+ applies (follow it with `ERROR-PATTERNS.md#<id>`); on a sheet-part check it is
3754
+ `sheet-parts`, this guide's "Sheet parts" section, instead,
3467
3755
  - `note` — an optional caveat about *how* the value was measured, or a companion
3468
3756
  reading, attached whatever the verdict. `minWall` sets one when the reading came
3469
3757
  from a sample rather than every triangle (see below); `overhangArea` sets one
@@ -3517,6 +3805,14 @@ pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
3517
3805
  **absent** rather than empty when it did not run — an empty table means "measured,
3518
3806
  and these pairs have no distance", which a declared `clearance` gate fails on.
3519
3807
 
3808
+ A sheet sub-part (`sheetPart()`) also carries `sheet` — its 2-D facts:
3809
+ `material`, `thickness`, `flat` (the cut layer's size), `area`, `pieces`, the
3810
+ bisected `bridge`/`gap` widths with their `bridgeCapped`/`gapCapped` flags,
3811
+ `marksOutside`, `evaluated` (false when the 2-D budget ran out) and `at` (each
3812
+ finding's spot in the assembly) — and `sheet: null` on every other sub-part. In a
3813
+ view that holds one, each printed sub-part also carries `printBbox`, its size in
3814
+ the print (export) pose.
3815
+
3520
3816
  ### Quick checks
3521
3817
 
3522
3818
  An editor may ask for a **quick** check, which skips both ray-casting passes — min
@@ -3698,6 +3994,15 @@ the two apart. A single answerable expectation — or a process profile, which b
3698
3994
  the bed-fit gate — is enough for a verdict. Treat `null` as "not verified", never as
3699
3995
  a pass.
3700
3996
 
3997
+ **Forges with sheet parts.** When a view holds a `sheetPart()` sub-part, the
3998
+ profile's bed fits each printed sub-part in its print (export) pose — the check
3999
+ carries the note "measured in the print (export) pose" — instead of the assembled
4000
+ view, and `minWall` and `overhangArea` skip the sheet parts. The laser checks
4001
+ (`sheetBridge`, `sheetGap`, `sheetMarks`, `sheetPieces`, `sheetSolidMatch`) run on
4002
+ every sheet part as volunteered warnings that never make `ok` true on their own;
4003
+ see "Sheet parts" → "What lint and verify check". A view with no sheet part is
4004
+ verified exactly as before.
4005
+
3701
4006
  **Per-case expectations.** Checks run across defaults **and every preset**, so a
3702
4007
  static `expect` breaks the moment a preset legitimately changes an asserted fact —
3703
4008
  a "cup" preset that turns the drainage hole off flips the genus from 1 to 0.
@@ -296,7 +296,7 @@ above. All ops return a `Solid`.
296
296
  | `vector2d(name, {shape?, width\|height\|fit, align?, valign?})` | A declared vector document → `Shape2D`. `name` = a declared name in the part's `vectors` field (`partforge-vector` JSON — authored by hand or ingested from an `.svg`; never raw `.svg`). With no `shape`, the document's own composition: every `"add"` shape unioned minus every `"subtract"` shape; `shape` selects one named shape's geometry whatever its role. Sizing follows the document's required `units`: `"artwork"` requires exactly one of `width`/`height`/`fit` in millimetres, `"mm"` places as authored (scale 1, no translate) and accepts a size option optionally; more than one is refused. Uniform scale in every case (`fit` = larger extent); `align`/`valign` position it, same as `text2d`, defaulting to centre/middle for `"artwork"` and to no translate for `"mm"`. **The composed call derives ONE transform from every region in the document**, `"add"` and `"subtract"` alike, so a size or align option cannot scale the subtracts relative to the adds; a `shape` call is measured against that shape alone. **Primitive contours (`circle`/`rect`/`polygon`) expand to ordinary path contours at the format boundary, in `vector-format.js`, so this op's kernel-facing contract is unchanged by them** — nothing below the loader learns primitives exist. Conformance: **both backends** (it lowers to `shape2d` + `union`, exactly like `text2d`). Parity: **identical across backends by construction** — the regions are curve-native (arcs/cubics), so there is no sampling step for the two backends to diverge over. |
297
297
  | `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
298
298
  | `hullChain(inputs[])` | Swept hull over an ordered sequence of ≥2 inputs (same input forms as `hull`): the union of `hull([inᵢ, inᵢ₊₁])` for each consecutive pair — e.g. a tapered link connecting a row of circles. Throws with fewer than 2 inputs. |
299
- | `toSTEP(named[])` | `[{name, solid}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. |
299
+ | `toSTEP(named[])` | `[{name, solid, color?}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. `color` (`0xRRGGBB`, optional) is the body's surface colour, written as that exact sRGB value; a body without one (or with an unusable one) is written in the viewer's no-material blue-grey (`0x9fb4cc`), never a kernel default. The framework passes each sub-part's display colour — the one its 3MF object gets. Additive: `CONTRACT_VERSION` stays 4, the `import` precedent. |
300
300
  | `import(name)` | Previously-registered imported geometry (STEP/STL/3MF, declared in the part's `imports` field) as an ordinary `Solid`. Required on both in-repo backends (Manifold accepts mesh formats, OCCT accepts STEP); a format the routed backend can't use throws lazily, at this call, not at registration. Fed by an underscore-prefixed side-channel, not part authors — see [Conformance classes](#conformance-classes). Additive: not in `OCCT_ONLY_OPS`; needed no `CONTRACT_VERSION` bump of its own (v3 came from the mesh fillet/chamfer change, not this op). |
301
301
 
302
302
  `hull`/`hullChain` parity: point-list and curve-contour inputs hull bit-identically
@@ -600,7 +600,17 @@ backend-independently on the contour IR — no backend `CrossSection` or `Drawin
600
600
  ever involved. Lines and arcs offset exactly (arcs stay arcs); cubics are
601
601
  approximated to ≤ 1e-3 mm deviation. `corners: "round"` inserts exact arc joins,
602
602
  `"chamfer"` a true 45°-bisecting bevel chord at every corner angle, `"sharp"` miters
603
- with limit 2 (falling back to the bevel chord past the limit). Self-intersecting raw
603
+ with limit 2 (falling back to the bevel chord past the limit) — where an arc meets the
604
+ corner it extends along its own circle, not its end tangent (OCCT's intersection join,
605
+ `BRepOffsetAPI_MakeOffset` with `GeomAbs_Intersection`: measured within 2e-3 mm of it on
606
+ D shapes, pies, ring sectors, lenses and notches, `test/offset-oracle-occt.test.js`), so a
607
+ sharp inset grown back by the same amount returns an arc-and-line corner to the winding
608
+ resolver's precision (5e-3 mm), not exactly. The arc is extended up to 90° and the join
609
+ takes the meeting point nearest the corner within 2·|delta|; where the extended pieces
610
+ never meet there — an inner arc that collapses or curls away, as a 3..5 mm ring sector's
611
+ does grown by 2 — the tangent miter applies, then the bevel, and the result matches
612
+ neither OCCT join. A line meeting a line or a cubic is mitred along its end tangent, as
613
+ before. Self-intersecting raw
604
614
  results are resolved through the shared planar boolean engine (paper.js), which may
605
615
  return arcs as cubic approximations — identical to boolean-op output. `segs` is
606
616
  accepted and ignored (there is no backend LOD to tune). Both backends produce
@@ -616,7 +626,7 @@ backwards for the holes (see the migration note below).
616
626
  | Op | Contract |
617
627
  |---|---|
618
628
  | `union(other)` / `cut(other)` / `cutAll(others[])` / `intersect(other)` | 2-D boolean ops; `other` may be a `Shape2D` or a raw profile (lifted via `shape2d` first). Curve-exact and backend-identical (paper.js). |
619
- | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. Runs backend-independently on the contour IR — lines/arcs offset exactly, cubics approximate to ≤ 1e-3 mm; `chamfer` is a true 45°-bisecting bevel at every corner angle, `sharp` miters with limit 2. Backend-identical by construction, like every other Shape2D op. Holes offset material-wise (`-delta` where the outer gets `delta`). `segs` is accepted and ignored. Empty in → empty out (short-circuits before the engine). Throws if the offset collapses the shape. |
629
+ | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. Runs backend-independently on the contour IR — lines/arcs offset exactly, cubics approximate to ≤ 1e-3 mm; `chamfer` is a true 45°-bisecting bevel at every corner angle, `sharp` miters with limit 2 (an arc at the corner extends along its circle — OCCT's intersection join, to the resolver's 5e-3 mm, the tangent miter where the extensions never meet). Backend-identical by construction, like every other Shape2D op. Holes offset material-wise (`-delta` where the outer gets `delta`). `segs` is accepted and ignored. Empty in → empty out (short-circuits before the engine). Throws if the offset collapses the shape. |
620
630
  | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
621
631
  | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
622
632
  | `toRegions()` | Materialize into `{outer, holes}[]` point-ring region arrays (`assembleRegions`), tessellating curves at the backend's LOD; a boolean result may be several disjoint regions. |
@@ -700,19 +710,30 @@ The currently parked limitations are narrower:
700
710
  - **Round erosion with several holes reaching the eroded outer can keep too much material.**
701
711
  The characterized 30×20 plate with three rectangular holes at −2 returns about 324.75
702
712
  instead of the 258.18 oracle truth under round corners; chamfer and sharp are exact.
703
- - **Fully eroded holes under sharp and chamfer can leave a remnant.** The source-inradius
704
- gate is intentionally limited to round joins, whose structuring element is a Euclidean
705
- disk. A 1×1 hole at +2 closes correctly under round, while the sharp/chamfer variants
706
- remain parked rather than applying the wrong geometric criterion.
713
+ - **Fully eroded holes under chamfer can leave a remnant.** The source-inradius gate
714
+ (a hole survives a positive offset only if it holds a disk of radius delta) runs for
715
+ round joins, whose structuring element is that disk, and for sharp ones whose every
716
+ join on the hole takes the miter, which then erodes the hole at least as deeply — the
717
+ miter reaches past the arc at the hole's reflex corners. Without it a sharp dilation
718
+ left the inverted ring as a phantom hole once delta passed twice the hole's half-width.
719
+ A sharp hole with a spike of material under 60° is NOT gated: there the miter would
720
+ pass the limit, the join falls back to the bevel, and the bevel chord adds less than the
721
+ arc, so a real pocket survives that the disk would fill — a 12-ray star hole (rays 5,
722
+ spikes to 1.2) keeps 0.9–1.9 mm² at +1.3…+2, and the Minkowski oracle agrees. Such a
723
+ hole takes the ordinary path, which can still leave a phantom if it fully erodes.
724
+ Chamfer's bevel removes less than the arc at every reflex corner, so the disk rule is
725
+ the wrong criterion there: a 1×1 hole at +2 closes under round and sharp, and the
726
+ chamfer variant remains parked (four fuzz seeds at +2 are pinned exactly).
707
727
  - **Erosion can emit sub-0.001 mm² rings.** Five exact seeded cases are pinned in
708
728
  `test/offset-fuzz.test.js`. They are not automatically deleted: unlike positive
709
729
  dilation, erosion has no source-membership invariant that distinguishes a false island
710
730
  from a genuine surviving crumb.
711
731
 
712
- The fuzz oracle sweep covers 150 seeded shapes × 6 deltas × 3 styles and currently reports
713
- no region-count, hole-count, or area disagreements outside those explicit
714
- characterizations. Do not widen tolerances or add an area-based sliver filter when a new
715
- case appears; add its deterministic fixture and establish the source-domain truth first.
732
+ The fuzz oracle sweep covers 150 seeded shapes × 7 deltas (five inward; outward, 1 and 2)
733
+ × 3 styles and currently reports no region-count, hole-count, or area disagreements outside
734
+ those explicit characterizations. Do not widen tolerances or add an area-based sliver
735
+ filter when a new case appears; add its deterministic fixture and establish the
736
+ source-domain truth first.
716
737
  ## The 2-D helper library
717
738
 
718
739
  `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
@@ -736,6 +757,15 @@ the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
736
757
  - `pathProfile` — fluent builder for a curve-native path contour (`lineTo` /
737
758
  `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
738
759
  facet at mesh LOD on Manifold.
760
+ - **Sheet parts** — `sheetPart` wraps a laser-cut piece into an ordinary sub-part
761
+ (a generated `build` and `place` plus a plain-data `sheet` declaration); the
762
+ joinery helpers `fingers`, `tabs`, `tSlots`, `sheetPanel`, `matchingSlots`,
763
+ `fingerBox`, `printedTab`, `sheetHole` and the `JOINERY_SCREWS` table are pure
764
+ functions returning plain data in this contract's input format; `sheetToWorld` /
765
+ `worldToSheet` convert between a posed sheet's drawing and world coordinates. The
766
+ generated build calls only ops from the tables above (`shape2d`, `extrude`,
767
+ `offset`, `cut`, `cutAll`, `translate`, `rotate`), so a sheet part is portable by
768
+ construction too. None of these is a kernel op.
739
769
 
740
770
  **Profile validation on the way in** (0.112). `prism`, `extrude`, `revolve`, `sweep`,
741
771
  `loft` (per ring) and `shape2d` (including boolean operands) run `validateProfile` on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.132.1",
3
+ "version": "0.133.1",
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",