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 +22 -3
- package/docs/AUTHORING-PARTS.md +68 -12
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +40 -2
- package/package.json +1 -1
- package/src/framework/geometry/boolean-gate.js +200 -0
- package/src/framework/geometry/errors.js +11 -0
- package/src/framework/geometry/manifold-backend.js +46 -7
- package/src/framework/geometry/occt-backend.js +91 -17
- package/src/framework/lint/rules-verify.js +17 -1
- package/src/framework/oracle/dfm-profiles.js +45 -5
- package/src/framework/oracle/gates.js +10 -1
- package/src/framework/oracle/measure.js +27 -1
- package/src/framework/oracle/overhang.js +111 -0
- package/src/framework/oracle/verify.js +73 -8
- package/src/framework/verify-metrics.js +8 -0
- package/src/oracle.js +1 -0
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +20 -0
- package/types/testing.d.ts +46 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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,
|
|
2997
|
-
whatever the verdict.
|
|
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)
|
|
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
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|
|
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
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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`
|
|
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
|
@@ -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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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) =>
|
|
728
|
-
?? cached(h("union", solids.map((s) => s._hash)), () =>
|
|
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.
|