partforge 0.109.1 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -222,11 +222,17 @@ const commands = {
222
222
  };
223
223
  if (flags.out) writeOut();
224
224
  let vok = true;
225
- if ((part.verify || flags.process) && !flags["no-verify"]) {
225
+ // Always, not only when the part has a `verify` block: a part with none is
226
+ // the most vacuous case of all, and the "nothing verified" verdict below
227
+ // exists to say so rather than let a clean exit read as verified.
228
+ if (!flags["no-verify"]) {
226
229
  const v = verify(kernel, part, { process: flags.process, view });
227
230
  if (!flags.json) printVerify(v);
228
231
  report.verify = v;
229
- vok = v.ok;
232
+ // Tri-state: `null` is a withheld verdict (nothing declared, or a check
233
+ // that could not run), not a failure — the notice prints; the exit code
234
+ // reports only what actually failed.
235
+ vok = v.ok !== false;
230
236
  if (flags.out) writeOut();
231
237
  }
232
238
  if (flags.out) console.log(`\nwrote ${flags.out}`);
@@ -542,8 +548,21 @@ function printVerify(v) {
542
548
  }
543
549
  }
544
550
  }
551
+ // Part-level notices live only in `warnings` (they are about the part, not a
552
+ // case), so they print here, after the cases.
553
+ for (const n of v.warnings.filter((c) => c.scope === "part")) {
554
+ console.log(` part`);
555
+ console.log(` ⚠ ${n.metric} (${n.message})`);
556
+ if (n.hint) console.log(` hint: ${n.hint}`);
557
+ }
545
558
  const f = v.failures.length, w = v.warnings.length;
546
- console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
559
+ // One switch on the tri-state verdict, so the line and `ok` cannot disagree.
560
+ const verdict = v.ok === false ? `${f} gate failure(s)`
561
+ : v.ok === true ? "all gates passed"
562
+ : v.unevaluated?.length ? `verdict withheld (${v.unevaluated.length} check(s) not evaluated)`
563
+ : v.declared ? "nothing verified (every declared check skipped)"
564
+ : "nothing verified (no expectations declared)";
565
+ console.log(` result: ${verdict}${w ? `, ${w} warning(s)` : ""}`);
547
566
  }
548
567
 
549
568
  function printLint(r) {
@@ -2860,6 +2860,7 @@ probe runs).
2860
2860
 
2861
2861
  **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
2862
2862
  `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
2863
+ `verify-unknown-orientation`,
2863
2864
  `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
2864
2865
  `contacts` / `clearance` keys, which are not scalar view metrics; they are
2865
2866
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
@@ -2993,11 +2994,13 @@ carries:
2993
2994
  - `hint` — one self-contained corrective sentence (always present),
2994
2995
  - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
2995
2996
  applies (follow it with `ERROR-PATTERNS.md#<id>`),
2996
- - `note` — an optional caveat about *how* the value was measured, attached
2997
- whatever the verdict. Today only `minWall` sets one, when the reading came
2998
- from a sample rather than every triangle (see below),
2997
+ - `note` — an optional caveat about *how* the value was measured, or a companion
2998
+ reading, attached whatever the verdict. `minWall` sets one when the reading came
2999
+ from a sample rather than every triangle (see below); `overhangArea` sets one
3000
+ naming the steepest unsupported face's angle,
2999
3001
  - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
