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 +12 -2
- package/docs/AUTHORING-PARTS.md +304 -1
- package/docs/KERNEL-CONTRACT.md +40 -10
- package/package.json +1 -1
- package/src/framework/geometry/arc-fit.js +169 -36
- package/src/framework/geometry/arc-math.js +15 -7
- package/src/framework/geometry/contour-offset.js +132 -5
- package/src/framework/geometry/polygon.js +7 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-schema.js +2 -1
- package/src/framework/lint/rules-shape.js +10 -3
- package/src/framework/lint/rules-sheet.js +172 -0
- package/src/framework/lint/rules-verify.js +10 -0
- package/src/framework/oracle/gates.js +13 -0
- package/src/framework/oracle/measure.js +129 -5
- package/src/framework/oracle/verify.js +113 -5
- package/src/framework/process/laser/descriptor.js +1089 -0
- package/src/framework/process/registry.js +24 -0
- package/src/framework/sheet/constants.js +104 -0
- package/src/framework/sheet/joinery.js +356 -0
- package/src/framework/sheet/part.js +98 -0
- package/src/framework/sheet/pose.js +114 -0
- package/src/framework/sheet/resolve.js +133 -0
- package/src/framework/verify-metrics.js +8 -1
- package/src/parts/laser-box.js +108 -0
- package/types/geometry.d.ts +115 -0
- package/types/lint.d.ts +4 -1
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +57 -1
- 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 ? ` (
|
|
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 ? ` (
|
|
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)` : ""}`);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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)
|
|
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
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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 ×
|
|
713
|
-
no region-count, hole-count, or area disagreements outside
|
|
714
|
-
characterizations. Do not widen tolerances or add an area-based sliver
|
|
715
|
-
case appears; add its deterministic fixture and establish the
|
|
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