partforge 0.132.1 → 0.133.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.
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)` : ""}`);
@@ -1518,6 +1518,9 @@ returned `circlePolygon`'s points; it takes no `segs`.) For a round SOLID use `k
1518
1518
  and **use `k.torus({ rMajor, rMinor })` for a torus** — the primitive keeps real TORUS faces
1519
1519
  in STEP.
1520
1520
 
1521
+ **Laser-cut flat stock** (plywood, acrylic, MDF panels) is built differently — with
1522
+ `sheetPart()` and the joinery helpers; see [Sheet parts](#sheet-parts).
1523
+
1521
1524
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
1522
1525
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
1523
1526
 
@@ -1769,6 +1772,280 @@ that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(di
1769
1772
  opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`, `isEmpty()` (no
1770
1773
  regions left — see the vanishing-features rule above).
1771
1774
 
1775
+ ## Sheet parts
1776
+
1777
+ Laser-cut parts from flat stock — plywood, MDF, acrylic — for a laser cutter
1778
+ (LightBurn, xTool, Glowforge) or a cutting service (SendCutSend): flat-pack boxes,
1779
+ finger/box joints, tab and slot, T-slot screw joints, engraving and scoring. Build each
1780
+ piece cut from a sheet with `sheetPart()` from `partforge/geometry`; the cut & print
1781
+ kit turns them into SVG or DXF cut files, and kerf is chosen there, at download.
1782
+
1783
+ ### When a sub-part is a sheet part
1784
+
1785
+ When it is cut out of one flat sheet: a 2-D outline with holes, marks on its face, the
1786
+ stock as its third dimension. A 3 mm plate you **print** is not one, nor is anything
1787
+ with pockets, steps or fillets. Mixing is normal — plywood panels, printed hinges.
1788
+
1789
+ ### sheetPart
1790
+
1791
+ ```js
1792
+ import { sheetPart, sheetHole } from "partforge/geometry";
1793
+
1794
+ plate: sheetPart({
1795
+ label: "Plate", views: ["main"], display: { material: "clear-acrylic" },
1796
+ material: "clear acrylic", // stock label: groups pieces in the kit
1797
+ thickness: (p) => p.t, // the MEASURED thickness, from a control
1798
+ profile: (k, p) => k.shape2d([[0, 0], [p.w, 0], [p.w, p.h], [0, p.h]])
1799
+ .cut(sheetHole({ d: 5, at: [8, 8] })), // the cut layer
1800
+ pose: (p) => ({ face: "-Y", up: "+Z", at: [-p.w / 2, 0, 0] }),
1801
+ }),
1802
+ ```
1803
+
1804
+ It returns an ordinary sub-part: `build`, `place` and a plain-data `sheet`. A field
1805
+ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
1806
+ `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1807
+ `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1808
+ `enabled`, `exportable`, `reference` pass through; your own `place` runs after the
1809
+ pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` (use
1810
+ `profile`) and `quantity` (the kit counts identical pieces).
1811
+
1812
+ ### Cut, score and engrave
1813
+
1814
+ - **`profile`** is the CUT layer — outline plus holes, seen from the laser face, one
1815
+ piece. Round holes: `sheetHole({ d, at })` or `circleProfile(r, at)` — exact, not the
1816
+ 48-gon `circlePolygon`.
1817
+ - **`score`** returns an array: an entry of exactly two `[x, y]` points is a LINE;
1818
+ anything else is a shape whose boundaries are scored (`[[0, 0], [10, 0],
1819
+ [10, 10]]` is a triangle). `null` entries are skipped.
1820
+ - **`engrave`** returns filled regions — `k.text2d(…)`, `k.vector2d(…)`, a Shape2D —
1821
+ or `null`. No raster engraving.
1822
+
1823
+ The preview pockets engraving and scores a groove 0.2 mm deep, so marks show in
1824
+ renders; the cut files carry vectors. Empty marks are dropped. Keep these functions
1825
+ chainable — no branching on `isEmpty()`/`area()`/`boundingBox()` — so pose-only
1826
+ sliders stay fast.
1827
+
1828
+ ### Thickness, clearance and kerf
1829
+
1830
+ Three numbers, never mixed. **Thickness** is what you *measured* ("3 mm" plywood is
1831
+ often 2.7–3.3): bind it to a control, `thickness: (p) => p.t`; it is the extrusion and
1832
+ every joint's depth. **Clearance** is a finished joint's total play, on its own control
1833
+ (`fit`, 0–0.4 mm): fingers split it between the panels, slots take all of it. **Kerf**,
1834
+ what the beam burns away, never appears in the part: it is asked at download and
1835
+ applied once to the cut lines. Joinery refuses a `kerf` option; add no kerf control.
1836
+
1837
+ ### Placing panels: pose and worldToSheet
1838
+
1839
+ A sheet part is drawn in its own frame: drawing in XY, material over z ∈ [0, t], laser
1840
+ face at z = t. `pose: { face, up, at }` places it — `face` is the laser face's outward
1841
+ normal, `up` the drawing's +y (axis words `"+X"`, `"-Y"`, …, at right angles), `at`
1842
+ where drawing `[0, 0]` lands; the material runs back along −`face`. Drawing +x is
1843
+ `up × face`, so an engraving is never mirrored. The pose holds for display AND export:
1844
+ STEP/3MF/STL come out assembled. No pose: flat at the origin. A box's front panel,
1845
+ laser face out: `{ face: "-Y", up: "+Z", at: [-W / 2, -D / 2, 0] }`.
1846
+
1847
+ `worldToSheet(pose, [x, y, z])` → `[u, v]`: where a world point lands on the drawing —
1848
+ cut a slot where a printed tongue really is. `sheetToWorld(pose, [u, v], depth)` goes
1849
+ back.
1850
+
1851
+ ### Joinery helpers
1852
+
1853
+ Pure and kernel-free — call them in `derive()`. They draw NOMINAL material.
1854
+
1855
+ | Helper | Returns |
1856
+ |---|---|
1857
+ | `fingers({ thickness, clearance = 0.1, finger = 2t, side = "outer" })` | outer fingers protrude, inner ones notch |
1858
+ | `tabs({ thickness, count = 2, width = 3t })` | tongues protruding `thickness` |
1859
+ | `tSlots({ thickness, screw = "M3", screwLength = 12, at = [0.5], clearance = 0.2 })` | shank slot + nut trap (`JOINERY_SCREWS`) |
1860
+ | `sheetPanel({ width, height, edges: { bottom, right, top, left } })` | `{ outline, size }` |
1861
+ | `matchingSlots(joint, { line, clearance })` | holes the OTHER panel needs |
1862
+ | `fingerBox({ width, depth, height, thickness, clearance, finger })` | `{ bottom, front, back, left, right }` → `{ outline, size, pose }` |
1863
+
1864
+ `sheetPanel`'s nominal edges are the box `[0, W] × [0, H]`; protrusions lie outside
1865
+ (`size` includes them). Edges run CCW — bottom, right, top, left — and positions are
1866
+ measured from an edge's start. Finger counts are odd, cells ≥ 2t, and every corner has
1867
+ one owner. `thickness` is the mating sheet's. `matchingSlots`' `line` is the tabbed
1868
+ panel's mid-plane in the slotted panel's frame, from the tabbed edge's start.
1869
+
1870
+ ```js
1871
+ derive: (p) => ({ box: fingerBox({ width: p.w, depth: p.d, height: p.h, thickness: p.t, clearance: p.fit }) }),
1872
+ front: sheetPart({ ...PLY, profile: (k, p, d) => d.box.front.outline, pose: (p, d) => d.box.front.pose }),
1873
+ ```
1874
+
1875
+ ### Printed parts that key into sheets
1876
+
1877
+ `printedTab({ size: [w, h], thickness, clearance = 0.3 })` gives a slot and its printed
1878
+ tongue from one spec: `slot` is `(w + c) × (h + c)`, centred (all the play); `tongue`
1879
+ is `[w, h, thickness]` for `k.box({ size: tongue })`. Build the printed part in its
1880
+ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1881
+ `k.shape2d(tab.slot).translate(worldToSheet(panelPose, tongueCentre))`.
1882
+
1883
+ ### What lint and verify check
1884
+
1885
+ **Lint** (no kernel; judged at the part's defaults). Every finding carries
1886
+ `pattern: "sheet-parts"`:
1887
+
1888
+ | Rule | Tier | Fires when |
1889
+ |---|---|---|
1890
+ | `sheet-invalid` | error | a hand-written `sheet` declaration is malformed |
1891
+ | `sheet-thickness-invalid` | error | `thickness(p, d)` is not a finite number above 0, or throws |
1892
+ | `sheet-pose-invalid` | error | a pose uses a non-axis word, or `up` is not perpendicular to `face` |
1893
+ | `sheet-thickness-literal` | warning | thickness is a fixed number, not the measured-value control |
1894
+ | `sheet-kerf-control` | warning | a control's key or label says kerf |
1895
+ | `sheet-custom-build` | warning | `build` replaced the one `sheetPart` generated |
1896
+ | `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
1897
+ | `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
1898
+
1899
+ **Verify** runs the laser checks on every sheet part. They are
1900
+ *volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
1901
+ `verify.ok` true on its own. Declare one in `expect` to make it count:
1902
+
1903
+ | Metric | Reads | Checked at |
1904
+ |---|---|---|
1905
+ | `sheetBridge` | narrowest web or finger, mm | `>=` half the thickness, at least 0.5 mm |
1906
+ | `sheetGap` | narrowest hole, slot or notch, mm | the same floor |
1907
+ | `sheetMarks` | engrave/score regions outside the cut | `0` |
1908
+ | `sheetPieces` | regions in the profile | `1` |
1909
+ | `sheetSolidMatch` | a custom build's volume vs. profile area × thickness, % | `<=2` (custom builds only) |
1910
+
1911
+ Widths come from shrinking and regrowing the cut outline with sharp corners,
1912
+ bisected to 0.05 mm; nothing narrower than twice the floor reads as that ceiling,
1913
+ with a note. A finding's `location` is its spot in the assembly at mid-thickness
1914
+ (none when the pose cannot be traced). The 2-D checks share a 1.5 s budget per
1915
+ measurement: past it, or when a profile is plainly too complex for it, a sheet
1916
+ gets one `sheetChecks` warning instead, and a declared sheet check comes back
1917
+ unevaluated. In a forge mixing sheet and printed
1918
+ parts, the profile's bed fits each **printed** part in its print (export) pose, not
1919
+ the assembled view, and `minWall` and the overhang check skip sheet parts.
1920
+
1921
+ ### Limits
1922
+
1923
+ - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1924
+ same sheet.
1925
+ - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. A
1926
+ panel at an angle is an author `place` after the pose, or a printed part.
1927
+ - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1928
+ `grain` are reserved keys and throw.
1929
+ - Cut and score are vector lines; engraving is filled regions. There is no raster
1930
+ image engraving.
1931
+ - The laser checks are warnings. Sharp-corner shrinking can over-report at acute
1932
+ tips, and a web within 0.05 mm of the floor can read as passing.
1933
+
1934
+ ### Worked example: laser-box.js
1935
+
1936
+ `src/parts/laser-box.js`, verbatim:
1937
+
1938
+ ```js
1939
+ // Finger-jointed plywood box: five laser-cut panels from fingerBox, a laser-cut lid,
1940
+ // and two print-in-place hinges whose tongues key into slots in the back panel and
1941
+ // the lid; an engraved label and a score line on the front. A hinge prints flat and
1942
+ // flat is 90° open, so the box is shown with its lid standing up.
1943
+ import { sheetPart, fingerBox, printedTab, worldToSheet } from "partforge/geometry";
1944
+
1945
+ // Hinge in its PRINT pose: leaves flat on the bed, knuckle axis along X at y = 0,
1946
+ // z = R, tongues up (+Z). Fixed leaf (y < 0) → back panel; lid leaf (y > 0) → lid.
1947
+ const HINGE = { width: 24, leaf: 18, leafT: 3, R: 3, gap: 0.4, pinR: 1.2, lift: 0.25,
1948
+ tongue: [8, 3], tongueX: 6, tongueY: 11 };
1949
+
1950
+ // ONE spec for a tongue and its slot; the slot carries the clearance.
1951
+ const tab = (p) => printedTab({ size: HINGE.tongue, thickness: p.t, clearance: p.printFit });
1952
+
1953
+ function hinge(k, p, d) {
1954
+ const { width: w, leaf, leafT, R, gap, pinR, tongueX } = HINGE, { tongueY } = d;
1955
+ const seg = (w - 2 * gap) / 3; // three knuckles, `gap` apart
1956
+ const x0 = -w / 2, x1 = x0 + seg, x2 = x1 + gap, x3 = x2 + seg, x4 = x3 + gap, x5 = w / 2;
1957
+ const box = (min, max) => k.box({ min, max });
1958
+ const barrel = (a, b, r = R) => k.cylinder({ r, h: b - a }).along("+X").at([a, 0, R]);
1959
+ const tongues = (y) => [-tongueX, tongueX].map((x) => k.box({ size: tab(p).tongue }).at([x, y, leafT]));
1960
+ const fixed = k.union([
1961
+ box([x0, -leaf, 0], [x5, -(R + gap), leafT]),
1962
+ box([x0, -(R + gap), 0], [x1, 0, leafT]), box([x4, -(R + gap), 0], [x5, 0, leafT]),
1963
+ barrel(x0, x1), barrel(x4, x5), barrel(x0, x5, pinR), // outer knuckles + pin
1964
+ ...tongues(-tongueY),
1965
+ ]).label("Fixed leaf");
1966
+ const moving = k.union([
1967
+ box([x0, R + gap, 0], [x5, leaf, leafT]), box([x2, 0, 0], [x3, R + gap, leafT]),
1968
+ barrel(x2, x3), ...tongues(tongueY),
1969
+ ]).cut(barrel(x2 - 1, x3 + 1, pinR + gap)).label("Lid leaf"); // running clearance on the pin
1970
+ return k.union([fixed, moving]);
1971
+ }
1972
+
1973
+ // Display: flat hinge against the back panel's outside face, knuckles above the wall.
1974
+ // Export: the print pose, untouched — both hinges export the same solid.
1975
+ const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
1976
+ : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
1977
+
1978
+ // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
1979
+ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
1980
+ [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
1981
+ k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
1982
+
1983
+ const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
1984
+ const panel = (name, label, extra = {}) => sheetPart({
1985
+ ...PLY, label,
1986
+ profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
1987
+ pose: (p, d) => d.box[name].pose, // fingerBox knows where it goes
1988
+ ...extra,
1989
+ });
1990
+
1991
+ export default {
1992
+ meta: { title: "Plywood box with printed hinges", units: "mm" },
1993
+ parameters: [
1994
+ { id: "box", title: "Box", description: "Outside size, lid excluded.", controls: [
1995
+ { key: "width", label: "Width", unit: "mm", min: 100, max: 400, step: 1, description: "Outside width." },
1996
+ { key: "depth", label: "Depth", unit: "mm", min: 80, max: 300, step: 1, description: "Hinges on the back." },
1997
+ { key: "height", label: "Height", unit: "mm", min: 50, max: 200, step: 1, description: "Wall height." },
1998
+ { key: "label", type: "text", label: "Label", description: "Engraved on the front; empty for none." },
1999
+ ] },
2000
+ { id: "stock", title: "Stock & fit", description: "Measure your sheet. Kerf is chosen when you download the kit.", controls: [
2001
+ { 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." },
2002
+ { key: "fit", label: "Finger clearance", unit: "mm", min: 0, max: 0.4, step: 0.02, description: "Total play per finger joint." },
2003
+ { key: "printFit", label: "Hinge tab clearance", unit: "mm", min: 0, max: 0.8, step: 0.05, description: "Slot minus printed tongue." },
2004
+ ] },
2005
+ ],
2006
+ defaults: { width: 160, depth: 110, height: 80, label: "TOOLS", t: 3, fit: 0.1, printFit: 0.3 },
2007
+ derive: (p) => {
2008
+ const rise = Math.max(0, p.t - HINGE.R); // thick stock: the shut lid clears the walls
2009
+ const axisZ = p.height + HINGE.lift + HINGE.R + rise; // knuckle axis, above the back wall
2010
+ const lidZ = axisZ + HINGE.lift + HINGE.R; // hinge edge of the open lid
2011
+ return {
2012
+ box: fingerBox({ width: p.width, depth: p.depth, height: p.height, thickness: p.t, clearance: p.fit }),
2013
+ hingeX: [-(p.width / 2 - 25), p.width / 2 - 25],
2014
+ axisZ,
2015
+ tongueY: HINGE.tongueY + rise, // slots stay put
2016
+ // Open 90°: laser face to the back, the drawing's front edge (v = 0) on top.
2017
+ lidPose: { face: "+Y", up: "-Z", at: [-p.width / 2, p.depth / 2, lidZ + p.depth] },
2018
+ };
2019
+ },
2020
+ parts: {
2021
+ bottom: panel("bottom", "Bottom"),
2022
+ left: panel("left", "Left"),
2023
+ right: panel("right", "Right"),
2024
+ front: panel("front", "Front", { // u across, v up
2025
+ engrave: (k, p) => (p.label?.trim() ? k.text2d(p.label, { size: 14 }).translate([p.width / 2, p.height * 0.55]) : null),
2026
+ score: (k, p) => [[[12, p.height * 0.35], [p.width - 12, p.height * 0.35]]], // a two-point line
2027
+ }),
2028
+ back: panel("back", "Back", {
2029
+ profile: (k, p, d) => k.shape2d(d.box.back.outline)
2030
+ .cutAll(hingeSlots(k, p, d, d.box.back.pose, d.axisZ - d.tongueY)),
2031
+ }),
2032
+ lid: sheetPart({
2033
+ ...PLY, label: "Lid",
2034
+ profile: (k, p, d) => k.shape2d([[0, 0], [p.width, 0], [p.width, p.depth], [0, p.depth]])
2035
+ .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
2036
+ pose: (p, d) => d.lidPose,
2037
+ }),
2038
+ hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
2039
+ export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
2040
+ hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
2041
+ build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
2042
+ },
2043
+ views: { box: { label: "Box" } },
2044
+ // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.
2045
+ verify: { process: "fdm-pla", expect: { _view: { overlaps: 0 } } },
2046
+ };
2047
+ ```
2048
+
1772
2049
  ## Convex hull
1773
2050
 
1774
2051
  `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
@@ -3452,6 +3729,14 @@ rules judge the argument values the probe resolves under the part's default para
3452
3729
  same basis `import-unknown-name` uses; a call that only goes wrong for
3453
3730
  non-default params still fails correctly at build time.
3454
3731
 
3732
+ **Sheet parts** — `sheet-invalid`, `sheet-thickness-invalid`, `sheet-pose-invalid`
3733
+ (errors); `sheet-thickness-literal`, `sheet-kerf-control`, `sheet-custom-build`,
3734
+ `verify-process-sheets-only`, `laser-thickness-range` (warnings). Each carries
3735
+ `pattern: "sheet-parts"` and is described under "Sheet parts" → "What lint and
3736
+ verify check". `no-buildable-parts` points a sub-part with a `sheet` but no `build`
3737
+ at `sheetPart()`, and `verify-unknown-process` answers `process: "laser"` the same
3738
+ way.
3739
+
3455
3740
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
3456
3741
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
3457
3742
 
@@ -3463,7 +3748,8 @@ carries:
3463
3748
 
3464
3749
  - `hint` — one self-contained corrective sentence (always present),
3465
3750
  - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
3466
- applies (follow it with `ERROR-PATTERNS.md#<id>`),
3751
+ applies (follow it with `ERROR-PATTERNS.md#<id>`); on a sheet-part check it is
3752
+ `sheet-parts`, this guide's "Sheet parts" section, instead,
3467
3753
  - `note` — an optional caveat about *how* the value was measured, or a companion
3468
3754
  reading, attached whatever the verdict. `minWall` sets one when the reading came
3469
3755
  from a sample rather than every triangle (see below); `overhangArea` sets one
@@ -3517,6 +3803,14 @@ pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
3517
3803
  **absent** rather than empty when it did not run — an empty table means "measured,
3518
3804
  and these pairs have no distance", which a declared `clearance` gate fails on.
3519
3805
 
3806
+ A sheet sub-part (`sheetPart()`) also carries `sheet` — its 2-D facts:
3807
+ `material`, `thickness`, `flat` (the cut layer's size), `area`, `pieces`, the
3808
+ bisected `bridge`/`gap` widths with their `bridgeCapped`/`gapCapped` flags,
3809
+ `marksOutside`, `evaluated` (false when the 2-D budget ran out) and `at` (each
3810
+ finding's spot in the assembly) — and `sheet: null` on every other sub-part. In a
3811
+ view that holds one, each printed sub-part also carries `printBbox`, its size in
3812
+ the print (export) pose.
3813
+
3520
3814
  ### Quick checks
3521
3815
 
3522
3816
  An editor may ask for a **quick** check, which skips both ray-casting passes — min
@@ -3698,6 +3992,15 @@ the two apart. A single answerable expectation — or a process profile, which b
3698
3992
  the bed-fit gate — is enough for a verdict. Treat `null` as "not verified", never as
3699
3993
  a pass.
3700
3994
 
3995
+ **Forges with sheet parts.** When a view holds a `sheetPart()` sub-part, the
3996
+ profile's bed fits each printed sub-part in its print (export) pose — the check
3997
+ carries the note "measured in the print (export) pose" — instead of the assembled
3998
+ view, and `minWall` and `overhangArea` skip the sheet parts. The laser checks
3999
+ (`sheetBridge`, `sheetGap`, `sheetMarks`, `sheetPieces`, `sheetSolidMatch`) run on
4000
+ every sheet part as volunteered warnings that never make `ok` true on their own;
4001
+ see "Sheet parts" → "What lint and verify check". A view with no sheet part is
4002
+ verified exactly as before.
4003
+
3701
4004
  **Per-case expectations.** Checks run across defaults **and every preset**, so a
3702
4005
  static `expect` breaks the moment a preset legitimately changes an asserted fact —
3703
4006
  a "cup" preset that turns the drainage hole off flips the genus from 1 to 0.
@@ -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.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",