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.
- package/docs/AUTHORING-PARTS.md +10 -0
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +38 -0
- package/package.json +7 -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/panel/declared-source.js +0 -2
- package/src/framework/param-deps.js +0 -1
- package/src/framework/pick-request/server.js +1 -4
- package/src/parts/faceted-vase.js +0 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "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
|
-
|
|
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.
|
|
@@ -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
|
|
292
|
+
return judgedCache(key, () => {
|
|
232
293
|
const a = mat(), b = t._mat();
|
|
233
294
|
guardBoolean("cut", [a._s, b._s]);
|
|
234
|
-
|
|
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
|
|
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, ...
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
325
|
+
return judgedCache(key, () => {
|
|
259
326
|
const a = mat(), b = t._mat();
|
|
260
327
|
guardBoolean("intersect", [a._s, b._s]);
|
|
261
|
-
|
|
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
|
|
334
|
+
return judgedCache(key, () => {
|
|
267
335
|
const a = mat(), b = t._mat();
|
|
268
336
|
guardBoolean("union", [a._s, b._s]);
|
|
269
|
-
|
|
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: () =>
|
|
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
|
|
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
|
|
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
|
|