partforge 0.109.0 → 0.110.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.
@@ -3456,6 +3456,16 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3456
3456
  For the case that causes this most often — a tapped hole — reach for
3457
3457
  `k.tappedBore`, which owns the bore and the thread together and cannot land them
3458
3458
  on the same face.
3459
+ - **A boolean that comes back geometrically impossible is a build error, not a
3460
+ part.** Every `cut`/`cutAll`/`intersect`/`union` result is judged by volume against
3461
+ its operands on both backends: a union smaller than an input, a cut that grew, a
3462
+ negative volume, or the exact kernel's silent "returned one operand instead of the
3463
+ union" all throw `boolean result invalid: …` with the fix menu above instead of
3464
+ shipping a wrong preview or STEP file
3465
+ ([boolean-dropped-operand](ERROR-PATTERNS.md#boolean-dropped-operand),
3466
+ [boolean-impossible-result](ERROR-PATTERNS.md#boolean-impossible-result)). A
3467
+ legitimately degenerate design — a hole wider than its plate — is not impossible and
3468
+ builds as before; `verify` is what catches that.
3459
3469
 
3460
3470
  ---
3461
3471
 
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.109.0",
3
+ "version": "0.110.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",
@@ -96,6 +96,8 @@
96
96
  "test": "vitest run",
97
97
  "test:watch": "vitest",
98
98
  "typecheck": "tsc -p tsconfig.json",
99
+ "lint": "eslint .",
100
+ "lint:dead": "knip",
99
101
  "check": "node scripts/check-app.mjs",
100
102
  "offset-rates": "node scripts/offset-rates.mjs"
101
103
  },
@@ -120,9 +122,13 @@
120
122
  "three": "^0.184.0"
121
123
  },
122
124
  "devDependencies": {
125
+ "@eslint/js": "^10.0.1",
123
126
  "@fontsource-variable/geist": "^5.3.0",
124
127
  "@fontsource-variable/geist-mono": "^5.3.0",
128
+ "eslint": "^10.10.0",
129
+ "globals": "^17.12.0",
125
130
  "happy-dom": "^20.10.6",
131
+ "knip": "^6.35.1",
126
132
  "playwright": "^1.49.0",
127
133
  "typescript": "^5.9.3",
128
134
  "vite": "^8.0.16",
@@ -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.
@@ -16,6 +16,7 @@
16
16
  // Ops that need the real B-rep (booleans, fillet/chamfer/shell, exports,
17
17
  // volume, boundingBox) materialize the pending pose through replicad first.
18
18
  import { assertNoCoincidentBoolean } from "./occt-coincidence.js";
19
+ import { checkBooleanResult } from "./boolean-gate.js";
19
20
  import { toEdgeFinder } from "./edge-selector.js";
20
21
  import { toFaceFinder } from "./face-selector.js";
21
22
  import { addSugar } from "./solid-sugar.js";
@@ -128,6 +129,66 @@ export function createOcctKernel(replicad) {
128
129
  // see occt-repair.js for the policies and why they differ per op.
129
130
  const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
130
131
 
132
+ // Boolean result gate (boolean-gate.js): the coincidence guard above refuses the
133
+ // one degenerate CONSTRUCTION it can recognise before a boolean runs; this judges
134
+ // the RESULT afterwards, by volume, and throws on the impossible — a union
135
+ // smaller than an input, a cut that grew, a negative volume, and the documented
136
+ // silent failure where OCCT hands back one operand instead of the union.
137
+ // `judge(op, shapes, run)` owns the ordering: operand volumes are read BEFORE
138
+ // `run` (replicad consumes what it fuses/cuts, so a fused tool measured
139
+ // afterwards would be a deleted shape), the result's after; both are memoized
140
+ // per replicad shape, since a judged result is the next op's operand. A
141
+ // one-operand call runs unjudged and unmeasured (the gate has nothing to
142
+ // compare). The probes run only on the signatures the inequalities cannot
143
+ // decide: an enclosure test off bounding boxes first — a padded B-spline box
144
+ // can only make enclosure HARDER to prove, the safe direction — then one
145
+ // intersect on clones, freed at once. That intersect goes through the
146
+ // coincidence guard like every other boolean here, because it runs on exactly
147
+ // the operand pair that just misbehaved: a refused pair answers "nothing
148
+ // inside", which on the equal-volume signature is the refusal the gate was
149
+ // about to make anyway rather than a grind the WASM build cannot abort. A
150
+ // refusal is remembered by cache key so a live edit does not re-pay the failing
151
+ // boolean and its probe on every rebuild, and the refused shape is freed.
152
+ const volumes = new WeakMap();
153
+ const volumeOf = (s) => {
154
+ let v = volumes.get(s);
155
+ if (v === undefined) { v = measureVolume(s); volumes.set(s, v); }
156
+ return v;
157
+ };
158
+ const BOX_SLACK = 1e-3; // mm — an enclosure test, not a measurement
159
+ const encloses = (a, b) => {
160
+ const [amin, amax] = a.boundingBox.bounds, [bmin, bmax] = b.boundingBox.bounds;
161
+ return [0, 1, 2].every((k) => bmin[k] >= amin[k] - BOX_SLACK && bmax[k] <= amax[k] + BOX_SLACK);
162
+ };
163
+ const judge = (op, shapes, run, label = op) => {
164
+ if (shapes.length < 2) return run();
165
+ const operandVolumes = shapes.map(volumeOf);
166
+ const result = run();
167
+ const err = checkBooleanResult(op, operandVolumes, volumeOf(result), {
168
+ encloses: (i, j) => encloses(shapes[i], shapes[j]),
169
+ overlap: (i, j) => {
170
+ try { guardBoolean("intersect", [shapes[i], shapes[j]]); }
171
+ catch (e) { if (e?.code === "COINCIDENT_BOOLEAN") return 0; throw e; }
172
+ let x;
173
+ try { x = shapes[i].clone().intersect(shapes[j].clone()); return measureVolume(x); }
174
+ finally { x?.delete?.(); }
175
+ },
176
+ }, label);
177
+ if (err) { result.delete?.(); throw err; }
178
+ return result;
179
+ };
180
+ const refusals = new Map();
181
+ const judgedCache = (key, make) => {
182
+ const remembered = refusals.get(key);
183
+ if (remembered) throw remembered;
184
+ return cached(key, () => {
185
+ try { return make(); } catch (e) {
186
+ if (e?.code === "BOOLEAN_RESULT_INVALID") refusals.set(key, e);
187
+ throw e;
188
+ }
189
+ });
190
+ };
191
+
131
192
  // name -> { shape, digest } | { error, digest } — imported geometry the framework
132
193
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
133
194
  // solid cache: imports are the framework's own memo, keyed by name+digest).
@@ -228,26 +289,32 @@ export function createOcctKernel(replicad) {
228
289
  },
229
290
  cut: (t) => {
230
291
  const key = h("cut", hash, t._hash);
231
- return cached(key, () => {
292
+ return judgedCache(key, () => {
232
293
  const a = mat(), b = t._mat();
233
294
  guardBoolean("cut", [a._s, b._s]);
234
- return wrap(a._s.clone().cut(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
295
+ const result = judge("cut", [a._s, b._s], () => a._s.clone().cut(b._s.clone()));
296
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
235
297
  });
236
298
  },
237
299
  cutAll: (tools) => {
238
300
  const key = h("cutAll", hash, tools.map((t) => t._hash));
239
- return cached(key, () => {
301
+ return judgedCache(key, () => {
240
302
  const a = mat(), bs = tools.map((t) => t._mat());
241
303
  if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
304
+ const toolShapes = bs.map((b) => b._s);
242
305
  // All pairs, tools included: the cut below first fuses the tools
243
306
  // together, so tool-to-tool contact hangs exactly like target-to-tool
244
307
  // (the measured case WAS two tools — a bore and its thread).
245
- guardBoolean("cutAll", [a._s, ...bs.map((b) => b._s)]);
246
- const fusedTools = bs
247
- .slice(1)
248
- .reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
308
+ guardBoolean("cutAll", [a._s, ...toolShapes]);
309
+ // The tool fuse is a union in its own right — the bore + thread case
310
+ // fails HERE (the thread dropped, the cut then honestly removes a plain
311
+ // bore), so it is judged as one before the cut is.
312
+ const fusedTools = judge("union", toolShapes,
313
+ () => bs.slice(1).reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone()),
314
+ "cutAll (tools)");
315
+ const result = judge("cutAll", [a._s, ...toolShapes], () => a._s.clone().cut(fusedTools));
249
316
  return wrap(
250
- a._s.clone().cut(fusedTools),
317
+ result,
251
318
  [...cloneLabels(a._labels), ...bs.flatMap((b) => cloneLabels(b._labels))],
252
319
  key,
253
320
  );
@@ -255,18 +322,20 @@ export function createOcctKernel(replicad) {
255
322
  },
256
323
  intersect: (t) => {
257
324
  const key = h("intersect", hash, t._hash);
258
- return cached(key, () => {
325
+ return judgedCache(key, () => {
259
326
  const a = mat(), b = t._mat();
260
327
  guardBoolean("intersect", [a._s, b._s]);
261
- return wrap(a._s.clone().intersect(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
328
+ const result = judge("intersect", [a._s, b._s], () => a._s.clone().intersect(b._s.clone()));
329
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
262
330
  });
263
331
  },
264
332
  union: (t) => {
265
333
  const key = h("union", [hash, t._hash]);
266
- return cached(key, () => {
334
+ return judgedCache(key, () => {
267
335
  const a = mat(), b = t._mat();
268
336
  guardBoolean("union", [a._s, b._s]);
269
- return wrap(a._s.clone().fuse(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
337
+ const result = judge("union", [a._s, b._s], () => a._s.clone().fuse(b._s.clone()));
338
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
270
339
  });
271
340
  },
272
341
  clone: () => wrap(shape.clone(), cloneLabels(labels), hash, pose, baseHash),
@@ -343,7 +412,7 @@ export function createOcctKernel(replicad) {
343
412
  return wrap(safeOp(a._s.clone(), (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(a._labels), key);
344
413
  });
345
414
  },
346
- volume: () => measureVolume(mat()._s),
415
+ volume: () => volumeOf(mat()._s), // shares the gate's memo — a judged result is already measured
347
416
  // Same default as toSTL: an export is an export, so a .3mf must not ship a
348
417
  // coarser tessellation than the .stl of the same solid would.
349
418
  toIndexedMesh: ({ quality = "print" } = {}) => {
@@ -643,11 +712,11 @@ export function createOcctKernel(replicad) {
643
712
  sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
644
713
  union: (solids) => {
645
714
  const key = h("union", solids.map((s) => s._hash));
646
- return cached(key, () => {
715
+ return judgedCache(key, () => {
647
716
  const ms = solids.map((s) => s._mat());
648
717
  guardBoolean("union", ms.map((m) => m._s));
649
718
  return wrap(
650
- ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
719
+ judge("union", ms.map((m) => m._s), () => ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b))),
651
720
  ms.flatMap((m) => cloneLabels(m._labels)),
652
721
  key,
653
722
  );
@@ -661,10 +730,15 @@ export function createOcctKernel(replicad) {
661
730
  // Same cache key as union — the geometry is identical either way.
662
731
  _trustedUnion: (solids) => {
663
732
  const key = h("union", solids.map((s) => s._hash));
664
- return cached(key, () => {
733
+ return judgedCache(key, () => {
665
734
  const ms = solids.map((s) => s._mat());
735
+ // Trusted skips the PRE-check only; the result is judged like any union —
736
+ // the volume gate is precisely what proves the trusted composition worked.
737
+ // Labelled for what it is: the author never wrote this union, so a refusal
738
+ // here means the framework's own audited composition broke on this kernel.
666
739
  return wrap(
667
- ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
740
+ judge("union", ms.map((m) => m._s), () => ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
741
+ "k.tappedBore's bore ∪ thread union (the framework's own composition — report this)"),
668
742
  ms.flatMap((m) => cloneLabels(m._labels)),
669
743
  key,
670
744
  );
@@ -13,8 +13,6 @@
13
13
  // Main-thread only, and deliberately import-free beyond what a lookup needs: it
14
14
  // runs during panel construction, on every rebuild.
15
15
 
16
- const arr = (v) => (Array.isArray(v) ? v : []);
17
-
18
16
  // `images`/`vectors`/`fonts` may each be a plain map or a function of params —
19
17
  // the function form is what a control drives. Resolving it can throw (it is
20
18
  // author code running on every panel build), and a broken declaration must not
@@ -5,7 +5,6 @@
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { byteAwareReplacer } from "./geometry/solid-hash.js";
7
7
  import { viewSubParts } from "./part-model.js";
8
- import { resolveDerived } from "./derive.js";
9
8
 
10
9
  export const RELEVANT_ALL = Symbol("relevant-all");
11
10
 
@@ -22,10 +22,7 @@ import {
22
22
  isLoopbackOrigin, isLoopbackHost,
23
23
  } from "./endpoint.js";
24
24
 
25
- export {
26
- PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
27
- PICK_SERVER_DEFAULT_URL,
28
- } from "./endpoint.js";
25
+ export { PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS } from "./endpoint.js";
29
26
 
30
27
  const MAX_BODY_BYTES = 256 * 1024; // no route needs more; anything bigger is abuse
31
28
  const MAX_SELECTION_CHARS = 16 * 1024;
@@ -3,7 +3,6 @@
3
3
  // running twist plus an alternating half-facet offset, so the facets zig-zag into a
4
4
  // woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
5
5
  // backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
6
- import { regularPolygon } from "partforge/geometry";
7
6
 
8
7
  const RINGS = 28; // silhouette resolution (ring count up the height)
9
8