3000
- sample point) and `overlaps` (the center of the first offending intersection's
3002
+ sample point), `overhangArea` (the largest unsupported face's centroid) and
3003
+ `overlaps` (the center of the first offending intersection's
3001
3004
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
3002
3005
  more than one place the bbox center can fall in the empty space between regions)
3003
3006
  and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
@@ -3010,6 +3013,11 @@ exists, e.g. the OCCT backend or min-wall measurement turned off, matching
3010
3013
  per triangle, which is unbounded work on a dense mesh, so past a sample budget
3011
3014
  it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
3012
3015
  and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
3016
+ A part that opted into the overhang check (see the `verify` block) also carries
3017
+ `overhangArea` (mm² of unsupported downward-facing surface, `null` when not
3018
+ checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
3019
+ face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
3020
+ `measuredOverhang` stamps the angle the pass ran against, or `null`.
3013
3021
  **The budget depends on whether the reading is checked against anything**: a part
3014
3022
  that declares a min-wall gate — a `verify.process` profile, or an `expect`
3015
3023
  mentioning `minWall` — gets 50,000, because a gate's verdict rides on it; a part
@@ -3099,7 +3107,9 @@ A part can declare how it should be checked, co-located with its schema, so
3099
3107
  ```js
3100
3108
  verify: {
3101
3109
  process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
3102
- // inline { bed:[x,y,z], minWall, clearance } object
3110
+ // inline { bed:[x,y,z], minWall, clearance, overhang } object
3111
+ orientation: "print", // optional; ONLY with this is overhang checked — it says the
3112
+ // part is laid out for its bed (Z up, bed at the lowest Z)
3103
3113
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
3104
3114
  expect: { // design intent, by sub-part name (+ "_view")
3105
3115
  spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
@@ -3110,10 +3120,30 @@ verify: {
3110
3120
  }
3111
3121
  ```
3112
3122
 
3113
- **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
3114
- and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
3115
- facts `measure` already reports `holes` (through-bores / genus), `volume`,
3116
- `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `boundsMin` / `boundsMax`
3123
+ **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`),
3124
+ a **min-wall** warning, and only for a part that also declares
3125
+ `orientation: "print"` an **overhang** warning: `overhangArea` is the mm² of
3126
+ downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
3127
+ on the FDM profiles; resin carries none, since it prints on supports), measured per
3128
+ sub-part with the bed at that sub-part's own lowest Z, and warned past 1 mm². The
3129
+ opt-in is deliberate: a profile says what a process can print, the orientation key
3130
+ says this part is laid out for it, and a part still being shaped, or one bound for a
3131
+ different process, should not be nagged about its underside. Writing your own
3132
+ `overhangArea` expectation is the other way in — it arms the measurement by itself,
3133
+ against the profile's angle or 45° when the profile names none, so a declared
3134
+ expectation is never answered "unavailable". Two bands next to the bed are never
3135
+ counted: the footprint itself, and faces whose centroid sits within 1 mm of the bed
3136
+ (the lower curl of a bottom-edge fillet or chamfer, which prints fine). A bridge (a
3137
+ flat underside spanning two supports) and the ceiling of a horizontal bore are
3138
+ reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
3139
+ is why this is a warning and never a gate. Two more limits, stated: it is judged in
3140
+ the DISPLAY pose, so a sub-part whose `place` differs for export (a lid that prints
3141
+ flat beside its base) is measured as displayed, and `exportable: false` sub-parts
3142
+ are skipped. Switch it off under an FDM profile with an inline
3143
+ `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
3144
+ assertions on the facts `measure` already reports — `holes` (through-bores / genus),
3145
+ `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
3146
+ `overhangArea`, `boundsMin` / `boundsMax`
3117
3147
  (the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
3118
3148
  `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
3119
3149
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -3150,9 +3180,25 @@ verify: { expect: {
3150
3180
 
3151
3181
  **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
3152
3182
  `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
3153
- **warning** — it flags walls below the profile's minimum but never fails the build.
3154
- `holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
3155
- rather than fail.
3183
+ **warning** — it flags walls below the profile's minimum but never fails the build
3184
+ and so is `overhangArea` (see above). `holes`/`watertight` are Manifold-only, so those
3185
+ assertions **skip** on OCCT parts rather than fail.
3186
+
3187
+ **A verify block that declares nothing verifies nothing.** `verify.ok` is tri-state:
3188
+ `true` when every declared check passed, `false` on any gate failure, and `null` when
3189
+ no verdict can be given — a quick lap that could not measure a gate, or a part with
3190
+ **no declared expectations** at all (no `verify` block, an empty `expect`, no profile).
3191
+ That last case used to read as `ok: true` with zero checks, which every reader took as
3192
+ "verified". It now comes back `ok: null` with `evaluated: 0` and a `no expectations
3193
+ declared` warning whose hint says what to pin; the CLI runs verify on every part, block
3194
+ or no block, prints *nothing verified* and exits 0 (a withheld verdict is not a
3195
+ failure). The same `null` covers a part that declared checks none of which could be
3196
+ answered — every one SKIPPED (a `ref*` metric on a sub-part with no `reference`,
3197
+ `holes` on the OCCT backend, a pair on a disabled sub-part) — with a `no expectation
3198
+ could be evaluated` warning instead; `declared` and `evaluated` on the report tell
3199
+ the two apart. A single answerable expectation — or a process profile, which brings
3200
+ the bed-fit gate — is enough for a verdict. Treat `null` as "not verified", never as
3201
+ a pass.
3156
3202
 
3157
3203
  **Per-case expectations.** Checks run across defaults **and every preset**, so a
3158
3204
  static `expect` breaks the moment a preset legitimately changes an asserted fact —
@@ -3456,6 +3502,16 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3456
3502
  For the case that causes this most often — a tapped hole — reach for
3457
3503
  `k.tappedBore`, which owns the bore and the thread together and cannot land them
3458
3504
  on the same face.
3505
+ - **A boolean that comes back geometrically impossible is a build error, not a
3506
+ part.** Every `cut`/`cutAll`/`intersect`/`union` result is judged by volume against
3507
+ its operands on both backends: a union smaller than an input, a cut that grew, a
3508
+ negative volume, or the exact kernel's silent "returned one operand instead of the
3509
+ union" all throw `boolean result invalid: …` with the fix menu above instead of
3510
+ shipping a wrong preview or STEP file
3511
+ ([boolean-dropped-operand](ERROR-PATTERNS.md#boolean-dropped-operand),
3512
+ [boolean-impossible-result](ERROR-PATTERNS.md#boolean-impossible-result)). A
3513
+ legitimately degenerate design — a hole wider than its plate — is not impossible and
3514
+ builds as before; `verify` is what catches that.
3459
3515
 
3460
3516
  ---
3461
3517
 
@@ -68,6 +68,18 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
68
68
  - **Detected:** The exact kernel now refuses the common cylindrical form of this contact up front — several swept faces lying exactly on one cylindrical face fail the boolean immediately with `<op> between exactly-touching surfaces: … (radius <r>)` and the fix menu below, instead of grinding. Scope, honestly: the guard needs the contact to tile the cylinder (a thread does, ~6+ hugging faces per turn; a sub-turn thread can slip under it — that is the old grinding behavior, not a new one), it covers swept-face-on-cylinder contact only (two swept faces mated exactly, or contact with non-cylindrical faces, can still hang), and a hand-sunk thread whose chord-bands happen to hug the wall can be refused even though it would have built — `k.tappedBore` resolves that refusal too, since its internal composition is exempt. The rule below applies everywhere regardless.
69
69
  - **Fix:** For a tapped hole — far and away the most common cause — use `k.tappedBore({ d, pitch, turns, depth })`, which returns the bore and its thread as one tool and cannot put them on the same face. Otherwise: give the surfaces a deliberate clearance instead of letting them land on the same number. Derive one from the other with an explicit gap — `const boreD = threadRootD - 2 * boreClearance;` with `boreClearance` around 0.05-0.1 mm — rather than reusing the same expression for both. The gap is far below a printable layer, so the fit is unchanged. The same rule covers a cut that ends exactly flush with a face (overshoot it by a few tenths, as the surrounding examples do with `+ 0.4` / `- 0.2`) and two tools that abut exactly end-to-end.
70
70
 
71
+ ## boolean-dropped-operand
72
+
73
+ - **Symptom:** `dropped an operand` — the full message reads `boolean result invalid: <op> dropped an operand — the result's volume (<v>) equals operand <i>'s exactly, but operand <j> (<vj>) has <x> mm³ of material outside it that the union lost.`, thrown from a `union` (or from `cutAll`, labelled `cutAll (tools)`, whose tools are fused before the cut; or labelled `k.tappedBore's bore ∪ thread union`, in which case the author wrote no union — the framework's own composition failed, report it). Before partforge 0.110 the same construction shipped silently: a STEP export that is a plain cylinder, a preview missing the thread, a `measure` volume equal to the core's alone ([screw-thread-vanishes-on-occt](#screw-thread-vanishes-on-occt)).
74
+ - **Cause:** The exact kernel's fuse failed without reporting it and returned one operand instead of the union — a thin or near-self-touching swept operand (a sub-pitch thread ridge riding a core, a thread tool whose root sits on the bore wall) is the case seen on real parts. The gate (`boolean-gate.js`) noticed because the result's volume is one operand's to float precision while the other operand has material outside it, which no union can lose.
75
+ - **Fix:** Give the operands genuine overlap rather than a tangent contact (sink one 0.05 mm or more into the other — derive one radius from the other with an explicit offset, never the same expression twice). Build a thread in the **periodic** `screwSweep` form, which needs no union at all, or a tapped hole with `k.tappedBore`, which owns the bore and thread together. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Helical & threaded features". Do not "fix" it by catching the error — the geometry it refused is wrong, and the previous good preview stays on screen while you correct the construction.
76
+
77
+ ## boolean-impossible-result
78
+
79
+ - **Symptom:** `produced an impossible result` — the full message reads `boolean result invalid: <op> produced an impossible result — …`, thrown from `union`, `cut`, `cutAll` or `intersect`, naming one of: `a negative volume`, `a union smaller than its largest operand`, `a cut larger than its body`, `a cut that emptied its body although the tools cover at most <x> mm³ of its <y> mm³`, `an intersection larger than its smallest operand`.
80
+ - **Cause:** The kernel's boolean returned geometry that violates the one property no boolean may (a union contains its inputs, a cut only removes, an intersection lies inside each input, a solid has non-negative volume) without reporting an error. On the exact kernel this follows a tangent or self-touching contact the coincidence guard could not recognise up front ([boolean-coincident-faces-hang](#boolean-coincident-faces-hang) covers the form it does refuse); on the mesh kernel it should never happen and would be a kernel bug worth reporting. The gate carries 1% slack on the largest operand, so volume-integration noise cannot trip it.
81
+ - **Fix:** The same menu as the coincidence guard's — genuine overlap or genuine clearance (0.05 mm or more) instead of exact contact, cut tools overcut past the faces they pierce, threads in the periodic `screwSweep` form or via `k.tappedBore`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Gotchas". A refusal is a throw, not a warning, on purpose: the alternative is the silently wrong part these rules exist to stop, and a live preview keeps its last good mesh on screen across a failed rebuild.
82
+
71
83
  ## chamfer-rescue-bisection
