partforge 0.9.0 → 0.11.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 +3 -0
- package/docs/AUTHORING-PARTS.md +130 -20
- package/docs/ERROR-PATTERNS.md +18 -0
- package/package.json +4 -2
- package/src/framework/app.css +1 -15
- package/src/framework/derive.js +28 -0
- package/src/framework/geometry/kernel.js +8 -1
- package/src/framework/geometry/probe.js +6 -1
- package/src/framework/jobs.js +8 -5
- package/src/framework/mount.js +7 -1
- package/src/framework/param-deps.js +64 -5
- package/src/framework/tokens.css +18 -0
- package/src/parts/planter.js +6 -4
- package/src/testing/bvh.js +111 -4
- package/src/testing/gaps.js +48 -0
- package/src/testing/measure.js +18 -2
- package/src/testing/verify.js +116 -6
- package/src/testing.js +3 -0
package/bin/cli.js
CHANGED
|
@@ -150,6 +150,9 @@ function printMeasure(r) {
|
|
|
150
150
|
console.log(` overlaps: ${r.overlaps.length
|
|
151
151
|
? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³ at [${o.location.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
|
|
152
152
|
: "none"}`);
|
|
153
|
+
console.log(` near-misses: ${r.nearMisses.length
|
|
154
|
+
? r.nearMisses.map((g) => `${g.a}×${g.b} (${g.distance.toFixed(2)}mm at [${g.at.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
|
|
155
|
+
: "none"}`);
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
function printVerify(v) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -44,7 +44,7 @@ export default {
|
|
|
44
44
|
meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
|
|
45
45
|
parameters, // the control-panel schema (array of sections — see below)
|
|
46
46
|
defaults, // flat { paramKey: value } — seeds params + control values
|
|
47
|
-
derive?, // (p) => d
|
|
47
|
+
derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
|
|
48
48
|
parts: { // named sub-parts; each builds ONE solid
|
|
49
49
|
<name>: {
|
|
50
50
|
label?, // display name (tabs/progress); defaults to the key
|
|
@@ -71,6 +71,14 @@ export default {
|
|
|
71
71
|
`"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
|
|
72
72
|
parts omit it. **Display placement must not depend on `view`** — display meshes are built
|
|
73
73
|
once per sub-part and cached across views (the viewer re-centres per view).
|
|
74
|
+
**Any difference between the display and export pose must be a rigid motion** —
|
|
75
|
+
`translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
|
|
76
|
+
non-identity `scale` on one purpose but not the other: the exported (printed) part is the
|
|
77
|
+
same physical object you show in the assembly, and a reflection or resize there makes the
|
|
78
|
+
two silently disagree — you print the mirror image of what the viewer showed
|
|
79
|
+
([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). If a part genuinely needs a
|
|
80
|
+
reflected or resized form (e.g. a block that seats flipped), bake that into `build` so
|
|
81
|
+
both purposes share one canonical solid, then pose it rigidly.
|
|
74
82
|
- `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
|
|
75
83
|
- A view's sub-parts are derived, never hard-coded: those whose `views` include the view
|
|
76
84
|
and whose `enabled(p)` is true.
|
|
@@ -81,8 +89,10 @@ export default {
|
|
|
81
89
|
|
|
82
90
|
`build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
|
|
83
91
|
handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF) and
|
|
84
|
-
**OCCT/replicad** (exact B-rep — STEP).
|
|
85
|
-
`src/framework/geometry/kernel.js
|
|
92
|
+
**OCCT/replicad** (exact B-rep — STEP). Op lists live in
|
|
93
|
+
`src/framework/geometry/kernel.js`; the normative semantics (conventions, value
|
|
94
|
+
semantics, conformance classes, versioning) are in `docs/KERNEL-CONTRACT.md` — the
|
|
95
|
+
tables below are the authoring-side view of that contract.
|
|
86
96
|
|
|
87
97
|
**Kernel — make solids:**
|
|
88
98
|
|
|
@@ -204,9 +214,16 @@ anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary ab
|
|
|
204
214
|
|
|
205
215
|
### Naming features (`.label()`)
|
|
206
216
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
Label your part's features, and label them **thoroughly** — this is how a user points
|
|
218
|
+
at what they want changed. The viewer's hover tooltip, highlight, and pick selection
|
|
219
|
+
all show a feature's label, so you, the app user, and an agent editing on their behalf
|
|
220
|
+
share one vocabulary: "make the Drainage hole 10 mm", "raise the Motor upright". A
|
|
221
|
+
feature with no name can't be referred to — it reads as the whole part, so the request
|
|
222
|
+
has nowhere to land.
|
|
223
|
+
|
|
224
|
+
Treat comprehensive labeling as the default, not a finishing touch. Name every feature
|
|
225
|
+
a user could reasonably want to change: the base body, and each functional feature —
|
|
226
|
+
grooves, mounts, bores, pockets, distinct structural members.
|
|
210
227
|
|
|
211
228
|
```js
|
|
212
229
|
const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
|
|
@@ -214,18 +231,26 @@ let s = body.cut(cavity.label("Cavity"));
|
|
|
214
231
|
if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
|
|
215
232
|
```
|
|
216
233
|
|
|
234
|
+
- **Aim for functional groups.** Label at the granularity a user would name a thing
|
|
235
|
+
("Rope groove", "Tensioner pockets", "Bearing seat"), grouping repeated or related
|
|
236
|
+
faces under one name. Fine enough to reference any feature; coarse enough that
|
|
237
|
+
near-identical surfaces don't fragment into dozens of near-duplicates.
|
|
217
238
|
- A label names the solid's **surface** wherever it survives into the final part —
|
|
218
239
|
a cutting tool's label lands on the faces it leaves behind (the hole's wall).
|
|
219
240
|
- Label **after** shaping compound tools (e.g. after an `intersect` clip) and
|
|
220
241
|
either before or after transforms — labels ride through `at`/`rotate`/etc.
|
|
221
|
-
-
|
|
222
|
-
|
|
242
|
+
- **Same label merges; distinct siblings need distinct names.** The same label on
|
|
243
|
+
several solids merges into one feature — label a ring of four bolt holes
|
|
244
|
+
`"Mounting holes"` and they hover/highlight as one. Conversely, when two similar
|
|
245
|
+
features are things a user would tell apart, name them apart — two uprights as
|
|
246
|
+
`"Drum upright"` and `"Motor upright"`, not both `"Upright"`.
|
|
223
247
|
- Unlabeled geometry falls back to the sub-part's `label`. Faces created by
|
|
224
248
|
`fillet`/`chamfer`/`shell` are new surfaces, so they use the fallback too.
|
|
225
249
|
- Works on both backends. On OCCT each label keeps a geometry snapshot for
|
|
226
|
-
mesh-time classification
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
mesh-time classification, so label meaningful features (functional groups — a
|
|
251
|
+
handful to a couple dozen per part), not hundreds of individual faces.
|
|
252
|
+
- Names should describe intent ("Drainage hole", not "cylinder2"); make them
|
|
253
|
+
unique per sub-part unless you specifically want the merge behavior.
|
|
229
254
|
|
|
230
255
|
### Caching & determinism
|
|
231
256
|
|
|
@@ -324,6 +349,33 @@ coherently:
|
|
|
324
349
|
thicknesses — so a single input feeds everything downstream. In the demo, `derive`
|
|
325
350
|
turns the nominal `bore` into `boreR` (with a fixed print clearance) and `h` into the
|
|
326
351
|
cut-tool height `cutH`; `build(k, p, d)` reads those.
|
|
352
|
+
- **Grouped `derive` (recommended once it grows):** `derive` may instead be an object of
|
|
353
|
+
named group functions, run in declaration order; each group receives `(p, d)` where `d`
|
|
354
|
+
holds the merged outputs of the groups **before** it:
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
derive: {
|
|
358
|
+
core: (p) => ({ boreR: p.bore / 2 + 0.15 }),
|
|
359
|
+
stand: (p, d) => ({ postH: d.boreR * 4 + p.base_t }), // may read earlier groups
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Builds see the same merged `d` either way. The point is the **control panel's
|
|
364
|
+
relevance dimming** (and the rebuild cache): with a single function, a sub-part that
|
|
365
|
+
reads *any* derived value is assumed to depend on *every* param `derive` touches, so
|
|
366
|
+
e.g. stand-only controls stay lit in a drum-only view. With groups, each derived key
|
|
367
|
+
is attributed to just its own group's inputs (plus, transitively, those of the groups
|
|
368
|
+
it read), so unrelated controls dim correctly. Group along your sub-part seams:
|
|
369
|
+
values only one sub-part family reads belong in their own group.
|
|
370
|
+
|
|
371
|
+
Grouped-form rules: a group reading a key **no earlier group produced** throws
|
|
372
|
+
immediately (misordered groups / typos would otherwise surface as silent NaN
|
|
373
|
+
geometry) — this includes optional-chaining reads like `d.maybe?.x`, so probe for a
|
|
374
|
+
conditionally-produced key with `"maybe" in d`, not `?.`. Prefer returning values
|
|
375
|
+
over mutating `d` in place — mutation works and is tracked, but returned keys read
|
|
376
|
+
clearer. Outside the part definition (helpers, tests), merge groups with
|
|
377
|
+
`resolveDerived(part, p)` from **`partforge/derive`** — a lean, DOM-free entry safe
|
|
378
|
+
to import from part modules; don't hand-roll the merge.
|
|
327
379
|
- **Reuse a param `key`** across sub-parts/features so one slider moves all of them.
|
|
328
380
|
- **`enabled(p)`** gates a whole sub-part on a toggle param (the part appears/disappears
|
|
329
381
|
with the control).
|
|
@@ -458,11 +510,11 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
|
|
|
458
510
|
`npx vitest run`. Build geometry directly off your part with a Manifold kernel:
|
|
459
511
|
|
|
460
512
|
```js
|
|
461
|
-
import { bootManifoldKernel } from "partforge/testing";
|
|
513
|
+
import { bootManifoldKernel, resolveDerived } from "partforge/testing";
|
|
462
514
|
import part from "../src/parts/<part>.js";
|
|
463
515
|
|
|
464
516
|
const k = await bootManifoldKernel();
|
|
465
|
-
const solid = part.parts.<name>.build(k, part.defaults,
|
|
517
|
+
const solid = part.parts.<name>.build(k, part.defaults, resolveDerived(part, part.defaults));
|
|
466
518
|
expect(solid.toMesh().triangles).toBeGreaterThan(0);
|
|
467
519
|
```
|
|
468
520
|
|
|
@@ -498,7 +550,10 @@ check it without opening the app:
|
|
|
498
550
|
|
|
499
551
|
`measure` prints a report: per sub-part and per view it reports bounding box,
|
|
500
552
|
volume, surface area, triangle count, whether the solid is watertight, and the
|
|
501
|
-
number of through-holes (genus), plus an assembly overlap check
|
|
553
|
+
number of through-holes (genus), plus an assembly overlap check, and a
|
|
554
|
+
**near-miss** check — sub-part pairs whose surfaces come closer than 0.5 mm
|
|
555
|
+
without touching (`near-misses:` in the output; reported for judgment, never an
|
|
556
|
+
exit-code gate by itself). It exits non-zero
|
|
502
557
|
if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
|
|
503
558
|
CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
|
|
504
559
|
`--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
|
|
@@ -531,13 +586,17 @@ carries:
|
|
|
531
586
|
- `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
|
|
532
587
|
sample point) and `overlaps` (the center of the first offending intersection's
|
|
533
588
|
*bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
|
|
534
|
-
more than one place the bbox center can fall in the empty space between regions)
|
|
535
|
-
|
|
589
|
+
more than one place the bbox center can fall in the empty space between regions)
|
|
590
|
+
and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
|
|
591
|
+
the pair's closest surface points). Whole-solid metrics (bbox, volume, …) have
|
|
592
|
+
none.
|
|
536
593
|
|
|
537
594
|
Subpart facts include `minWall` (number or `null` — null exactly when no reading
|
|
538
595
|
exists, e.g. the OCCT backend or min-wall measurement turned off, matching
|
|
539
596
|
`minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
|
|
540
|
-
`{ a, b, volume, location }`.
|
|
597
|
+
`{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
|
|
598
|
+
pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
|
|
599
|
+
`nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
|
|
541
600
|
|
|
542
601
|
A **thrown** error (bad part module, kernel failure) with `--json` prints pure
|
|
543
602
|
JSON to stdout and exits 1:
|
|
@@ -582,7 +641,9 @@ verify: {
|
|
|
582
641
|
cases: ["defaults", "M3"], // optional; default = defaults + every preset
|
|
583
642
|
expect: { // design intent, by sub-part name (+ "_view")
|
|
584
643
|
spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
|
|
585
|
-
_view: { overlaps: 0
|
|
644
|
+
_view: { overlaps: 0,
|
|
645
|
+
contacts: [["drum", "flange"]], // these pairs must touch
|
|
646
|
+
clearance: { "lid×body": ">=0.3" } }, // intended free fits
|
|
586
647
|
},
|
|
587
648
|
}
|
|
588
649
|
```
|
|
@@ -591,7 +652,7 @@ verify: {
|
|
|
591
652
|
and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
|
|
592
653
|
facts `measure` already reports — `holes` (through-bores / genus), `volume`,
|
|
593
654
|
`surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
|
|
594
|
-
`bbox`, `volume`, `overlaps
|
|
655
|
+
`bbox`, `volume`, `overlaps`, plus the pair-wise `contacts` / `clearance` below.
|
|
595
656
|
|
|
596
657
|
**Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
|
|
597
658
|
`"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
|
|
@@ -604,6 +665,53 @@ The parser is strict — a malformed assertion fails loudly.
|
|
|
604
665
|
`holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
|
|
605
666
|
rather than fail.
|
|
606
667
|
|
|
668
|
+
**Per-case expectations.** Checks run across defaults **and every preset**, so a
|
|
669
|
+
static `expect` breaks the moment a preset legitimately changes an asserted fact —
|
|
670
|
+
a "cup" preset that turns the drainage hole off flips the genus from 1 to 0.
|
|
671
|
+
For that, declare `expect` as a **pure function of the case's resolved params**,
|
|
672
|
+
`(p, d) => ({ … })` (same `p`/`d` your `build` sees, `d` from `derive`):
|
|
673
|
+
|
|
674
|
+
```js
|
|
675
|
+
verify: {
|
|
676
|
+
process: "fdm-pla",
|
|
677
|
+
expect: (p) => ({
|
|
678
|
+
planter: { holes: p.drain > 0 ? 1 : 0, bbox: "<=[220,220,250]" },
|
|
679
|
+
_view: { overlaps: 0 },
|
|
680
|
+
}),
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
`src/parts/planter.js` is the worked example — its "Pen cup" and "Vase" presets
|
|
685
|
+
disable the drain, so the hole count is pinned per case. Keep the function pure
|
|
686
|
+
(no clock/randomness), like every other part function.
|
|
687
|
+
|
|
688
|
+
**Contacts & clearance (near-miss gaps).** Volume, bbox, and render checks all miss
|
|
689
|
+
sub-parts that *almost* touch — a flange floating 0.3 mm off its drum body passes
|
|
690
|
+
every one of them. `measure` therefore reports `nearMisses` (pairs with a
|
|
691
|
+
surface-to-surface gap under 0.5 mm), and `_view` accepts two pair-wise gates:
|
|
692
|
+
|
|
693
|
+
- `contacts: [["drum", "flange"]]` — each listed pair must touch. The gate fails
|
|
694
|
+
with the measured gap and the closest-point location when the surfaces don't
|
|
695
|
+
meet. Interpenetration counts as contact — the separate `overlaps` gate owns
|
|
696
|
+
*excessive* interpenetration. A pair naming an `enabled()`-gated sub-part
|
|
697
|
+
**skips** in cases where that sub-part is off; a name that exists nowhere in
|
|
698
|
+
the part still throws.
|
|
699
|
+
- `clearance: { "lid×body": ">=0.3" }` — an intended free fit. Keys are `"a×b"`
|
|
700
|
+
(order doesn't matter); values take the same assertion DSL as any metric (and
|
|
701
|
+
the `{ expr, hint }` form), evaluated against the pair's minimum surface
|
|
702
|
+
distance in mm.
|
|
703
|
+
|
|
704
|
+
Any pair *not* declared either way that sits closer than 0.5 mm becomes a
|
|
705
|
+
**warning** — the "did you mean these to touch?" signal. Declare the pair to
|
|
706
|
+
silence it. Distances are measured mesh-to-mesh (exact triangle distance, so it
|
|
707
|
+
works on both backends with no kernel booleans); contact tolerates ~1 µm, so a
|
|
708
|
+
tessellation-limited curved contact (e.g. equal-radius cylinder-in-bore built with
|
|
709
|
+
different facet counts) may read a few hundredths of a millimetre — prefer a tight
|
|
710
|
+
`clearance` bound like `"<=0.05"` over `contacts` for those. One OCCT caveat: with
|
|
711
|
+
no overlap detection there (`Solid.intersect` is Manifold-only), a sub-part
|
|
712
|
+
*fully contained* inside another reads as its surface-to-surface distance, so it
|
|
713
|
+
can surface as a near miss — check containment cases on Manifold.
|
|
714
|
+
|
|
607
715
|
**Running it:**
|
|
608
716
|
|
|
609
717
|
```bash
|
|
@@ -645,7 +753,9 @@ whole part to OCCT — no declaration needed:
|
|
|
645
753
|
- `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
|
|
646
754
|
- `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
|
|
647
755
|
- `{ near: [x,y,z] }` — edges passing through a point
|
|
648
|
-
- a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier
|
|
756
|
+
- a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier — **OCCT-only
|
|
757
|
+
escape hatch**: fine for a part that's happy to stay in this repo, but non-portable
|
|
758
|
+
(parts meant to travel must use the object forms — see `KERNEL-CONTRACT.md`)
|
|
649
759
|
|
|
650
760
|
```js
|
|
651
761
|
let s = k.box([0,0,0],[40,30,16]);
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -73,6 +73,12 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
73
73
|
- **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
|
|
74
74
|
- **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
75
75
|
|
|
76
|
+
## place-not-rigid
|
|
77
|
+
|
|
78
|
+
- **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
|
|
79
|
+
- **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
|
|
80
|
+
- **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
81
|
+
|
|
76
82
|
## wrong-node-version
|
|
77
83
|
|
|
78
84
|
- **Symptom:** Confusing failures during `npm install`, tests, or CLI runs — WASM load errors, syntax errors in dependencies, or kernels that never boot — on a machine that built fine before.
|
|
@@ -91,6 +97,18 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
91
97
|
- **Cause:** The ray-shot wall-thickness measurement can catch sliver triangles at facet seams, reading a near-zero "wall" that isn't a designed wall.
|
|
92
98
|
- **Fix:** Check where the reported thin spot is: at a facet seam or chamfer transition it's a sliver artifact (minWall is a warning, never a gate — safe to note and move on); along a real wall, thicken the wall. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
|
|
93
99
|
|
|
100
|
+
## near-miss-gap
|
|
101
|
+
|
|
102
|
+
- **Symptom:** A `⚠ … nearMiss` warning or `✗ … contact` failure from `verify` reporting sub-parts `N mm apart, expected touching`, or a `near-misses:` line in `measure` output for parts that look joined in the preview.
|
|
103
|
+
- **Cause:** Two sub-parts that should meet don't quite — a boss shorter than the gap it must bridge, a mis-placed mating datum in `derive()`, or a union that silently missed. Renders and volume/bbox checks cannot see sub-mm joint gaps; this check exists precisely for them.
|
|
104
|
+
- **Fix:** If the pair should touch, grow the joining feature or fix the datum math so the faces meet, then declare the pair in `verify.expect._view.contacts`; if a free fit is intended, declare it under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
|
|
105
|
+
|
|
106
|
+
## expect-static-across-presets
|
|
107
|
+
|
|
108
|
+
- **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
|
|
109
|
+
- **Cause:** `verify` runs `expect` across defaults + every preset, and a preset legitimately changes the asserted fact (an optional feature like a drain/bore toggles the genus), while the expectation is one static value.
|
|
110
|
+
- **Fix:** Declare `expect` as a pure function of the case's resolved params — `expect: (p, d) => ({ body: { holes: p.drain > 0 ? 1 : 0 } })` — or restrict `verify.cases`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
|
|
111
|
+
|
|
94
112
|
## param-key-missing-from-defaults
|
|
95
113
|
|
|
96
114
|
- **Symptom:** The affected control's number box renders empty/blank (internally `numStr(undefined)` produces the string `NaN`, which a number input sanitizes to empty), or its range slider sits at a browser-default position and edits don't drive the geometry — no error is thrown — and if the key is `hidden`, no control is rendered for it at all.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
".": "./src/index.js",
|
|
26
26
|
"./worker": "./src/framework/worker.js",
|
|
27
27
|
"./geometry": "./src/framework/geometry/polygon.js",
|
|
28
|
-
"./
|
|
28
|
+
"./derive": "./src/framework/derive.js",
|
|
29
|
+
"./testing": "./src/testing.js",
|
|
30
|
+
"./tokens.css": "./src/framework/tokens.css"
|
|
29
31
|
},
|
|
30
32
|
"bin": {
|
|
31
33
|
"partforge": "./bin/cli.js"
|
package/src/framework/app.css
CHANGED
|
@@ -2,22 +2,8 @@
|
|
|
2
2
|
busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
|
|
3
3
|
every part-app gets it; each part's HTML only carries structural markup.
|
|
4
4
|
See docs/AUTHORING-PARTS.md. */
|
|
5
|
+
@import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
|
|
5
6
|
|
|
6
|
-
:root {
|
|
7
|
-
color-scheme: dark;
|
|
8
|
-
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
9
|
-
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
10
|
-
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
11
|
-
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
12
|
-
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
13
|
-
}
|
|
14
|
-
:root[data-theme="light"] {
|
|
15
|
-
color-scheme: light;
|
|
16
|
-
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
17
|
-
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
18
|
-
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
19
|
-
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
20
|
-
}
|
|
21
7
|
* { box-sizing: border-box; }
|
|
22
8
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
23
9
|
font: 13px/1.4 -apple-system, system-ui, sans-serif; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Resolve a part's `derive` into the derived-values object `d` that builds receive.
|
|
2
|
+
//
|
|
3
|
+
// Two authoring forms:
|
|
4
|
+
// derive: (p) => d — one function, computed in a single pass.
|
|
5
|
+
// derive: { name: (p, d) => {...}, … } — named GROUPS, run in declaration order;
|
|
6
|
+
// each group gets the params plus the merged outputs of the groups before it.
|
|
7
|
+
// The grouped form exists so the relevance layer (param-deps.js) can attribute each
|
|
8
|
+
// derived value to just its own group's inputs instead of every param derive touches.
|
|
9
|
+
export function resolveDerived(part, p) {
|
|
10
|
+
const derive = part.derive;
|
|
11
|
+
if (!derive) return {};
|
|
12
|
+
if (typeof derive === "function") return derive(p) ?? {};
|
|
13
|
+
const d = {};
|
|
14
|
+
// Groups read earlier groups' outputs through this guard: a key nothing has
|
|
15
|
+
// produced yet is a wiring mistake (group order / typo), and silently reading
|
|
16
|
+
// undefined would surface as NaN geometry far downstream — throw here instead.
|
|
17
|
+
// (Builds still receive the plain merged object, unguarded.)
|
|
18
|
+
const guard = new Proxy(d, {
|
|
19
|
+
get(t, key) {
|
|
20
|
+
if (typeof key === "string" && key !== "then" && !(key in t)) {
|
|
21
|
+
throw new Error(`derive: group read "${key}" before any earlier group produced it`);
|
|
22
|
+
}
|
|
23
|
+
return Reflect.get(t, key);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
for (const fn of Object.values(derive)) Object.assign(d, fn(p, guard) ?? {});
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
// test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
|
|
4
4
|
// contract can't silently drift from the implementations — the drift class that
|
|
5
5
|
// once broke the probe kernel (see probe.js). The @typedefs document signatures.
|
|
6
|
-
//
|
|
6
|
+
// The prose half of the contract — conventions, value semantics, conformance
|
|
7
|
+
// classes, versioning policy — is docs/KERNEL-CONTRACT.md; change either side and
|
|
8
|
+
// you must update the other. (2-D polygon helpers live in ./polygon.js.)
|
|
9
|
+
|
|
10
|
+
// The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
|
|
11
|
+
// must match this number (asserted in kernel-contract.test.js). Bump only on a
|
|
12
|
+
// breaking contract change — see the doc's Versioning section.
|
|
13
|
+
export const CONTRACT_VERSION = 1;
|
|
7
14
|
|
|
8
15
|
// Ops every backend kernel must implement.
|
|
9
16
|
export const KERNEL_OPS = [
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
|
|
4
4
|
// in kernel.js — the same list generates the Manifold backend's throwing stubs.
|
|
5
5
|
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
6
|
+
import { resolveDerived } from "../derive.js";
|
|
6
7
|
|
|
7
8
|
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
8
9
|
|
|
@@ -49,7 +50,11 @@ export function createProbeKernel() {
|
|
|
49
50
|
export function detectBackend(part, params = {}) {
|
|
50
51
|
if (part.meta?.backend) return part.meta.backend;
|
|
51
52
|
const p = { ...part.defaults, ...params };
|
|
52
|
-
|
|
53
|
+
let d = {};
|
|
54
|
+
// A throwing derive must not escape here — this runs on the main thread mid
|
|
55
|
+
// regen (after the busy spinner goes up). Probe with an empty `d`; the worker
|
|
56
|
+
// build hits the same throw and posts a proper error for the UI.
|
|
57
|
+
try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
|
|
53
58
|
const { kernel, used } = createProbeKernel();
|
|
54
59
|
for (const name of Object.keys(part.parts)) {
|
|
55
60
|
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
package/src/framework/jobs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
2
|
+
import { resolveDerived } from "./derive.js";
|
|
2
3
|
|
|
3
4
|
// Names of the sub-parts a view shows: declared in the view and enabled for these
|
|
4
5
|
// params. Order follows Object.keys(part.parts) (definition order).
|
|
@@ -23,8 +24,7 @@ export function exportSubParts(part, view, params) {
|
|
|
23
24
|
// layered over the part defaults, and derive() run once over the result.
|
|
24
25
|
export function resolveParams(part, params) {
|
|
25
26
|
const p = { ...part.defaults, ...params };
|
|
26
|
-
|
|
27
|
-
return { p, d };
|
|
27
|
+
return { p, d: resolveDerived(part, p) };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Build one sub-part and apply its optional place() for the given purpose/view.
|
|
@@ -52,13 +52,16 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
|
52
52
|
|
|
53
53
|
export async function handle(kernel, part, msg, post) {
|
|
54
54
|
const onProgress = (phase) => post({ type: "progress", phase });
|
|
55
|
-
const { p, d } = resolveParams(part, msg.params);
|
|
56
55
|
const label = (name) => part.parts[name].label ?? name;
|
|
57
56
|
const exportName = (name) => part.parts[name].export?.name ?? name;
|
|
58
|
-
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
59
|
-
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
60
57
|
|
|
61
58
|
try {
|
|
59
|
+
// Inside the try so a throwing derive posts an error the UI can show,
|
|
60
|
+
// instead of killing the worker turn silently (an endless spinner).
|
|
61
|
+
const { p, d } = resolveParams(part, msg.params);
|
|
62
|
+
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
63
|
+
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
64
|
+
|
|
62
65
|
if (msg.type === "generate") {
|
|
63
66
|
const t0 = Date.now();
|
|
64
67
|
const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
|
package/src/framework/mount.js
CHANGED
|
@@ -8,6 +8,7 @@ import { relevantParamKeys } from "./param-deps.js";
|
|
|
8
8
|
import { createMeshCache } from "./mesh-cache.js";
|
|
9
9
|
import { createGeometryService } from "./geometry-service.js";
|
|
10
10
|
import { viewSubParts } from "./jobs.js";
|
|
11
|
+
import { resolveDerived } from "./derive.js";
|
|
11
12
|
import { detectBackend } from "./geometry/probe.js";
|
|
12
13
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
13
14
|
import { createRegenLoop } from "./regen-loop.js";
|
|
@@ -58,7 +59,12 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
58
59
|
|
|
59
60
|
// Current selection context for the pickers: the active view + live params +
|
|
60
61
|
// derived values. Shared by both ?pick modes below.
|
|
61
|
-
const getContext = () =>
|
|
62
|
+
const getContext = () => {
|
|
63
|
+
let derived = {};
|
|
64
|
+
// A throwing derive must not crash the pick flow — proceed without derived context.
|
|
65
|
+
try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
|
|
66
|
+
return { view: view(), params, derived };
|
|
67
|
+
};
|
|
62
68
|
|
|
63
69
|
// ?pick enables click-to-select: a toggle button + a transient toast. Off by
|
|
64
70
|
// default — no button, no listener, no behavior change. Deleting this block and
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
6
|
import { viewSubParts } from "./jobs.js";
|
|
7
|
+
import { resolveDerived } from "./derive.js";
|
|
7
8
|
|
|
8
9
|
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
9
10
|
|
|
@@ -19,9 +20,56 @@ function recorder(obj, seen) {
|
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// Run derive with recorders. `allInputs` is every raw param derive reads.
|
|
24
|
+
// For the grouped form (derive as an object of group functions — see derive.js),
|
|
25
|
+
// `depsOf` maps each derived key to the raw params of just its own group,
|
|
26
|
+
// transitively including the groups whose outputs it read. For the single-function
|
|
27
|
+
// form there is no per-key attribution, so depsOf is null and callers fall back to
|
|
28
|
+
// treating every derive input as feeding every derived key.
|
|
29
|
+
function analyzeDerive(part, params) {
|
|
30
|
+
const allInputs = new Set();
|
|
31
|
+
if (!part.derive) return { derived: {}, allInputs, depsOf: null };
|
|
32
|
+
if (typeof part.derive === "function") {
|
|
33
|
+
const derived = part.derive(recorder(params, allInputs)) ?? {};
|
|
34
|
+
return { derived, allInputs, depsOf: null };
|
|
35
|
+
}
|
|
36
|
+
const derived = {};
|
|
37
|
+
const depsOf = new Map();
|
|
38
|
+
for (const fn of Object.values(part.derive)) {
|
|
39
|
+
const raw = new Set();
|
|
40
|
+
const fromEarlier = new Set();
|
|
41
|
+
const written = new Set();
|
|
42
|
+
// Reads are recorded (and guarded against not-yet-produced keys, matching
|
|
43
|
+
// resolveDerived); writes pass THROUGH to the real accumulator so a group
|
|
44
|
+
// that mutates `d` in place analyzes exactly like it runs in production.
|
|
45
|
+
const dProxy = new Proxy(derived, {
|
|
46
|
+
get(t, key) {
|
|
47
|
+
if (typeof key === "string" && key !== "then") {
|
|
48
|
+
if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
|
|
49
|
+
fromEarlier.add(key);
|
|
50
|
+
}
|
|
51
|
+
return Reflect.get(t, key);
|
|
52
|
+
},
|
|
53
|
+
set(t, key, v) {
|
|
54
|
+
if (typeof key === "string") written.add(key);
|
|
55
|
+
return Reflect.set(t, key, v);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const out = fn(recorder(params, raw), dProxy) ?? {};
|
|
59
|
+
const deps = new Set(raw);
|
|
60
|
+
for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
|
|
61
|
+
for (const r of raw) allInputs.add(r);
|
|
62
|
+
for (const key of [...Object.keys(out), ...written]) {
|
|
63
|
+
depsOf.set(key, deps);
|
|
64
|
+
if (Object.hasOwn(out, key)) derived[key] = out[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { derived, allInputs, depsOf };
|
|
68
|
+
}
|
|
69
|
+
|
|
22
70
|
export function relevantParamKeys(part, view, params) {
|
|
23
71
|
// The union of every on-screen sub-part's read set (that's exactly what
|
|
24
|
-
// subPartReadKeys computes, derive
|
|
72
|
+
// subPartReadKeys computes, derive attribution included)...
|
|
25
73
|
const reads = subPartReadKeys(part, view, params);
|
|
26
74
|
if (reads === RELEVANT_ALL) return RELEVANT_ALL; // analysis failed → everything relevant
|
|
27
75
|
try {
|
|
@@ -46,8 +94,7 @@ export function relevantParamKeys(part, view, params) {
|
|
|
46
94
|
// analysis failure (caller then treats every param as relevant — safe, just slower).
|
|
47
95
|
export function subPartReadKeys(part, view, params) {
|
|
48
96
|
try {
|
|
49
|
-
const
|
|
50
|
-
const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
|
|
97
|
+
const { derived, allInputs, depsOf } = analyzeDerive(part, params);
|
|
51
98
|
const { kernel } = createProbeKernel();
|
|
52
99
|
const map = new Map();
|
|
53
100
|
for (const name of viewSubParts(part, view, params)) {
|
|
@@ -55,8 +102,20 @@ export function subPartReadKeys(part, view, params) {
|
|
|
55
102
|
const reads = new Set();
|
|
56
103
|
const dSeen = new Set();
|
|
57
104
|
if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
|
|
58
|
-
sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
|
|
59
|
-
|
|
105
|
+
const built = sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
|
|
106
|
+
// place() shapes what's on screen too (display pose is baked into the cached
|
|
107
|
+
// mesh), so its reads count — without this, a param consumed only by place()
|
|
108
|
+
// would let the mesh cache skip a rebuild and leave the sub-part misplaced.
|
|
109
|
+
if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
|
|
110
|
+
if (dSeen.size > 0) {
|
|
111
|
+
if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
|
|
112
|
+
for (const k of dSeen) for (const dep of depsOf.get(k)) reads.add(dep);
|
|
113
|
+
} else {
|
|
114
|
+
// single-function derive, or a derived key no group produced: no
|
|
115
|
+
// attribution possible — fold every derive input in (safe, coarser).
|
|
116
|
+
for (const dep of allInputs) reads.add(dep);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
60
119
|
map.set(name, reads);
|
|
61
120
|
}
|
|
62
121
|
return map;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* partforge design tokens — the light/dark palette. Imported by app.css (so every
|
|
2
|
+
part-app gets it via mount) and exported as "partforge/tokens.css" for other
|
|
3
|
+
consumers (e.g. partforge-cloud's app chrome) to share one source of truth. */
|
|
4
|
+
:root {
|
|
5
|
+
color-scheme: dark;
|
|
6
|
+
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
7
|
+
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
8
|
+
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
9
|
+
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
10
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
11
|
+
}
|
|
12
|
+
:root[data-theme="light"] {
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
15
|
+
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
16
|
+
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
17
|
+
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
18
|
+
}
|
package/src/parts/planter.js
CHANGED
|
@@ -109,12 +109,14 @@ export default {
|
|
|
109
109
|
views: { planter: { label: "Planter" } },
|
|
110
110
|
// Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
|
|
111
111
|
// FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
|
|
112
|
-
// —
|
|
112
|
+
// — fits the bed, no interpenetration, and the RIGHT genus per case: verify runs
|
|
113
|
+
// across every preset, and "Pen cup"/"Vase" turn the drain off, so `expect` is a
|
|
114
|
+
// function of the case's params rather than one static hole count.
|
|
113
115
|
verify: {
|
|
114
116
|
process: "fdm-pla",
|
|
115
|
-
expect: {
|
|
116
|
-
planter: { holes:
|
|
117
|
+
expect: (p) => ({
|
|
118
|
+
planter: { holes: p.drain > 0 ? 1 : 0, bbox: "<=[220,220,250]" },
|
|
117
119
|
_view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
|
|
118
|
-
},
|
|
120
|
+
}),
|
|
119
121
|
},
|
|
120
122
|
};
|
package/src/testing/bvh.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
|
|
3
3
|
// triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
|
|
4
4
|
// `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
|
|
5
|
-
// (raycast)
|
|
6
|
-
// median split on the widest centroid axis, slab ray–box
|
|
5
|
+
// (raycast), nearest surface point (closestPoint), and exact mesh-to-mesh distance
|
|
6
|
+
// (distanceTo). AABB tree, median split on the widest centroid axis, slab ray–box
|
|
7
|
+
// test with pruning.
|
|
7
8
|
|
|
8
9
|
const LEAF = 4; // max triangles per leaf
|
|
9
10
|
|
|
@@ -104,6 +105,82 @@ function distSqBox(p, min, max) {
|
|
|
104
105
|
return s;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// summed extent of a node's AABB — the "which node is larger" heuristic for dual traversal
|
|
109
|
+
const nodeExtent = (n) => (n.max[0] - n.min[0]) + (n.max[1] - n.min[1]) + (n.max[2] - n.min[2]);
|
|
110
|
+
|
|
111
|
+
// squared distance between two AABBs (0 when they overlap)
|
|
112
|
+
function boxBoxDistSq(a, b) {
|
|
113
|
+
let s = 0;
|
|
114
|
+
for (let ax = 0; ax < 3; ax++) {
|
|
115
|
+
const v = a.min[ax] > b.max[ax] ? a.min[ax] - b.max[ax] : b.min[ax] > a.max[ax] ? b.min[ax] - a.max[ax] : 0;
|
|
116
|
+
s += v * v;
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// closest points between segments P1→Q1 and P2→Q2 (Ericson 5.1.9), → { a, b, d2 }
|
|
122
|
+
function closestSegSeg(P1, Q1, P2, Q2) {
|
|
123
|
+
const sub = (p, q) => [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
|
|
124
|
+
const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
|
|
125
|
+
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
126
|
+
const d1 = sub(Q1, P1), d2v = sub(Q2, P2), r = sub(P1, P2);
|
|
127
|
+
const a = dot(d1, d1), e = dot(d2v, d2v), f = dot(d2v, r);
|
|
128
|
+
const EPS = 1e-12;
|
|
129
|
+
let s, t;
|
|
130
|
+
if (a <= EPS && e <= EPS) { s = 0; t = 0; }
|
|
131
|
+
else if (a <= EPS) { s = 0; t = clamp01(f / e); }
|
|
132
|
+
else {
|
|
133
|
+
const c = dot(d1, r);
|
|
134
|
+
if (e <= EPS) { t = 0; s = clamp01(-c / a); }
|
|
135
|
+
else {
|
|
136
|
+
const b = dot(d1, d2v), denom = a * e - b * b;
|
|
137
|
+
s = denom !== 0 ? clamp01((b * f - c * e) / denom) : 0;
|
|
138
|
+
t = (b * s + f) / e;
|
|
139
|
+
if (t < 0) { t = 0; s = clamp01(-c / a); }
|
|
140
|
+
else if (t > 1) { t = 1; s = clamp01((b - c) / a); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const A = [P1[0] + d1[0] * s, P1[1] + d1[1] * s, P1[2] + d1[2] * s];
|
|
144
|
+
const B = [P2[0] + d2v[0] * t, P2[1] + d2v[1] * t, P2[2] + d2v[2] * t];
|
|
145
|
+
const pq = sub(A, B);
|
|
146
|
+
return { a: A, b: B, d2: dot(pq, pq) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// exact min distance between two triangles → { d2, a, b } (a on t1, b on t2).
|
|
150
|
+
// Non-intersecting triangles realize their minimum at a vertex-face or edge-edge
|
|
151
|
+
// feature pair; a piercing edge (interior×interior crossing) is caught first with
|
|
152
|
+
// rayTri, since feature distances alone would miss it. rayTri's t is in units of
|
|
153
|
+
// the unnormalized edge direction, so 0 < t <= 1 means the segment itself pierces;
|
|
154
|
+
// parallel/grazing edges return Infinity and the coplanar cases fall to the
|
|
155
|
+
// feature distances.
|
|
156
|
+
function triTriDist(t1, t2) {
|
|
157
|
+
const edges = (t) => [[t.v0, t.v1], [t.v1, t.v2], [t.v2, t.v0]];
|
|
158
|
+
for (const [p, q] of edges(t1)) {
|
|
159
|
+
const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
|
|
160
|
+
const t = rayTri(p, d, t2, 0);
|
|
161
|
+
if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
|
|
162
|
+
}
|
|
163
|
+
for (const [p, q] of edges(t2)) {
|
|
164
|
+
const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
|
|
165
|
+
const t = rayTri(p, d, t1, 0);
|
|
166
|
+
if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
|
|
167
|
+
}
|
|
168
|
+
let best = { d2: Infinity, a: null, b: null };
|
|
169
|
+
for (const v of [t2.v0, t2.v1, t2.v2]) {
|
|
170
|
+
const r = closestOnTri(v, t1);
|
|
171
|
+
if (r.d2 < best.d2) best = { d2: r.d2, a: r.point, b: v };
|
|
172
|
+
}
|
|
173
|
+
for (const v of [t1.v0, t1.v1, t1.v2]) {
|
|
174
|
+
const r = closestOnTri(v, t2);
|
|
175
|
+
if (r.d2 < best.d2) best = { d2: r.d2, a: v, b: r.point };
|
|
176
|
+
}
|
|
177
|
+
for (const [p1, q1] of edges(t1)) for (const [p2, q2] of edges(t2)) {
|
|
178
|
+
const r = closestSegSeg(p1, q1, p2, q2);
|
|
179
|
+
if (r.d2 < best.d2) best = { d2: r.d2, a: r.a, b: r.b };
|
|
180
|
+
}
|
|
181
|
+
return best;
|
|
182
|
+
}
|
|
183
|
+
|
|
107
184
|
// Möller–Trumbore; returns t>tMin or Infinity
|
|
108
185
|
function rayTri(o, d, tri, tMin) {
|
|
109
186
|
const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
|
|
@@ -144,7 +221,6 @@ export function buildBVH(mesh) {
|
|
|
144
221
|
return bestTri === -1 ? null : { t: best, tri: bestTri };
|
|
145
222
|
}
|
|
146
223
|
|
|
147
|
-
// No production consumer yet — pre-built + tested as the reusable primitive for the deferred clearance/min-feature gate.
|
|
148
224
|
function closestPoint(p) {
|
|
149
225
|
let best2 = Infinity, bestPt = null, bestTri = -1;
|
|
150
226
|
const stack = [root];
|
|
@@ -162,5 +238,36 @@ export function buildBVH(mesh) {
|
|
|
162
238
|
return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
|
|
163
239
|
}
|
|
164
240
|
|
|
165
|
-
|
|
241
|
+
// Exact minimum surface-to-surface distance to another buildBVH result.
|
|
242
|
+
// Dual traversal pruned by AABB–AABB distance; exact triangle–triangle
|
|
243
|
+
// distance at leaf pairs; early-exits at 0 (touching/intersecting).
|
|
244
|
+
function distanceTo(other) {
|
|
245
|
+
let best = { d2: Infinity, a: null, b: null };
|
|
246
|
+
const stack = [[root, other._root]];
|
|
247
|
+
while (stack.length && best.d2 > 0) {
|
|
248
|
+
const [na, nb] = stack.pop();
|
|
249
|
+
if (boxBoxDistSq(na, nb) >= best.d2) continue;
|
|
250
|
+
const aLeaf = !!na.tris, bLeaf = !!nb.tris;
|
|
251
|
+
if (aLeaf && bLeaf) {
|
|
252
|
+
for (const ta of na.tris) for (const tb of nb.tris) {
|
|
253
|
+
const r = triTriDist(ta, tb);
|
|
254
|
+
if (r.d2 < best.d2) best = r;
|
|
255
|
+
}
|
|
256
|
+
} else if (!aLeaf && (bLeaf || nodeExtent(na) >= nodeExtent(nb))) {
|
|
257
|
+
// descend the larger node; push the nearer child last so it pops first
|
|
258
|
+
const dl = boxBoxDistSq(na.left, nb), dr = boxBoxDistSq(na.right, nb);
|
|
259
|
+
if (dl < dr) stack.push([na.right, nb], [na.left, nb]);
|
|
260
|
+
else stack.push([na.left, nb], [na.right, nb]);
|
|
261
|
+
} else {
|
|
262
|
+
const dl = boxBoxDistSq(na, nb.left), dr = boxBoxDistSq(na, nb.right);
|
|
263
|
+
if (dl < dr) stack.push([na, nb.right], [na, nb.left]);
|
|
264
|
+
else stack.push([na, nb.left], [na, nb.right]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (best.a === null) return { distance: Infinity, at: null, pointA: null, pointB: null }; // empty mesh
|
|
268
|
+
const at = [(best.a[0] + best.b[0]) / 2, (best.a[1] + best.b[1]) / 2, (best.a[2] + best.b[2]) / 2];
|
|
269
|
+
return { distance: Math.sqrt(best.d2), at, pointA: best.a, pointB: best.b };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { raycast, closestPoint, distanceTo, _root: root };
|
|
166
273
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { buildView } from "./build.js";
|
|
2
|
+
import { buildBVH } from "./bvh.js";
|
|
3
|
+
|
|
4
|
+
// A measured pair distance at or below this (mm) counts as touching — absorbs
|
|
5
|
+
// posing float error while staying far below any real print clearance.
|
|
6
|
+
export const CONTACT_EPS = 1e-3;
|
|
7
|
+
|
|
8
|
+
// Default near-miss threshold (mm): pairs closer than this without touching are
|
|
9
|
+
// the "did you mean these to touch?" signal.
|
|
10
|
+
export const GAP_THRESHOLD = 0.5;
|
|
11
|
+
|
|
12
|
+
// Canonical order-insensitive pair identity — the one rule for "the same pair"
|
|
13
|
+
// shared by measure's overlap exclusion and verify's declared-pair matching.
|
|
14
|
+
export const pairKey = (a, b) => [a, b].sort().join("×");
|
|
15
|
+
|
|
16
|
+
// Minimum surface-to-surface distance for every sub-part pair of pre-built posed
|
|
17
|
+
// meshes ([{ name, mesh }] — buildView output). Distance 0 = touching or
|
|
18
|
+
// interpenetrating surfaces; callers filter. Pairs involving an empty mesh are
|
|
19
|
+
// skipped (the watertight gate owns that failure). Pure mesh math — both backends.
|
|
20
|
+
// → [{ a, b, distance, at: [x,y,z] }]
|
|
21
|
+
export function meshGaps(built) {
|
|
22
|
+
const hasTris = (m) => (m.indices ? m.indices.length > 0 : m.positions.length > 0);
|
|
23
|
+
const bvhs = built
|
|
24
|
+
.filter(({ mesh }) => hasTris(mesh))
|
|
25
|
+
.map(({ name, mesh }) => ({ name, bvh: buildBVH(mesh) }));
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = 0; i < bvhs.length; i++) {
|
|
28
|
+
for (let j = i + 1; j < bvhs.length; j++) {
|
|
29
|
+
const { distance, at } = bvhs[i].bvh.distanceTo(bvhs[j].bvh);
|
|
30
|
+
out.push({ a: bvhs[i].name, b: bvhs[j].name, distance, at });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Near-miss check for an assembled view — the complement of assemblyOverlaps:
|
|
37
|
+
// sub-part pairs that *almost* touch (0 < distance < threshold mm) in the display
|
|
38
|
+
// pose. Same posing path as assemblyOverlaps; no kernel booleans, so it runs on
|
|
39
|
+
// Manifold and OCCT alike.
|
|
40
|
+
// → [{ a, b, distance, at }] (empty = no near misses)
|
|
41
|
+
export function assemblyGaps(kernel, part, view, params = {}, { threshold = GAP_THRESHOLD } = {}) {
|
|
42
|
+
if (!(threshold > CONTACT_EPS)) {
|
|
43
|
+
throw new Error(`assemblyGaps: threshold must exceed CONTACT_EPS (${CONTACT_EPS} mm), got ${threshold}`);
|
|
44
|
+
}
|
|
45
|
+
const gaps = meshGaps(buildView(kernel, part, view, params));
|
|
46
|
+
kernel.cleanup?.(); // free the per-check WASM objects (meshes are JS-owned copies)
|
|
47
|
+
return gaps.filter((g) => g.distance > CONTACT_EPS && g.distance < threshold);
|
|
48
|
+
}
|
package/src/testing/measure.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildView } from "./build.js";
|
|
2
2
|
import { assemblyOverlaps } from "../framework/assembly.js";
|
|
3
|
+
import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
|
|
3
4
|
import { bounds, meshArea } from "./mesh.js";
|
|
4
5
|
import { minWall } from "./min-wall.js";
|
|
5
6
|
|
|
@@ -11,9 +12,10 @@ const unionBounds = (list) => list.reduce(
|
|
|
11
12
|
|
|
12
13
|
// Headless geometric report for one view of a part (Manifold-only). Reads exact
|
|
13
14
|
// solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
|
|
14
|
-
// the assembly overlap check
|
|
15
|
+
// the assembly overlap check plus pair gap distances (near misses are reported,
|
|
16
|
+
// never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
|
|
15
17
|
// which frees the shared kernel's objects at its end.
|
|
16
|
-
// → { part, view, subparts[], aggregate, overlaps[], ok }
|
|
18
|
+
// → { part, view, subparts[], aggregate, overlaps[], gaps[], nearMisses[], ok }
|
|
17
19
|
export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
|
|
18
20
|
const built = buildView(kernel, part, view, params);
|
|
19
21
|
const subBounds = [];
|
|
@@ -34,12 +36,24 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
34
36
|
};
|
|
35
37
|
});
|
|
36
38
|
|
|
39
|
+
// Pair surface distances from the meshes already built — no kernel dependency,
|
|
40
|
+
// so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
|
|
41
|
+
// *almost* touch; overlapping pairs are excluded by name (a fully-contained
|
|
42
|
+
// sub-part has surface distance > 0 but is the overlap gate's business).
|
|
43
|
+
const gaps = built.length > 1 ? meshGaps(built) : [];
|
|
44
|
+
|
|
37
45
|
// Rebuilds with the same kernel and cleans up at its end — every solid fact
|
|
38
46
|
// above is already read, so this is safe.
|
|
39
47
|
const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
|
|
40
48
|
const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
|
|
41
49
|
kernel.cleanup?.();
|
|
42
50
|
|
|
51
|
+
const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
|
|
52
|
+
const gapThreshold = opts.gapThreshold ?? GAP_THRESHOLD;
|
|
53
|
+
const nearMisses = gaps.filter(
|
|
54
|
+
(g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
|
|
55
|
+
);
|
|
56
|
+
|
|
43
57
|
const aggregate = {
|
|
44
58
|
bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
|
|
45
59
|
volume: subparts.reduce((a, s) => a + s.volume, 0),
|
|
@@ -52,6 +66,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
52
66
|
subparts,
|
|
53
67
|
aggregate,
|
|
54
68
|
overlaps,
|
|
69
|
+
gaps,
|
|
70
|
+
nearMisses,
|
|
55
71
|
ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
|
|
56
72
|
};
|
|
57
73
|
}
|
package/src/testing/verify.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
|
+
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
3
4
|
import { resolveProfile } from "./dfm-profiles.js";
|
|
4
5
|
import { expandCases } from "./cases.js";
|
|
5
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
|
|
7
|
+
import { resolveParams } from "../framework/jobs.js";
|
|
6
8
|
|
|
7
9
|
// Metric registry: name → how to pull the value out of facts, whether a failure
|
|
8
10
|
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
@@ -44,6 +46,98 @@ const normalizeExpectation = (spec) =>
|
|
|
44
46
|
? { expr: spec.expr, hint: spec.hint }
|
|
45
47
|
: { expr: spec, hint: undefined };
|
|
46
48
|
|
|
49
|
+
const PAIR_HINTS = {
|
|
50
|
+
contact: "the pair should touch but doesn't — grow the joining feature or move the mating datum so the faces meet",
|
|
51
|
+
clearance: "the pair's free-fit gap is out of the declared range — adjust the mating dimensions or the declared clearance",
|
|
52
|
+
nearMiss: "sub-parts nearly touch here — if they should meet, declare the pair in verify.expect._view.contacts and close the gap; if a free fit is intended, declare it under clearance",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Pair-wise view checks: `contacts` (must touch), `clearance` (assertion DSL vs
|
|
56
|
+
// the measured pair distance), and warnings for undeclared near misses. These are
|
|
57
|
+
// per-pair, so they live outside the scalar VIEW_METRICS registry but emit the
|
|
58
|
+
// same structured check objects.
|
|
59
|
+
function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
|
|
60
|
+
const checks = [];
|
|
61
|
+
const declared = new Set();
|
|
62
|
+
const names = new Set(facts.subparts.map((s) => s.name));
|
|
63
|
+
// The part's full sub-part vocabulary (when the caller knows it): a declared
|
|
64
|
+
// name absent from THIS case's facts but present in the part is an
|
|
65
|
+
// enabled()-gated sub-part that is off for this case → skip, don't throw.
|
|
66
|
+
// A name in neither set is a typo → throw. Without the vocabulary (bare
|
|
67
|
+
// evaluateCase callers) the case's own names are the vocabulary.
|
|
68
|
+
const known = subPartNames ? new Set(subPartNames) : names;
|
|
69
|
+
const requirePair = (a, b, what) => {
|
|
70
|
+
if (a === b) throw new Error(`${what}: a pair must name two different sub-parts, got ["${a}", "${b}"]`);
|
|
71
|
+
let absent = false;
|
|
72
|
+
for (const n of [a, b]) {
|
|
73
|
+
if (names.has(n)) continue;
|
|
74
|
+
if (!known.has(n)) throw new Error(`${what}: unknown sub-part "${n}" (view has: ${[...names].join(", ")})`);
|
|
75
|
+
absent = true;
|
|
76
|
+
}
|
|
77
|
+
return absent; // true = valid pair, but a sub-part is disabled in this case
|
|
78
|
+
};
|
|
79
|
+
const gapFor = (a, b) => facts.gaps?.find((g) => pairKey(g.a, g.b) === pairKey(a, b));
|
|
80
|
+
const disabledSkip = (base) => ({ ...base, actual: null, status: "skip", pass: null, message: "sub-part disabled in this case" });
|
|
81
|
+
// No gap table at all = legacy facts → skip. A table that MERELY LACKS the pair
|
|
82
|
+
// = the sub-part built empty (meshGaps skips empty meshes) → a declared gate
|
|
83
|
+
// must fail loudly, not skip, or verify.ok would vouch for an unverified pair.
|
|
84
|
+
const noReading = (base) => (facts.gaps
|
|
85
|
+
? { ...base, actual: null, status: "fail", pass: false,
|
|
86
|
+
message: "no measured distance for the pair",
|
|
87
|
+
hint: "one sub-part produced no mesh (an empty solid?) — fix the build before trusting this gate" }
|
|
88
|
+
: { ...base, actual: null, status: "skip", pass: null, message: "unavailable" });
|
|
89
|
+
|
|
90
|
+
if (contacts != null && !Array.isArray(contacts)) {
|
|
91
|
+
throw new Error(`contacts: must be an array of ["a", "b"] pairs, got ${JSON.stringify(contacts)}`);
|
|
92
|
+
}
|
|
93
|
+
for (const pair of contacts ?? []) {
|
|
94
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
95
|
+
throw new Error(`contacts: each entry must be an ["a", "b"] pair, got ${JSON.stringify(pair)}`);
|
|
96
|
+
}
|
|
97
|
+
const [a, b] = pair;
|
|
98
|
+
const disabled = requirePair(a, b, "contacts");
|
|
99
|
+
declared.add(pairKey(a, b));
|
|
100
|
+
const base = { scope: "view", subpart: `${a}×${b}`, metric: "contact", kind: "gate", expr: "touching" };
|
|
101
|
+
if (disabled) { checks.push(disabledSkip(base)); continue; }
|
|
102
|
+
const g = gapFor(a, b);
|
|
103
|
+
if (!g) { checks.push(noReading(base)); continue; }
|
|
104
|
+
const overlapping = (facts.overlaps ?? []).some((o) => pairKey(o.a, o.b) === pairKey(a, b));
|
|
105
|
+
if (overlapping || g.distance <= CONTACT_EPS) {
|
|
106
|
+
checks.push({ ...base, actual: g.distance, status: "pass", pass: true, message: overlapping ? "in contact (overlapping)" : "in contact" });
|
|
107
|
+
} else {
|
|
108
|
+
checks.push({ ...base, actual: g.distance, status: "fail", pass: false,
|
|
109
|
+
message: `${g.distance.toFixed(3)}mm apart, expected touching`,
|
|
110
|
+
hint: PAIR_HINTS.contact, pattern: "near-miss-gap", location: g.at });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [key, spec] of Object.entries(clearance ?? {})) {
|
|
115
|
+
const pair = key.split("×").map((s) => s.trim());
|
|
116
|
+
if (pair.length !== 2 || !pair[0] || !pair[1]) throw new Error(`clearance: pair key must be "a×b", got "${key}"`);
|
|
117
|
+
const [a, b] = pair;
|
|
118
|
+
const disabled = requirePair(a, b, "clearance");
|
|
119
|
+
declared.add(pairKey(a, b));
|
|
120
|
+
const { expr, hint: partHint } = normalizeExpectation(spec);
|
|
121
|
+
const base = { scope: "view", subpart: `${a}×${b}`, metric: "clearance", kind: "gate", expr: String(expr) };
|
|
122
|
+
if (disabled) { checks.push(disabledSkip(base)); continue; }
|
|
123
|
+
const g = gapFor(a, b);
|
|
124
|
+
if (!g) { checks.push(noReading(base)); continue; }
|
|
125
|
+
const { pass, message } = evaluateAssertion(parseAssertion(expr), g.distance);
|
|
126
|
+
const out = { ...base, actual: g.distance, status: pass ? "pass" : "fail", pass, message };
|
|
127
|
+
if (!pass) { out.hint = partHint ?? PAIR_HINTS.clearance; out.pattern = "near-miss-gap"; out.location = g.at; }
|
|
128
|
+
checks.push(out);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const g of facts.nearMisses ?? []) {
|
|
132
|
+
if (declared.has(pairKey(g.a, g.b))) continue;
|
|
133
|
+
checks.push({ scope: "view", subpart: `${g.a}×${g.b}`, metric: "nearMiss", kind: "warn",
|
|
134
|
+
expr: "intent undeclared", actual: g.distance, status: "warn", pass: false,
|
|
135
|
+
message: `${g.distance.toFixed(3)}mm gap`, hint: PAIR_HINTS.nearMiss,
|
|
136
|
+
pattern: "near-miss-gap", location: g.at });
|
|
137
|
+
}
|
|
138
|
+
return checks;
|
|
139
|
+
}
|
|
140
|
+
|
|
47
141
|
function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
48
142
|
const reg = registry[metric];
|
|
49
143
|
if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
|
|
@@ -71,13 +165,17 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
71
165
|
}
|
|
72
166
|
|
|
73
167
|
// Pure policy: profile rules + per-part expect → checks for one case's facts.
|
|
74
|
-
export function evaluateCase(facts, { profile, expect }) {
|
|
168
|
+
export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
75
169
|
const checks = [];
|
|
170
|
+
// contacts/clearance are per-pair, not scalar view metrics — peel them off
|
|
171
|
+
// before the registry loop and hand them to pairGapChecks.
|
|
172
|
+
const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
|
|
76
173
|
const viewExp = {
|
|
77
174
|
...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
|
|
78
|
-
...
|
|
175
|
+
...viewScalarExp,
|
|
79
176
|
};
|
|
80
177
|
for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
|
|
178
|
+
checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
|
|
81
179
|
|
|
82
180
|
for (const s of facts.subparts) {
|
|
83
181
|
const merged = {
|
|
@@ -93,11 +191,22 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
93
191
|
view = view ?? Object.keys(part.views)[0];
|
|
94
192
|
const profileSpec = process ?? part.verify?.process;
|
|
95
193
|
const profile = profileSpec ? resolveProfile(profileSpec) : null;
|
|
96
|
-
const
|
|
97
|
-
const expectMentionsMinWall = Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o);
|
|
98
|
-
const needMinWall = profile?.minWall != null || expectMentionsMinWall;
|
|
194
|
+
const expectSpec = part.verify?.expect ?? {};
|
|
99
195
|
|
|
100
196
|
const cases = expandCases(part);
|
|
197
|
+
// `expect` can be a pure function of the case's resolved params — (p, d) →
|
|
198
|
+
// expect object — so topology that legitimately changes with a preset (an
|
|
199
|
+
// optional drain or bore flipping the genus) can be pinned per case instead
|
|
200
|
+
// of one static number that some presets must violate.
|
|
201
|
+
const resolveExpect = (params) => {
|
|
202
|
+
if (typeof expectSpec !== "function") return expectSpec;
|
|
203
|
+
const { p, d } = resolveParams(part, params);
|
|
204
|
+
return expectSpec(p, d) ?? {};
|
|
205
|
+
};
|
|
206
|
+
const expanded = cases.map((c) => ({ ...c, expect: resolveExpect(c.params) }));
|
|
207
|
+
const expectMentionsMinWall = expanded.some(({ expect }) =>
|
|
208
|
+
Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o));
|
|
209
|
+
const needMinWall = profile?.minWall != null || expectMentionsMinWall;
|
|
101
210
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
102
211
|
const signature = (params) =>
|
|
103
212
|
readKeys === RELEVANT_ALL
|
|
@@ -111,7 +220,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
111
220
|
return memo.get(key);
|
|
112
221
|
};
|
|
113
222
|
|
|
114
|
-
const
|
|
223
|
+
const subPartNames = Object.keys(part.parts);
|
|
224
|
+
const caseResults = expanded.map(({ name, params, expect }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect, subPartNames }) }));
|
|
115
225
|
const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
|
|
116
226
|
return {
|
|
117
227
|
ok: !all.some((c) => c.status === "fail"),
|
package/src/testing.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
|
|
5
5
|
export { bootManifoldKernel } from "./testing/manifold.js";
|
|
6
6
|
export { handle, viewSubParts } from "./framework/jobs.js";
|
|
7
|
+
export { resolveDerived } from "./framework/derive.js";
|
|
8
|
+
export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
|
|
7
9
|
export { assemblyOverlaps } from "./framework/assembly.js";
|
|
10
|
+
export { assemblyGaps, meshGaps } from "./testing/gaps.js";
|
|
8
11
|
export { bootOcctKernel } from "./testing/occt.js";
|
|
9
12
|
export { meshVolume, bboxSize } from "./testing/mesh.js";
|
|
10
13
|
export { buildView } from "./testing/build.js";
|