partforge 0.60.1 → 0.61.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 +16 -0
- package/docs/ERROR-PATTERNS.md +2 -2
- package/docs/KERNEL-CONTRACT.md +16 -8
- package/package.json +1 -1
- package/src/app-mixed-smoke.js +9 -0
- package/src/framework/backend-select.js +70 -11
- package/src/framework/geometry/occt-backend.js +4 -0
- package/src/framework/geometry/op-options.js +16 -2
- package/src/framework/geometry/probe.js +11 -4
- package/src/framework/geometry/solid-sugar.js +10 -3
- package/src/framework/jobs.js +3 -1
- package/src/framework/mount.js +40 -11
- package/src/framework/regen-loop.js +13 -9
- package/src/mixed-smoke-worker.js +3 -0
- package/src/parts/mixed-smoke.js +36 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2052,6 +2052,22 @@ OCCT: rounding a profile before extruding keeps you on fast Manifold. Force the
|
|
|
2052
2052
|
`meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
|
|
2053
2053
|
entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
|
|
2054
2054
|
|
|
2055
|
+
The probe re-runs with the **live parameters on every regen**, and routing works in both
|
|
2056
|
+
directions: turn a fillet on and the part moves to OCCT; dial it back to 0 (or take the
|
|
2057
|
+
branch that skips it) and the part drops back to Manifold automatically. A zero magnitude
|
|
2058
|
+
— `fillet(0)`, `chamfer({ d: 0 })` — is the **identity** on both backends (see
|
|
2059
|
+
KERNEL-CONTRACT.md), so an unguarded `s.fillet(p.r)` needs no `if (p.r > 0)` wrapper to
|
|
2060
|
+
get the fast preview back when the slider hits 0. (`shell` is the exception: `t: 0` is
|
|
2061
|
+
degenerate, not identity, so a shell call always routes to OCCT.)
|
|
2062
|
+
|
|
2063
|
+
**Preview routing is per sub-part.** Each sub-part is probed and routed independently, and
|
|
2064
|
+
a mixed part's regen fans out to both workers in parallel — a filleted body pays for OCCT
|
|
2065
|
+
while a plain lid rebuilds at Manifold speed beside it. This makes it worth isolating a
|
|
2066
|
+
CAD-op solid in its own sub-part rather than folding it into a bigger build. Two scopes
|
|
2067
|
+
still route whole-part (the max over the sub-parts): **exports** (one STL/STEP/3MF job
|
|
2068
|
+
builds everything in one worker) and the **CLI** (a single Node process boots exactly one
|
|
2069
|
+
kernel). Within one sub-part's build there is no per-op backend mixing.
|
|
2070
|
+
|
|
2055
2071
|
**Shading intent.** The kernel decides what shades smooth and where edge lines
|
|
2056
2072
|
draw — spheres, cylinders and fillets are smooth by construction; boolean cut
|
|
2057
2073
|
seams always shade hard and draw a line; a loft's facets shade flat when its
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -52,8 +52,8 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
52
52
|
## probe-routed-to-occt
|
|
53
53
|
|
|
54
54
|
- **Symptom:** A part builds far slower than expected (preview takes seconds instead of milliseconds), and the worker logs show it running on the `occt` worker.
|
|
55
|
-
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to
|
|
56
|
-
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). If the rounding is on a 2-D profile, `Shape2D.fillet` before extruding keeps the part on Manifold. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
|
|
55
|
+
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes that sub-part to OCCT (preview routing is per sub-part; exports and the CLI route the whole part to the max over its sub-parts, since those jobs run in one worker/kernel). (`Shape2D.fillet`/`.chamfer` are the shared pure-JS implementation and do **not** route; the probe tracks which handle kind an op ran on. A magnitude that is provably `0` — `fillet(0)`, `chamfer({d: 0})` — is the identity and does not route either, so a fillet param dialed to 0 reverts the part to Manifold with no guard; a magnitude the probe can't prove zero — e.g. computed from a dummy query value — routes conservatively.)
|
|
56
|
+
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). If the rounding is on a 2-D profile, `Shape2D.fillet` before extruding keeps the part on Manifold. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)". (Routing re-runs with live params on every regen — even after a runtime `needs-occt` reroute, which pins OCCT only until the params next change — so a stale backend choice never outlives a parameter edit.)
|
|
57
57
|
|
|
58
58
|
## fillet-chamfer-many-edges-slow
|
|
59
59
|
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -33,10 +33,16 @@ carries the conformance semantics and that one the usage guidance;
|
|
|
33
33
|
**Core class.** A conforming core kernel implements every op in `KERNEL_OPS` and
|
|
34
34
|
every `Solid` op in `SOLID_OPS`, *except* that the B-rep ops (`fillet`, `chamfer`,
|
|
35
35
|
`shell` — the `OCCT_ONLY_OPS` list — and `toSTEP`) may instead throw
|
|
36
|
-
`KernelCapabilityError`.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
`KernelCapabilityError`. Exception to the exception: `fillet(0)` / `chamfer({d: 0})`
|
|
37
|
+
(a magnitude that is exactly the number `0`, either calling convention) is the
|
|
38
|
+
**identity** on every class — it returns the solid unchanged and must not throw, so a
|
|
39
|
+
parametric radius dialed to 0 builds on a core kernel with no guard in the part.
|
|
40
|
+
`shell` has no identity form (`t: 0` means zero-thickness walls — degenerate, not
|
|
41
|
+
identity) and always throws on core. The in-repo Manifold backend is the reference
|
|
42
|
+
core kernel. Kernels built from this repo get the stubs for free: `addSugar()`
|
|
43
|
+
generates the Solid-level stubs (including the zero-magnitude identity) from
|
|
44
|
+
`OCCT_ONLY_OPS`, and `finishKernel()` stubs `toSTEP` (a kernel-level op, so it is
|
|
45
|
+
not in that Solid-op list).
|
|
40
46
|
|
|
41
47
|
**B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
|
|
42
48
|
OCCT/replicad backend is the reference.
|
|
@@ -51,10 +57,12 @@ have gone unbuilt for three consecutive rebinds.
|
|
|
51
57
|
|
|
52
58
|
`KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
|
|
53
59
|
probe (`probe.js`) runs `build` against a fake kernel, and any use of an `OCCT_ONLY_OPS`
|
|
54
|
-
op **on a Solid handle** routes the
|
|
60
|
+
op **on a Solid handle** routes the build to a B-rep-class kernel (the probe tracks
|
|
55
61
|
handle kinds, so the same names on a `Shape2D` — shared pure JS, backend-identical — do
|
|
56
|
-
not route).
|
|
57
|
-
|
|
62
|
+
not route). Routing granularity is a host choice: the in-repo framework routes preview
|
|
63
|
+
builds per sub-part (each sub-part builds wholly on one kernel) and exports/CLI whole-
|
|
64
|
+
part; a single-kernel host routes everything whole-part. A host with only a core kernel
|
|
65
|
+
must surface the error ("this part needs a B-rep backend") rather than swallow it.
|
|
58
66
|
|
|
59
67
|
## Global semantics
|
|
60
68
|
|
|
@@ -266,7 +274,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
266
274
|
| `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends — see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
|
|
267
275
|
| `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
|
|
268
276
|
| `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
|
|
269
|
-
| `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
|
|
277
|
+
| `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`), *except* a zero magnitude — `fillet(0)` / `chamfer({d: 0})` — which is the identity on every class (returns the solid unchanged, never throws; `shell` excluded, `t: 0` is degenerate). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
|
|
270
278
|
|
|
271
279
|
`quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
|
|
272
280
|
speed and a backend may bake it at kernel creation (Manifold does). A part must never
|
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import part from "./parts/mixed-smoke.js";
|
|
2
|
+
import { mount } from "./framework/index.js";
|
|
3
|
+
|
|
4
|
+
// Dev/CI-only app for the mixed-backend smoke fixture — see parts/mixed-smoke.js.
|
|
5
|
+
// Handle stashed for scripts/check-app.mjs, same as the other smoke apps.
|
|
6
|
+
window.__pfRuntime = mount(part, {
|
|
7
|
+
createWorker: (name) =>
|
|
8
|
+
new Worker(new URL("./mixed-smoke-worker.js", import.meta.url), { type: "module", name }),
|
|
9
|
+
});
|
|
@@ -2,27 +2,86 @@
|
|
|
2
2
|
// This is framework-level policy, not geometry-kernel plumbing: it knows the full
|
|
3
3
|
// PartDefinition shape (meta.backend, defaults, parts[name].build), unlike
|
|
4
4
|
// everything in geometry/, which is part-agnostic.
|
|
5
|
-
import { OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
6
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
|
+
import { isZeroMagnitudeCadOp } from "./geometry/op-options.js";
|
|
7
7
|
import { resolveDerived } from "./derive.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Per-sub-part routing: probe each sub-part's build independently and map it to
|
|
11
|
+
* its own backend, so a mixed part previews its plain sub-parts on fast Manifold
|
|
12
|
+
* while only the CAD-op ones pay for OCCT. Sub-parts build independently by
|
|
13
|
+
* construction (buildPosed builds one at a time), which is what makes splitting
|
|
14
|
+
* them across the two workers sound.
|
|
15
|
+
*/
|
|
16
|
+
export function detectBackends(part, params = {}) {
|
|
17
|
+
const forced = part.meta?.backend;
|
|
13
18
|
const p = { ...part.defaults, ...params };
|
|
14
19
|
let d = {};
|
|
15
20
|
// A throwing derive must not escape here — this runs on the main thread mid
|
|
16
21
|
// regen (after the busy spinner goes up). Probe with an empty `d`; the worker
|
|
17
22
|
// build hits the same throw and posts a proper error for the UI.
|
|
18
23
|
try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
|
|
19
|
-
const
|
|
24
|
+
const backends = {};
|
|
20
25
|
for (const name of Object.keys(part.parts)) {
|
|
26
|
+
if (forced) { backends[name] = forced; continue; }
|
|
27
|
+
const { kernel, cadCalls } = createProbeKernel();
|
|
21
28
|
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
29
|
+
// cadCalls holds only Solid-handle fillet/chamfer/shell — `Shape2D.fillet`/
|
|
30
|
+
// `.chamfer` are the shared pure-JS implementation (backend-identical) and must
|
|
31
|
+
// not drag a sub-part onto OCCT. A provably zero magnitude is the identity (see
|
|
32
|
+
// KERNEL-CONTRACT.md) and doesn't route either, so a fillet param dialed to 0
|
|
33
|
+
// drops the sub-part back onto Manifold with no `if (r > 0)` guard in the build.
|
|
34
|
+
backends[name] = cadCalls.some(({ op, args }) => !isZeroMagnitudeCadOp(op, args))
|
|
35
|
+
? "occt"
|
|
36
|
+
: "manifold";
|
|
22
37
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
return backends;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Whole-part detection: the max over detectBackends. This is the routing for
|
|
42
|
+
// anything that must build every sub-part in ONE worker — exports (a single
|
|
43
|
+
// STL/STEP/3MF job) and the single-process CLI, which can't mix kernels at all.
|
|
44
|
+
export function detectBackend(part, params = {}) {
|
|
45
|
+
return Object.values(detectBackends(part, params)).includes("occt") ? "occt" : "manifold";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The mount-time backend chooser. detectBackends() re-runs per regen with live
|
|
49
|
+
// params, so backend choice already follows the parameters in both directions —
|
|
50
|
+
// this wrapper exists for the runtime backstop: when the probe under-detects
|
|
51
|
+
// (a CAD-only call it can't reach — e.g. gated on a real geometry query the
|
|
52
|
+
// probe answers with dummies) the Manifold build throws NEEDS_OCCT and the
|
|
53
|
+
// worker asks for a reroute. That must not pin OCCT for the rest of the session,
|
|
54
|
+
// or turning the OCCT-only feature off never reverts to Manifold. Instead the
|
|
55
|
+
// reroute is latched per (sub-part, params snapshot): the exact combination that
|
|
56
|
+
// failed skips the doomed Manifold retry, and ANY param change re-consults the
|
|
57
|
+
// probe. A part the probe chronically under-detects costs one cheap failed
|
|
58
|
+
// Manifold dispatch per param change — the price of automatic reversion.
|
|
59
|
+
export function createBackendPolicy(part, { forced = null } = {}) {
|
|
60
|
+
let latchedParams = null; // JSON snapshot of the params proven at runtime to need OCCT
|
|
61
|
+
let latchedNames = null; // the sub-parts that proved it (null = all of them)
|
|
62
|
+
const latched = (params, name) =>
|
|
63
|
+
latchedParams !== null && latchedParams === JSON.stringify(params) &&
|
|
64
|
+
(latchedNames === null || latchedNames.has(name));
|
|
65
|
+
return {
|
|
66
|
+
// name → backend for a preview generate; mount groups sub-parts by this.
|
|
67
|
+
backendsFor(params) {
|
|
68
|
+
const backends = detectBackends(part, params);
|
|
69
|
+
for (const name of Object.keys(backends)) {
|
|
70
|
+
if (forced) backends[name] = forced;
|
|
71
|
+
else if (latched(params, name)) backends[name] = "occt";
|
|
72
|
+
}
|
|
73
|
+
return backends;
|
|
74
|
+
},
|
|
75
|
+
// Whole-part max, for jobs that need one worker (exports).
|
|
76
|
+
backendFor(params) {
|
|
77
|
+
if (forced) return forced;
|
|
78
|
+
return Object.values(this.backendsFor(params)).includes("occt") ? "occt" : "manifold";
|
|
79
|
+
},
|
|
80
|
+
// `subparts` names the failed job's sub-parts; omitted (an export job, which
|
|
81
|
+
// doesn't carry them) it latches the whole part for these params.
|
|
82
|
+
noteNeedsOcct(params, subparts) {
|
|
83
|
+
latchedParams = JSON.stringify(params);
|
|
84
|
+
latchedNames = subparts ? new Set(subparts) : null;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
28
87
|
}
|
|
@@ -208,6 +208,9 @@ export function createOcctKernel(replicad) {
|
|
|
208
208
|
const key = h("fillet", hash, radius, selKey(selector));
|
|
209
209
|
return cached(key, () => {
|
|
210
210
|
const a = mat();
|
|
211
|
+
// radius 0 is the identity per the contract — skip the kernel call so it
|
|
212
|
+
// can't trip the repair path's "feature skipped" warning.
|
|
213
|
+
if (radius === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
211
214
|
return wrap(safeOp(a._s.clone(), (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(a._labels), key);
|
|
212
215
|
});
|
|
213
216
|
},
|
|
@@ -216,6 +219,7 @@ export function createOcctKernel(replicad) {
|
|
|
216
219
|
// validChamfer probes on internal clones and never consumes its input.
|
|
217
220
|
return cached(key, () => {
|
|
218
221
|
const a = mat();
|
|
222
|
+
if (distance === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key); // identity, same as fillet(0)
|
|
219
223
|
return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
|
|
220
224
|
});
|
|
221
225
|
},
|
|
@@ -280,8 +280,8 @@ export const KERNEL_OP_SPECS = {
|
|
|
280
280
|
};
|
|
281
281
|
|
|
282
282
|
// Solid ops under the options convention; addSugar() wraps these when the
|
|
283
|
-
// backend provides them natively (OCCT). The Manifold
|
|
284
|
-
//
|
|
283
|
+
// backend provides them natively (OCCT). The Manifold stubs check only for the
|
|
284
|
+
// zero-magnitude identity case below, then throw the routing error unnormalized.
|
|
285
285
|
export const SOLID_OP_SPECS = {
|
|
286
286
|
fillet: { toArgs: (o) => { checkKeys("fillet", o, ["r", "edges"]);
|
|
287
287
|
return [req("fillet", o, "r"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
|
|
@@ -290,3 +290,17 @@ export const SOLID_OP_SPECS = {
|
|
|
290
290
|
shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
|
|
291
291
|
return [req("shell", o, "t"), req("shell", o, "open")]; } },
|
|
292
292
|
};
|
|
293
|
+
|
|
294
|
+
// A zero-magnitude fillet/chamfer is the identity on every conformance class
|
|
295
|
+
// (KERNEL-CONTRACT.md), so it neither routes a part to OCCT nor throws the
|
|
296
|
+
// Manifold capability error. True only when the magnitude is PROVABLY the number
|
|
297
|
+
// 0 in either calling convention — anything unprovable stays conservative
|
|
298
|
+
// (routes/throws). shell is excluded: t = 0 means zero-thickness walls, which is
|
|
299
|
+
// degenerate, not identity.
|
|
300
|
+
export function isZeroMagnitudeCadOp(op, args) {
|
|
301
|
+
const key = op === "fillet" ? "r" : op === "chamfer" ? "d" : null;
|
|
302
|
+
if (!key) return false;
|
|
303
|
+
const a0 = args[0];
|
|
304
|
+
const v = typeof a0 === "number" ? a0 : isPlainOptions(a0) ? a0[key] : undefined;
|
|
305
|
+
return v === 0;
|
|
306
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// lists, which test/kernel-contract.test.js pins to both backend implementations.
|
|
16
16
|
import {
|
|
17
17
|
KERNEL_OPS, KERNEL_OPTIONAL_OPS,
|
|
18
|
-
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
|
|
18
|
+
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS,
|
|
19
19
|
} from "./kernel.js";
|
|
20
20
|
import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
|
|
21
21
|
|
|
@@ -92,15 +92,22 @@ function makeProbe(onCall) {
|
|
|
92
92
|
return { kernel, proxy, shape2d };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const CAD_OPS = new Set(OCCT_ONLY_OPS);
|
|
96
|
+
|
|
95
97
|
export function createProbeKernel() {
|
|
96
98
|
const used = new Set(); // every op name, any handle kind
|
|
97
99
|
const solidUsed = new Set(); // ops recorded on kernel/Solid handles only — the
|
|
98
100
|
// routing set: Shape2D.fillet must not look like Solid.fillet
|
|
99
|
-
const
|
|
101
|
+
const cadCalls = []; // Solid-handle fillet/chamfer/shell calls WITH args —
|
|
102
|
+
// routing needs the magnitude (fillet(0) is identity, stays on Manifold)
|
|
103
|
+
const { kernel } = makeProbe((scope, key, args) => {
|
|
100
104
|
used.add(key);
|
|
101
|
-
if (scope !== "shape2d")
|
|
105
|
+
if (scope !== "shape2d") {
|
|
106
|
+
solidUsed.add(key);
|
|
107
|
+
if (CAD_OPS.has(key)) cadCalls.push({ op: key, args });
|
|
108
|
+
}
|
|
102
109
|
});
|
|
103
|
-
return { kernel, used, solidUsed };
|
|
110
|
+
return { kernel, used, solidUsed, cadCalls };
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// the needs-occt reroute works without hand-written per-backend stubs.
|
|
13
13
|
import { KernelCapabilityError } from "./errors.js";
|
|
14
14
|
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
15
|
-
import { isPlainOptions, SOLID_OP_SPECS } from "./op-options.js";
|
|
15
|
+
import { isPlainOptions, isZeroMagnitudeCadOp, SOLID_OP_SPECS } from "./op-options.js";
|
|
16
16
|
|
|
17
17
|
const ORIGIN = [0, 0, 0];
|
|
18
18
|
const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
@@ -59,14 +59,21 @@ export function addSugar(s) {
|
|
|
59
59
|
|
|
60
60
|
// Options-object calling convention for the multi-param B-rep ops. Wrap only
|
|
61
61
|
// when the backend provides the op natively (OCCT); the Manifold stubs below
|
|
62
|
-
//
|
|
62
|
+
// read their arguments just enough to spot the zero-magnitude identity case,
|
|
63
|
+
// and throw the routing error for everything else.
|
|
63
64
|
for (const [op, { toArgs }] of Object.entries(SOLID_OP_SPECS)) {
|
|
64
65
|
const raw = s[op];
|
|
65
66
|
if (raw) s[op] = (...a) => raw(...(a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a));
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
for (const op of OCCT_ONLY_OPS) {
|
|
69
|
-
|
|
70
|
+
// Zero magnitude is the identity per the contract — return a fresh handle
|
|
71
|
+
// (same idiom as along("+Z")) so an unguarded `.fillet(p.r)` at r = 0 builds
|
|
72
|
+
// on the core kernel instead of throwing the routing error.
|
|
73
|
+
s[op] ??= (...a) => {
|
|
74
|
+
if (isZeroMagnitudeCadOp(op, a)) return s.translate(ORIGIN);
|
|
75
|
+
throw new KernelCapabilityError(`${op} requires the OCCT backend`);
|
|
76
|
+
};
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
return Object.assign(s, SUGAR);
|
package/src/framework/jobs.js
CHANGED
|
@@ -244,7 +244,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
244
244
|
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
245
245
|
}
|
|
246
246
|
} catch (err) {
|
|
247
|
-
|
|
247
|
+
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
248
|
+
// failed job covered, so only those latch to OCCT — not the whole part.
|
|
249
|
+
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
|
|
248
250
|
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
249
251
|
} finally {
|
|
250
252
|
kernel.cleanup?.();
|
package/src/framework/mount.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createMeshCache } from "./mesh-cache.js";
|
|
|
13
13
|
import { createGeometryService } from "./geometry-service.js";
|
|
14
14
|
import { viewSubParts } from "./part-model.js";
|
|
15
15
|
import { resolveDerived } from "./derive.js";
|
|
16
|
-
import {
|
|
16
|
+
import { createBackendPolicy } from "./backend-select.js";
|
|
17
17
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
18
18
|
import { createRegenLoop } from "./regen-loop.js";
|
|
19
19
|
import { createPoseFastPath } from "./pose-fast-path.js";
|
|
@@ -253,10 +253,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
253
253
|
cleanup.defer(() => ui.setStatus(""));
|
|
254
254
|
cleanup.defer(() => ui.hideBusy());
|
|
255
255
|
|
|
256
|
-
// ?backend=occt|manifold forces the backend; otherwise it's detected per
|
|
256
|
+
// ?backend=occt|manifold forces the backend; otherwise it's re-detected per
|
|
257
|
+
// regen with the live params (so a fillet dialed to 0 reverts to Manifold),
|
|
258
|
+
// with a params-keyed latch for runtime needs-occt reroutes — see
|
|
259
|
+
// createBackendPolicy for why the latch must not outlive a param change.
|
|
257
260
|
let forcedBackend = new URLSearchParams(location.search).get("backend");
|
|
258
261
|
if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
|
|
259
|
-
const
|
|
262
|
+
const backendPolicy = createBackendPolicy(part, { forced: forcedBackend });
|
|
263
|
+
const backendFor = () => backendPolicy.backendFor(params);
|
|
260
264
|
|
|
261
265
|
// ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
|
|
262
266
|
const qs = new URLSearchParams(location.search);
|
|
@@ -398,6 +402,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
398
402
|
|
|
399
403
|
// The regenerate state machine (ready gating / debounce / stale-redo) lives in
|
|
400
404
|
// regen-loop.js; this send callback is the one place a build job is dispatched.
|
|
405
|
+
// Routing is per SUB-part: the missing set splits by backend and each group goes
|
|
406
|
+
// to its own worker in parallel, so a mixed part previews its plain sub-parts at
|
|
407
|
+
// Manifold speed while only the filleted ones wait on OCCT. The return value is
|
|
408
|
+
// the job count — the loop holds the cycle open until every group has replied.
|
|
401
409
|
const loop = createRegenLoop({
|
|
402
410
|
missingParts,
|
|
403
411
|
send: (missing) => {
|
|
@@ -406,7 +414,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
406
414
|
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
407
415
|
pendingPosed.clear(); // consumed — never counted against a second build
|
|
408
416
|
ui.showBusy("generating");
|
|
409
|
-
|
|
417
|
+
const backends = backendPolicy.backendsFor(params);
|
|
418
|
+
let jobs = 0;
|
|
419
|
+
for (const backend of ["manifold", "occt"]) {
|
|
420
|
+
const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
|
|
421
|
+
if (subparts.length === 0) continue;
|
|
422
|
+
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn }, backend);
|
|
423
|
+
jobs++;
|
|
424
|
+
}
|
|
425
|
+
return jobs;
|
|
410
426
|
},
|
|
411
427
|
});
|
|
412
428
|
cleanup.defer(() => loop.dispose());
|
|
@@ -505,7 +521,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
505
521
|
// delivered, which buildDone() true guarantees is at the live params.
|
|
506
522
|
fastPath.recordDelivered(m.name);
|
|
507
523
|
}
|
|
508
|
-
|
|
524
|
+
// A split dispatch answers in two meshes replies; the busy spinner
|
|
525
|
+
// stays up until the view has everything (the other worker's job may
|
|
526
|
+
// still be running — often OCCT, the slow one).
|
|
527
|
+
if (missingParts().length === 0) ui.hideBusy();
|
|
509
528
|
refreshView();
|
|
510
529
|
if (data.ms && missingParts().length === 0) {
|
|
511
530
|
const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
|
|
@@ -513,11 +532,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
513
532
|
}
|
|
514
533
|
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
515
534
|
onBuild?.({ status: "success", ms: data.ms });
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
535
|
+
// ready/autoplay wait for the WHOLE view: a split dispatch delivers in
|
|
536
|
+
// two replies, and a host acting on `ready` (screenshotting, measuring)
|
|
537
|
+
// must never see half an assembly.
|
|
538
|
+
if (missingParts().length === 0) {
|
|
539
|
+
if (!readySettled) { readySettled = true; resolveReady(); }
|
|
540
|
+
// First-show autoplay: latched separately from `ready`, which the
|
|
541
|
+
// error branch also settles — a part whose first build fails but
|
|
542
|
+
// whose retry succeeds still deserves its autoplay.
|
|
543
|
+
if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
|
|
544
|
+
}
|
|
521
545
|
} else if (lastAnimApplyVersion === loop.version()) {
|
|
522
546
|
// Stale ONLY because animation frames kept bumping the version:
|
|
523
547
|
// show the delivered meshes anyway — that IS best-effort playback —
|
|
@@ -549,7 +573,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
549
573
|
ui.setStatus(`${data.filename} downloaded`);
|
|
550
574
|
break;
|
|
551
575
|
case "needs-occt":
|
|
552
|
-
|
|
576
|
+
// Probe missed for the current params — rebuild the failed job's
|
|
577
|
+
// sub-parts on OCCT (an export reply has no subparts and latches the
|
|
578
|
+
// whole part). The latch is per params snapshot, so changing params
|
|
579
|
+
// re-consults the probe and sub-parts drop back to Manifold when the
|
|
580
|
+
// OCCT-only feature goes away.
|
|
581
|
+
backendPolicy.noteNeedsOcct(params, data.subparts);
|
|
553
582
|
loop.buildDone();
|
|
554
583
|
loop.kick();
|
|
555
584
|
break;
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Invariants (pinned by test/framework/regen-loop.test.js):
|
|
6
6
|
// - nothing is sent until ready() (the worker announced its kernel);
|
|
7
|
-
// - at most one
|
|
8
|
-
// caller re-kicks after buildDone()
|
|
7
|
+
// - at most one dispatch CYCLE is in flight; kicks while generating are absorbed
|
|
8
|
+
// and the caller re-kicks after the cycle's last buildDone(). `send` may fan a
|
|
9
|
+
// dispatch out to several workers (per-sub-part backend routing) and reports
|
|
10
|
+
// how many jobs it posted; each worker reply is one buildDone();
|
|
9
11
|
// - markDirty() bumps the params version and debounces a kick, so dragging a
|
|
10
12
|
// slider queues one build per pause, not one per pixel;
|
|
11
13
|
// - a build that a mid-flight edit outdated is reported stale by buildDone()
|
|
@@ -14,19 +16,19 @@
|
|
|
14
16
|
// fast-apply path kicks explicitly).
|
|
15
17
|
export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
16
18
|
let kernelReady = false;
|
|
17
|
-
let
|
|
19
|
+
let pending = 0; // worker replies still owed for the in-flight dispatch cycle
|
|
18
20
|
let disposed = false;
|
|
19
21
|
let paramsVersion = 0; // bumped on every settings edit
|
|
20
22
|
let genVersion = -1; // the params version the in-flight build is building
|
|
21
23
|
let timer = null;
|
|
22
24
|
|
|
23
25
|
function kick() {
|
|
24
|
-
if (disposed || !kernelReady ||
|
|
26
|
+
if (disposed || !kernelReady || pending > 0) return; // re-kicked when the current cycle finishes
|
|
25
27
|
const missing = missingParts();
|
|
26
28
|
if (missing.length === 0) return;
|
|
27
|
-
generating = true;
|
|
28
29
|
genVersion = paramsVersion;
|
|
29
|
-
send(
|
|
30
|
+
// A send with no return value is the single-job case (one worker, one reply).
|
|
31
|
+
pending = send(missing) ?? 1;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
return {
|
|
@@ -42,10 +44,12 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
|
42
44
|
clearTimeout(timer);
|
|
43
45
|
if (debounce) timer = setTimeout(kick, debounceMs);
|
|
44
46
|
},
|
|
45
|
-
//
|
|
46
|
-
// is still current; the caller applies the meshes only on true, then
|
|
47
|
+
// One job of the cycle finished (meshes / needs-occt / error). Returns whether
|
|
48
|
+
// its result is still current; the caller applies the meshes only on true, then
|
|
49
|
+
// kicks (a no-op until the cycle's last reply). Guarded decrement: a reply
|
|
50
|
+
// outside any cycle (an export-triggered needs-occt) must not pre-close the next.
|
|
47
51
|
buildDone() {
|
|
48
|
-
|
|
52
|
+
if (pending > 0) pending--;
|
|
49
53
|
return genVersion === paramsVersion;
|
|
50
54
|
},
|
|
51
55
|
version: () => paramsVersion,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// CI fixture for per-sub-part backend routing (the mixed-backend twin of
|
|
2
|
+
// text-smoke.js): one filleted sub-part that routes to OCCT next to a plain one
|
|
3
|
+
// that stays on Manifold, so the smoke check exercises a split generate — two
|
|
4
|
+
// workers answering one regen cycle — in a real browser. Dial "Edge fillet" to 0
|
|
5
|
+
// and the whole part drops back to Manifold (zero magnitude is the identity).
|
|
6
|
+
export default {
|
|
7
|
+
meta: { title: "Mixed smoke", units: "mm" },
|
|
8
|
+
parameters: [
|
|
9
|
+
{
|
|
10
|
+
id: "body",
|
|
11
|
+
title: "Body",
|
|
12
|
+
advanced: [
|
|
13
|
+
{ key: "w", label: "Width", unit: "mm", min: 10, max: 60, step: 1 },
|
|
14
|
+
{ key: "r", label: "Edge fillet", unit: "mm", min: 0, max: 5, step: 0.5 },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
defaults: { w: 30, r: 2 },
|
|
19
|
+
parts: {
|
|
20
|
+
body: {
|
|
21
|
+
label: "Body",
|
|
22
|
+
views: ["assembly"],
|
|
23
|
+
export: { name: "body" },
|
|
24
|
+
// Unguarded on purpose: fillet(0) is the identity, so r drives the routing.
|
|
25
|
+
build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 10] }).fillet({ r: p.r, edges: { dir: "Z" } }),
|
|
26
|
+
},
|
|
27
|
+
lid: {
|
|
28
|
+
label: "Lid",
|
|
29
|
+
views: ["assembly"],
|
|
30
|
+
export: { name: "lid" },
|
|
31
|
+
build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 2] }),
|
|
32
|
+
place: (s) => s.at([0, 0, 12]),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
views: { assembly: { label: "Assembly" } },
|
|
36
|
+
};
|