72
84
 
73
85
  - **Symptom:** `partforge: chamfer` warning saying the distance `over-ran the geometry — reduced to` a smaller one (or `has no valid distance`), with an attempt count and elapsed seconds, alongside slow builds.
@@ -574,6 +586,13 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
574
586
  - **Cause:** the thread was built as a thin sub-pitch helical sliver and unioned
575
587
  onto a core. OCCT's boolean fails on a near-self-touching swept operand and
576
588
  silently returns the other operand — or nothing — rather than throwing.
589
+ - **Detected:** since partforge 0.110 the boolean result gate refuses both
590
+ outcomes instead of shipping them — `boolean result invalid: union dropped an
591
+ operand` when the core comes back alone
592
+ ([boolean-dropped-operand](#boolean-dropped-operand)), `… produced an
593
+ impossible result` when nothing does
594
+ ([boolean-impossible-result](#boolean-impossible-result)). The fix below is
595
+ unchanged; the symptom is now a build error naming it.
577
596
  - **Fix:** build the thread in the **periodic** form instead — a profile spanning
578
597
  exactly one `pitch` with equal first and last radius encloses the axis, so
579
598
  `k.screwSweep` yields the whole threaded body with no boolean at all. See
@@ -487,6 +487,44 @@ exactly the old behavior. The same array also carries *job-level* notices that
487
487
  belong to no single sub-part — currently a font source refused by its control's
488
488
  `allow` list — as entries with `part: null`.
489
489
 
490
+ **Boolean result gate** (both backends, partforge 0.110, `boolean-gate.js`): every
491
+ author-facing boolean — `cut`, `cutAll`, `intersect`, `union` in both forms, and the
492
+ B-rep backend's internal tool fuse inside `cutAll` — is judged AFTER it runs, by
493
+ volume against its operands, and a result that violates the one property no boolean
494
+ may violate **throws** (`code: "BOOLEAN_RESULT_INVALID"`, message leading
495
+ `boolean result invalid:`): a union smaller than any operand, a cut larger than its
496
+ body, an intersection larger than its smallest operand, a negative volume. Each
497
+ inequality carries 1% of the operand it is compared AGAINST as slack (floored at
498
+ 1e-6 mm³) — never of the largest operand, which would switch the cut and intersect
499
+ rules off whenever the tool is the big one — so volume-integration noise can never
500
+ fire them. Two signatures the inequalities cannot decide are confirmed lazily, on that
501
+ signature only: first a free bounding-box enclosure test, then ONE extra intersect. A
502
+ union whose volume equals one operand's to float precision is refused when the other
503
+ operand has material outside it, judged against that operand's OWN size (the
504
+ documented silent failure — a thin thread ridge dropped, the core returned alone);
505
+ a cut that came back empty is refused when its tools cannot have covered the body. A
506
+ probe that fails is inconclusive, never a refusal, and so is a negative OPERAND (broken
507
+ input, not this boolean's doing). On the B-rep class the probe intersect runs through
508
+ the coincidence guard like every other boolean — it runs on exactly the pair that just
509
+ misbehaved — and a guard refusal counts as "nothing inside", which on the equal-volume
510
+ signature is the refusal the gate was about to make anyway rather than an unabortable
511
+ grind. A refusal is remembered by cache key, so a live edit does not re-pay the failing
512
+ boolean per rebuild. This is deliberately NOT the warnings channel: a skipped fillet is
513
+ an honest part minus a feature, whereas a union that lost its core is wrong, and the two
514
+ other provably-wrong cases in this contract (an empty `Shape2D` reaching `extrude`, the
515
+ coincidence guard) throw for the same reason; a refusal raised by a boolean INSIDE a
516
+ degrading feature (the mesh fillet's own cutters and fillers, a B-rep `safeOp`) is
517
+ caught by that feature's policy and reported as its skip, message included. Results are
518
+ judged once, before they enter the solid cache. A conforming mesh kernel is expected
519
+ never to trip it — the rule is stated for both classes so that a mesh-class refusal
520
+ reads as the kernel bug it would be; measured, the volume reads cost ~0.4% on a
521
+ hundred-cut chain there. Classification: additive, `CONTRACT_VERSION` stays 4. The
522
+ Versioning rule below counts "tightened validation that rejects previously valid
523
+ input" as breaking; this rejects input the kernel previously ACCEPTED but never built
524
+ correctly — every refused result is wrong geometry the author could not have wanted —
525
+ so no previously valid part changes, and a part it refuses was already broken on that
526
+ backend, with the breakage now named.
527
+
490
528
  ## Shape2D (2-D booleans)
491
529
 
492
530
  `k.shape2d(profile)` (`KERNEL_OPS`) lifts a point list, `{outer,
@@ -876,8 +914,8 @@ The recurring constraint: every op here is implementable on **both** a mesh-CSG
876
914
  and a B-rep kernel (see `docs/geometry-backend-strategy.md` for why that dual-backend
877
915
  property is worth protecting — OCCT booleans are ~75–1400× slower). Generation *safety*
878
916
  comes not from a restricted DSL but from the verify loop (`measure`/`verify` gates:
879
- `bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall` *warnings*, which
880
- report but never fail) — a generator gets machine-checkable
917
+ `bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall` and `overhangArea`
918
+ *warnings*, which report but never fail) — a generator gets machine-checkable
881
919
  pass/fail feedback per part, which a syntax could never provide.
882
920
 
883
921
  ## Conformance checklist for a new backend or host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.109.1",
3
+ "version": "0.111.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",
@@ -0,0 +1,200 @@
1
+ // Refuses a boolean whose RESULT is geometrically impossible — after the op ran.
2
+ //
3
+ // The failure this exists for (measured on real parts, and independently in the
4
+ // 2026-09 ModelRift CadQuery/OpenSCAD study): an exact B-rep kernel's boolean
5
+ // does not throw when it fails. It returns one operand instead of the union, an
6
+ // empty solid instead of the cut, or a negative-volume shape that its own
7
+ // validity check still calls valid — and every one of those ships as a STEP
8
+ // file, a preview, or a `measure` report that looks like a part. The
9
+ // coincidence guard (occt-coincidence.js) refuses the one degenerate
10
+ // CONSTRUCTION it can recognise before the op runs; this gate is the other
11
+ // half, judging the result by the one property no boolean may violate: its
12
+ // volume relative to its inputs.
13
+ //
14
+ // union ≥ every operand (a union contains its inputs)
15
+ // cut ≤ its body (a cut only removes)
16
+ // intersect ≤ its smallest operand (an intersection lies inside each input)
17
+ // any ≥ 0
18
+ //
19
+ // The inequalities are cheap — one volume read per solid, memoized by the
20
+ // backends — and each carries a slack of 1% of the operand it is compared
21
+ // AGAINST (floored at 1e-6 mm³), so B-spline volume integration noise can never
22
+ // fire them. Scaling the slack to the largest operand instead would switch the
23
+ // cut and intersect rules off whenever the tool is the big one, which the
24
+ // "overcut your tools" guidance makes routine. What the inequalities cannot
25
+ // catch is the exact documented signature of a dropped operand: a thin thread
26
+ // ridge unioned onto a core comes back as the core alone, which is LARGER than
27
+ // the ridge and so passes `union ≥ every operand`. That signature — the
28
+ // result's volume equals one operand's to float precision — is the one place
29
+ // the gate asks for more: first a bounding-box enclosure test (an operand whose
30
+ // box lies inside the other's cannot have lost material; free, and it settles
31
+ // the legitimate "boss buried in the plate" union without a boolean), then an
32
+ // overlap volume (`operands[i] ∩ operands[j]`, supplied lazily by the backend
33
+ // so the extra boolean only ever runs on this signature). If the other operand
34
+ // has material outside the equal one, the union lost it — judged against that
35
+ // operand's OWN size, so a thin ridge is not hidden behind the core's slack.
36
+ // The same probe decides a cut that came back EMPTY: impossible unless the
37
+ // tools covered the whole body. A probe that throws is inconclusive, never a
38
+ // refusal; so is a negative OPERAND, which is broken input rather than this
39
+ // boolean's doing (the backends latch orientation where they know to).
40
+ //
41
+ // This module is pure and backend-neutral on purpose: both kernels call it from
42
+ // inside their cached boolean closures (a cache hit is never re-judged), and
43
+ // test/boolean-gate.test.js pins every rule with plain numbers — the
44
+ // malfunctions it exists for have no deterministic fixture, since the
45
+ // reproducible one is now refused up front by the coincidence guard. Mesh CSG
46
+ // is exact arithmetic and is expected never to trip it; measured, the reads
47
+ // cost nothing there (~0.4% on a hundred-cut chain), and a mesh-class refusal
48
+ // would be a kernel bug worth seeing.
49
+ //
50
+ // A refusal THROWS rather than warning. The warnings channel is for honest
51
+ // degradation (a skipped fillet leaves a correct part minus a feature); a union
52
+ // that lost its core is wrong, not degraded, and the two existing precedents for
53
+ // provably wrong geometry — an empty Shape2D reaching `extrude`, and the
54
+ // coincidence guard — both throw with coaching. In the cloud app that throw lands
55
+ // in the export dialog's "Ask the assistant to fix it" path with the message
56
+ // forwarded verbatim, and a live preview keeps its last good mesh on screen.
57
+ // A refusal that reaches a boolean INSIDE a degrading feature (the mesh fillet's
58
+ // own cutters and fillers, a B-rep safeOp) is caught there and reported as that
59
+ // feature's skip, message included — the feature is what failed, and the part
60
+ // minus it is still honest.
61
+ import { BooleanResultError } from "./errors.js";
62
+
63
+ export const BOOLEAN_RESULT_INVALID = "BOOLEAN_RESULT_INVALID";
64
+
65
+ // Slack on the inequalities, relative to the operand being compared against and
66
+ // floored absolutely. 1% is far above any volume-integration error either kernel
67
+ // produces (OCCT's GProp on spline faces; Manifold's exact triangle sum) and far
68
+ // below any real failure (the measured cases were 30% and 100% off).
69
+ const REL_TOL = 1e-2;
70
+ const ABS_TOL = 1e-6; // mm³
71
+ const tolOf = (v) => Math.max(ABS_TOL, REL_TOL * v);
72
+ // "Equals one operand exactly": a returned-unchanged operand reports the identical
73
+ // number; a genuine union that added less than this is physically nothing.
74
+ const EQUAL_REL = 1e-9;
75
+
76
+ const fmt = (v) => Number(v.toPrecision(6)).toString();
77
+
78
+ const IMPOSSIBLE_COACHING =
79
+ "The kernel's boolean returned broken geometry without reporting an error, so the build was " +
80
+ "refused rather than shipping it. This happens on an exact B-rep kernel when operands touch " +
81
+ "tangentially or a swept operand nearly touches itself; give the surfaces genuine overlap or " +
82
+ "genuine clearance (0.05 or more) instead of an exact contact, overcut tools past the faces " +
83
+ "they pierce, and build threads in the periodic screwSweep form or with k.tappedBore. " +
84
+ "See ERROR-PATTERNS.md#boolean-impossible-result.";
85
+
86
+ const DROPPED_COACHING =
87
+ "The kernel's boolean returned one input instead of the union, without reporting an error — " +
88
+ "usually a thin or near-self-touching swept operand (a sub-pitch thread ridge riding a core). " +
89
+ "Give the operands genuine overlap instead of a tangent contact; build a thread in the periodic " +
90
+ "screwSweep form so it needs no union, or a tapped hole with k.tappedBore. " +
91
+ "See ERROR-PATTERNS.md#boolean-dropped-operand.";
92
+
93
+ const impossible = (label, detail) =>
94
+ new BooleanResultError(`boolean result invalid: ${label} produced an impossible result — ${detail} ${IMPOSSIBLE_COACHING}`);
95
+ const dropped = (label, detail) =>
96
+ new BooleanResultError(`boolean result invalid: ${label} dropped an operand — ${detail} ${DROPPED_COACHING}`);
97
+
98
+ // Probe `operands[i] ∩ operands[j]`'s volume through the backend's thunk; null when
99
+ // the probe itself fails (an inconclusive answer must not become a refusal).
100
+ function overlapOf(overlap, i, j) {
101
+ try {
102
+ const v = overlap(i, j);
103
+ return Number.isFinite(v) ? Math.max(0, v) : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // Cheap enclosure pre-test: true only when the backend can say from bounding
110
+ // boxes alone that operand j lies inside operand i. Absent or throwing = unknown.
111
+ function enclosedBy(encloses, i, j) {
112
+ try { return encloses?.(i, j) === true; } catch { return false; }
113
+ }
114
+
115
+ /**
116
+ * Judge a boolean's result by volume.
117
+ *
118
+ * @param {"union"|"cut"|"cutAll"|"intersect"} op
119
+ * @param {number[]} operands volumes; for cut/cutAll index 0 is the body, the rest tools
120
+ * @param {number} result the result's volume
121
+ * @param {{ overlap: (i: number, j: number) => number, encloses?: (i: number, j: number) => boolean }} probes
122
+ * `overlap` lazily computes the volume of `operands[i] ∩ operands[j]`; consulted only
123
+ * on the two signatures the inequalities cannot decide, and allowed to throw (treated
124
+ * as inconclusive). `encloses(i, j)` answers "does operand i's bounding box contain
125
+ * operand j's" — when true the overlap is never asked for.
126
+ * @param {string} [label=op] how the message names the op — a backend that fuses
127
+ * cutAll's tools before cutting judges that fuse as a `union` labelled `cutAll (tools)`
128
+ * @returns {BooleanResultError|null}
129
+ */
130
+ export function checkBooleanResult(op, operands, result, probes, label = op) {
131
+ if (operands.length < 2) return null;
132
+ if (!Number.isFinite(result) || !operands.every(Number.isFinite)) return null;
133
+ if (operands.some((v) => v < 0)) return null; // broken input, not this boolean's result
134
+ const { overlap, encloses } = probes ?? {};
135
+ const largest = Math.max(...operands);
136
+
137
+ if (result < -tolOf(largest)) {
138
+ return impossible(label, `a negative volume (${fmt(result)} mm³).`);
139
+ }
140
+
141
+ if (op === "union") {
142
+ if (result < largest - tolOf(largest)) {
143
+ return impossible(label, `a union smaller than its largest operand (${fmt(result)} mm³ vs ${fmt(largest)} mm³).`);
144
+ }
145
+ // The dropped-operand signature: the result IS one operand. Every other operand
146
+ // with volume must then lie inside it, or the union lost material.
147
+ const i = operands.findIndex((v) => v > ABS_TOL && Math.abs(result - v) <= EQUAL_REL * v);
148
+ if (i < 0) return null;
149
+ for (let j = 0; j < operands.length; j++) {
150
+ const vj = operands[j];
151
+ if (j === i || vj <= ABS_TOL || enclosedBy(encloses, i, j)) continue;
152
+ const inside = overlapOf(overlap, i, j);
153
+ if (inside === null) continue;
154
+ const outside = vj - inside;
155
+ if (outside > tolOf(vj)) {
156
+ return dropped(label,
157
+ `the result's volume (${fmt(result)} mm³) equals operand ${i}'s exactly, but operand ${j} ` +
158
+ `(${fmt(vj)} mm³) has ${fmt(outside)} mm³ of material outside it that the union lost.`);
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ if (op === "cut" || op === "cutAll") {
165
+ const body = operands[0];
166
+ const tol = tolOf(body);
167
+ if (result > body + tol) {
168
+ return impossible(label, `a cut larger than its body (${fmt(result)} mm³ vs ${fmt(body)} mm³).`);
169
+ }
170
+ if (result <= tol && body > tol) {
171
+ // Empty result: only possible when the tools covered the whole body. A tool
172
+ // whose box encloses the body's settles it; otherwise each tool's overlap is
173
+ // summed — an OVER-estimate of coverage (tools may overlap each other), so
174
+ // this refuses only when even that cannot reach the body's volume.
175
+ let covered = 0;
176
+ for (let j = 1; j < operands.length; j++) {
177
+ if (operands[j] <= ABS_TOL) continue;
178
+ if (enclosedBy(encloses, j, 0)) return null;
179
+ const inside = overlapOf(overlap, 0, j);
180
+ if (inside === null) return null; // inconclusive
181
+ covered += inside;
182
+ }
183
+ if (covered < body - tol) {
184
+ return impossible(label,
185
+ `a cut that emptied its body although the tools cover at most ${fmt(covered)} mm³ of its ${fmt(body)} mm³.`);
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ if (op === "intersect") {
192
+ const smallest = Math.min(...operands);
193
+ if (result > smallest + tolOf(smallest)) {
194
+ return impossible(label, `an intersection larger than its smallest operand (${fmt(result)} mm³ vs ${fmt(smallest)} mm³).`);
195
+ }
196
+ return null;
197
+ }
198
+
199
+ return null;
200
+ }
@@ -8,3 +8,14 @@ export class KernelCapabilityError extends Error {
8
8
  this.code = "NEEDS_OCCT";
9
9
  }
10
10
  }
11
+
12
+ // Thrown by the boolean result gate (boolean-gate.js) when a boolean's result is
13
+ // geometrically impossible — the kernel returned wrong geometry without reporting
14
+ // it. Not a capability gap: nothing reroutes on it. `.code` is the stable handle.
15
+ export class BooleanResultError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "BooleanResultError";
19
+ this.code = "BOOLEAN_RESULT_INVALID";
20
+ }
21
+ }
@@ -18,6 +18,7 @@ import { creasedNormals } from "./creased-normals.js";
18
18
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
19
19
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
20
20
  import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
21
+ import { checkBooleanResult } from "./boolean-gate.js";
21
22
  import { KernelCapabilityError } from "./errors.js";
22
23
  import { heightfieldMesh, hashGridData } from "./heightfield.js";
23
24
 
@@ -55,6 +56,42 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
55
56
  // while Manifold's own batch operator evaluates the same union as a balanced tree.
56
57
  // A one-solid "union" returns the operand's own Manifold untouched (see union below).
57
58
  const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
59
+ // Boolean result gate (boolean-gate.js): every author-facing boolean's result is
60
+ // judged by volume against its operands before it enters the cache, and an
61
+ // impossible result throws instead of shipping. Mesh CSG is exact arithmetic,
62
+ // so this backend is expected never to trip it — it is here because the gate
63
+ // is one kernel-contract rule, not an OCCT special case, and a refusal on this
64
+ // backend would itself be a kernel bug worth seeing. Measured at ~0.4% on a
65
+ // hundred-cut chain: `volume()` forces the lazy CSG node, but chained booleans
66
+ // were never batched in this binding, so nothing is lost to the read. Volumes
67
+ // are memoized per Manifold object (a result judged here is an operand a step
68
+ // later). The probes run only on the dropped-operand / emptied-body
69
+ // signatures: an enclosure test off bounding boxes first, and an intersect
70
+ // whose result is freed as soon as its one number is read — NOT tracked, so
71
+ // an n-ary union of buried features does not hold n intermediates to cleanup.
72
+ const volumes = new WeakMap();
73
+ const volumeOf = (mm) => {
74
+ let v = volumes.get(mm);
75
+ if (v === undefined) { v = mm.volume(); volumes.set(mm, v); }
76
+ return v;
77
+ };
78
+ const BOX_SLACK = 1e-3; // mm — an enclosure test, not a measurement
79
+ const judged = (op, operandMs, resultM) => {
80
+ const box = (mm) => mm.boundingBox();
81
+ const err = checkBooleanResult(op, operandMs.map(volumeOf), volumeOf(resultM), {
82
+ overlap: (i, j) => {
83
+ const x = operandMs[i].intersect(operandMs[j]);
84
+ try { return x.volume(); } finally { x.delete?.(); }
85
+ },
86
+ encloses: (i, j) => {
87
+ const a = box(operandMs[i]), b = box(operandMs[j]);
88
+ return [0, 1, 2].every((k) => b.min[k] >= a.min[k] - BOX_SLACK && b.max[k] <= a.max[k] + BOX_SLACK);
89
+ },
90
+ });
91
+ if (err) throw err;
92
+ return resultM;
93
+ };
94
+ const judgedUnion = (ms) => judged("union", ms, unionRaw(ms));
58
95
 
59
96
  const cache = createSolidCache();
60
97
  // Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
@@ -348,7 +385,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
348
385
  _m: m,
349
386
  _hash: hash,
350
387
  _canon: canon,
351
- cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
388
+ cut: (t) => cached(h("cut", hash, t._hash), () => judged("cut", [m, t._m], T(m.subtract(t._m)))),
352
389
  // THROWING forms. These are the composition primitives — internal callers
353
390
  // that have their own recovery (prismRoundAllFast, which answers a failed
354
391
  // fillet by falling back to the reference Minkowski roundAll) must use
@@ -411,10 +448,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
411
448
  },
412
449
  // batch difference: first minus the union of the rest, evaluated as one boolean
413
450
  // tree — no materialized intermediate union (the unionRaw memory note applies)
414
- cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
415
- () => T(Manifold.difference([m, ...tools.map((t) => t._m)]))),
416
- intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
417
- union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
451
+ cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)), () => {
452
+ const ms = [m, ...tools.map((t) => t._m)];
453
+ return judged("cutAll", ms, T(Manifold.difference(ms)));
454
+ }),
455
+ intersect: (t) => cached(h("intersect", hash, t._hash), () => judged("intersect", [m, t._m], T(m.intersect(t._m)))),
456
+ union: (t) => cached(h("union", [hash, t._hash]), () => judged("union", [m, t._m], unionRaw([m, t._m]))),
418
457
  clone: () => wrap(m, hash),
419
458
  // Name this solid's surface for hover/pick feature attribution. asOriginal()
420
459
  // stamps a fresh originalID that survives transforms and booleans, so every
@@ -724,8 +763,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
724
763
  // would pin one WASM object under two entries and eviction would dispose it twice.
725
764
  union: (solids) => solids.length === 1
726
765
  ? solids[0]
727
- : hoistBoolean("union", solids, (ops) => unionRaw(ops.map((s) => s._m)))
728
- ?? cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
766
+ : hoistBoolean("union", solids, (ops) => judgedUnion(ops.map((s) => s._m)))
767
+ ?? cached(h("union", solids.map((s) => s._hash)), () => judgedUnion(solids.map((s) => s._m))),
729
768
  // Imported geometry, registered pre-build by the framework via `_registerImport`
730
769
  // (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
731
770
  // see `imports` above); wrap() is free, so every call is cheap.