partforge 0.31.0 → 0.32.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/README.md +14 -0
- package/docs/AUTHORING-PARTS.md +60 -1
- package/docs/ERROR-PATTERNS.md +28 -0
- package/package.json +1 -1
- package/src/framework/controls.js +25 -6
- package/src/framework/debug-overlay.js +10 -2
- package/src/framework/geometry/kernel-front.js +11 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/occt-repair.js +9 -1
- package/src/framework/geometry/op-options.js +4 -1
- package/src/framework/geometry/pose.js +35 -3
- package/src/framework/geometry/rim-bevel.js +132 -0
- package/src/framework/mount.js +53 -7
- package/src/framework/pose-fast-path.js +49 -0
- package/src/framework/pose-probe.js +139 -0
- package/src/framework/selection/hover.js +6 -3
- package/src/framework/selection/raycast.js +17 -4
- package/src/framework/viewer.js +24 -1
package/README.md
CHANGED
|
@@ -128,6 +128,20 @@ so **the rail must be a direct child of the positioned `.pf-shell`** unless
|
|
|
128
128
|
the host also supplies `elements.shell` to point at the real containing block
|
|
129
129
|
(e.g. when a wrapper div sits between them, as is common in a React layout).
|
|
130
130
|
|
|
131
|
+
`runtime.setParams(partial)` edits parameters programmatically — the entry point
|
|
132
|
+
for animating a part from host code:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
runtime.setParams({ openAngle: 45 }); // merges into the live params, syncs the panel
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The partial is merged into the current params and the control panel updates to
|
|
139
|
+
match. Keys the part doesn't define are silently ignored. When every changed
|
|
140
|
+
parameter only moves geometry (a rotation or translation in `place()`), the
|
|
141
|
+
viewer re-poses the meshes it already has — instantly, with no worker rebuild;
|
|
142
|
+
`onBuild` does not fire for those pose-only edits. Anything that changes the
|
|
143
|
+
geometry itself rebuilds as usual.
|
|
144
|
+
|
|
131
145
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
132
146
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
|
133
147
|
sentence, `token` the compact form, `selection` the raw object. When `onPick` is
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -342,7 +342,9 @@ This holds on **both backends** — and on OCCT, `translate`/`rotate` are additi
|
|
|
342
342
|
re-running any B-rep work. A parameter that only feeds a final placement rotation (a
|
|
343
343
|
lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on the
|
|
344
344
|
slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
|
|
345
|
-
rather than baking them into the geometry earlier.
|
|
345
|
+
rather than baking them into the geometry earlier. In the app, such pose-only edits
|
|
346
|
+
skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
|
|
347
|
+
even at animation rates (see `runtime.setParams`).
|
|
346
348
|
|
|
347
349
|
---
|
|
348
350
|
|
|
@@ -1217,6 +1219,63 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
|
|
|
1217
1219
|
> `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
|
|
1218
1220
|
> topology); `render` works on both.
|
|
1219
1221
|
|
|
1222
|
+
### Cost: fillet/chamfer scale with edge count — and order matters
|
|
1223
|
+
|
|
1224
|
+
OCCT fillet/chamfer cost is **per selected edge**, on top of the OCCT boolean tax the
|
|
1225
|
+
routing already imposes on the rest of the part. Two habits keep it tolerable:
|
|
1226
|
+
|
|
1227
|
+
- **Fillet/chamfer as early as possible, on the simplest solid.** A fillet on a bare
|
|
1228
|
+
primitive is ~15× cheaper than the same fillet after a dozen boolean cuts have
|
|
1229
|
+
multiplied the face count — and because the solid cache keys each op by its input's
|
|
1230
|
+
content hash, an early fillet is a cache **hit** when a downstream parameter changes,
|
|
1231
|
+
while a fillet-last build re-pays the whole op on every slider step of every parameter.
|
|
1232
|
+
- **Never point a rim selector at a many-point extruded profile.** `edges: {inPlane}` on
|
|
1233
|
+
a gear-like extrusion selects *every* polygon edge (hundreds); one chamfer call then
|
|
1234
|
+
costs seconds — and if the distance doesn't fit the tooth lands, the failure-rescue
|
|
1235
|
+
bisection re-runs it ~8× (`ERROR-PATTERNS.md#chamfer-rescue-bisection`). Use the loft
|
|
1236
|
+
bevel below instead.
|
|
1237
|
+
|
|
1238
|
+
### Beveling profile rims: extrude's bevel option
|
|
1239
|
+
|
|
1240
|
+
For an **extruded profile** (gear, star, bracket outline — any `k.extrude` of a polygon),
|
|
1241
|
+
a top/bottom rim bevel doesn't need `chamfer` at all — it's built into `extrude`:
|
|
1242
|
+
|
|
1243
|
+
```js
|
|
1244
|
+
k.extrude({ profile: prof, h: 5, bevel: 0.6 }); // 45° bevel, both rims
|
|
1245
|
+
k.extrude({ profile: prof, h: 5, bevel: { top: 0.6 } }); // one rim only
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
Same 45° bevel a rim `chamfer` would cut, but it desugars into extrude + loft +
|
|
1249
|
+
intersect at the shared kernel front, so the part **stays on the fast Manifold
|
|
1250
|
+
backend** (no CAD-only op for the probe to find) and costs one boolean regardless of
|
|
1251
|
+
profile point count. Measured on a 24-tooth involute gear: ~0.1 s on Manifold vs
|
|
1252
|
+
~40 s for the equivalent OCCT `chamfer` (576-edge rim × the rescue bisection).
|
|
1253
|
+
|
|
1254
|
+
Every profile form works: point arrays, arc profiles, `{outer, holes}` regions
|
|
1255
|
+
(hole rims flare outward — the opening is larger at the face, as a chamfer would
|
|
1256
|
+
cut it), and `Shape2D` (multi-region shapes bevel each region and union). One
|
|
1257
|
+
fidelity caveat: curved profiles are **materialized to point rings** first — the
|
|
1258
|
+
loft envelope needs matched points — so a beveled extrusion is faceted at the
|
|
1259
|
+
sampling LOD even in STEP export. Arc contours sample at a fixed LOD identically
|
|
1260
|
+
on both backends; a `Shape2D` materializes at its own backend's LOD. If you need
|
|
1261
|
+
arc-exact STEP walls, that's the one case native `chamfer` still buys you (at
|
|
1262
|
+
its OCCT cost).
|
|
1263
|
+
|
|
1264
|
+
Rules (throws otherwise — `ERROR-PATTERNS.md#extrude-bevel-invalid`): no
|
|
1265
|
+
`twist`/`scaleTop`, and `bottom + top < h` — clamp from your height parameter, e.g.
|
|
1266
|
+
`bevel: Math.min(p.chamfer, p.thickness / 2 - 0.2)`. A bevel that would pinch a
|
|
1267
|
+
narrow feature shut (a gear's tooth land) is deterministically reduced to the
|
|
1268
|
+
largest offset the rim can take, with a console warning
|
|
1269
|
+
(`ERROR-PATTERNS.md#extrude-bevel-reduced`).
|
|
1270
|
+
|
|
1271
|
+
Under the hood it insets the profile with `offsetPolygon(prof, -c, { corners:
|
|
1272
|
+
"sharp" })` and intersects with a loft envelope extended past both faces (so the
|
|
1273
|
+
envelope's own end caps never coincide with the extrusion's faces — coincident caps
|
|
1274
|
+
leave sliver-triangle shading artifacts). The same construction works by hand when
|
|
1275
|
+
you need a variant the option doesn't cover. This bevels a **whole rim**; for
|
|
1276
|
+
selective edges on a solid that's already OCCT-routed, plain `chamfer` with a tight
|
|
1277
|
+
selector is still the right tool.
|
|
1278
|
+
|
|
1220
1279
|
---
|
|
1221
1280
|
|
|
1222
1281
|
## Conventions & gotchas
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -55,6 +55,34 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
55
55
|
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
|
|
56
56
|
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
|
|
57
57
|
|
|
58
|
+
## fillet-chamfer-many-edges-slow
|
|
59
|
+
|
|
60
|
+
- **Symptom:** A part that fillets or chamfers the rim of an extruded profile (a gear, a star, any many-point polygon) takes many seconds — even tens of seconds — per build, with no error anywhere.
|
|
61
|
+
- **Cause:** OCCT fillet/chamfer cost scales with the number of selected edges, and an `inPlane` rim selector on a many-point extruded profile selects every polygon edge (hundreds for a gear), so one op call costs seconds — and re-runs on every parameter change.
|
|
62
|
+
- **Fix:** Use `extrude`'s `bevel` option instead of `chamfer` — same geometry, stays on the fast Manifold backend. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
63
|
+
|
|
64
|
+
## chamfer-rescue-bisection
|
|
65
|
+
|
|
66
|
+
- **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.
|
|
67
|
+
- **Cause:** The requested chamfer distance doesn't fit the geometry (it over-runs an adjacent face or a short edge), so the failure-rescue bisection in `occt-repair.js` re-runs the full chamfer up to 7 more times to find the largest valid distance — multiplying an already-expensive op by ~8× on every build, since the result is only cached per exact input hash.
|
|
68
|
+
- **Fix:** Lower the chamfer parameter to at most the printed valid distance (the rescue then never fires), clamp it in `build` from the geometry that limits it, or — for extruded profile rims — switch to `extrude`'s `bevel` option, which finds its own limit in pure JS. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
69
|
+
|
|
70
|
+
Variant literals under this entry: `partforge: chamfer <d> over-ran the geometry — reduced to <d'> (largest valid; <n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`, `partforge: chamfer <d> has no valid distance for this geometry — feature skipped (<n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`.
|
|
71
|
+
|
|
72
|
+
## extrude-bevel-invalid
|
|
73
|
+
|
|
74
|
+
- **Symptom:** `extrude: bevel must fit the height (bottom + top < h)` or `extrude: bevel cannot combine with twist or scaleTop` thrown from a build.
|
|
75
|
+
- **Cause:** `extrude`'s `bevel` option desugars into offset-loft envelopes, which need an untwisted straight extrusion and room for both bevels inside the height.
|
|
76
|
+
- **Fix:** Clamp the bevel from the height parameter (e.g. `Math.min(c, h / 2 - 0.2)`) and drop `twist`/`scaleTop`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
|
|
77
|
+
|
|
78
|
+
Variant literals under this entry: `extrude: unknown bevel option`, `extrude: bevel must be a number or { bottom?, top? }`, `extrude: bevel distances must be finite numbers >= 0`.
|
|
79
|
+
|
|
80
|
+
## extrude-bevel-reduced
|
|
81
|
+
|
|
82
|
+
- **Symptom:** `partforge: extrude bevel` warning saying the requested distance `exceeds what the profile can take — reduced to` a smaller one (or `has no valid offset for this profile — rim left square`; `hole` in place of `profile` when a hole's flare is the limit).
|
|
83
|
+
- **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
|
|
84
|
+
- **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
|
|
85
|
+
|
|
58
86
|
## boolean-not-watertight
|
|
59
87
|
|
|
60
88
|
- **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
|
package/package.json
CHANGED
|
@@ -218,6 +218,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
218
218
|
const info = createInfoPopover();
|
|
219
219
|
const controls = []; // { key, el } per control element
|
|
220
220
|
const sections = []; // { el, keys:Set } per rendered section
|
|
221
|
+
const syncFns = []; // { key, sync } for every widget that can re-read params
|
|
221
222
|
for (const sec of parameters) {
|
|
222
223
|
if (!sectionRenders(sec)) continue;
|
|
223
224
|
const section = el("div", "section");
|
|
@@ -225,7 +226,11 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
225
226
|
attachInfo(title, sec.description, info);
|
|
226
227
|
section.append(title);
|
|
227
228
|
const keys = new Set();
|
|
228
|
-
const register = (key, node) => {
|
|
229
|
+
const register = (key, node, sync) => {
|
|
230
|
+
controls.push({ key, el: node });
|
|
231
|
+
keys.add(key);
|
|
232
|
+
if (sync) syncFns.push({ key, sync });
|
|
233
|
+
};
|
|
229
234
|
if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
|
|
230
235
|
else buildPresetSection(section, sec, params, onDirty, register, info);
|
|
231
236
|
root.append(section);
|
|
@@ -233,6 +238,12 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
233
238
|
}
|
|
234
239
|
return {
|
|
235
240
|
applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
|
|
241
|
+
// Re-read params into the widgets — all of them, or just `keys`. The
|
|
242
|
+
// programmatic twin of a user edit (setParams); never fires onDirty.
|
|
243
|
+
syncValues: (keys) => {
|
|
244
|
+
const only = keys && new Set(keys);
|
|
245
|
+
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
246
|
+
},
|
|
236
247
|
dispose: () => { info.dispose(); root.replaceChildren(); },
|
|
237
248
|
};
|
|
238
249
|
}
|
|
@@ -263,7 +274,7 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
|
263
274
|
attachInfo(lbl, t.description, info);
|
|
264
275
|
row.append(box, lbl);
|
|
265
276
|
box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
|
|
266
|
-
register(t.key, row);
|
|
277
|
+
register(t.key, row, () => { box.checked = params[t.key] > 0; });
|
|
267
278
|
section.append(row);
|
|
268
279
|
}
|
|
269
280
|
|
|
@@ -274,8 +285,11 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
|
274
285
|
for (const def of advanced) {
|
|
275
286
|
const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
|
|
276
287
|
adv.append(s.wrap);
|
|
277
|
-
syncs[def.key] = s.sync;
|
|
278
|
-
|
|
288
|
+
syncs[def.key] = s.sync; // raw: preset APPLICATION must not mark itself Custom
|
|
289
|
+
// A programmatic edit diverges from the preset exactly as a user edit does,
|
|
290
|
+
// so the picker falls back to Custom — leaving a stale preset name selected
|
|
291
|
+
// would also make it unre-appliable (no change event for the current option).
|
|
292
|
+
register(def.key, s.wrap, () => { s.sync(); if (preset) preset.value = "Custom"; });
|
|
279
293
|
}
|
|
280
294
|
section.append(toggle, adv);
|
|
281
295
|
}
|
|
@@ -306,7 +320,12 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
|
306
320
|
const featLabel = el("span", "", feat.label);
|
|
307
321
|
attachInfo(featLabel, feat.description, info);
|
|
308
322
|
checkRow.append(box, featLabel);
|
|
309
|
-
|
|
323
|
+
// `group` is created just below — the sync only ever runs after this
|
|
324
|
+
// function returns, so the closure is safely bound by then.
|
|
325
|
+
register(feat.key, checkRow, () => {
|
|
326
|
+
box.checked = params[feat.key] > 0;
|
|
327
|
+
group.classList.toggle("hidden", !box.checked);
|
|
328
|
+
});
|
|
310
329
|
|
|
311
330
|
const group = el("div", "feat-group");
|
|
312
331
|
const syncs = [];
|
|
@@ -314,7 +333,7 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
|
314
333
|
const s = makeParameterControl(def, params, onDirty, info);
|
|
315
334
|
group.append(s.wrap);
|
|
316
335
|
syncs.push(s.sync);
|
|
317
|
-
register(def.key, s.wrap);
|
|
336
|
+
register(def.key, s.wrap, s.sync);
|
|
318
337
|
}
|
|
319
338
|
group.classList.toggle("hidden", !box.checked);
|
|
320
339
|
|
|
@@ -43,13 +43,21 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
|
43
43
|
|
|
44
44
|
cb.addEventListener("change", () => onToggle?.(cb.checked));
|
|
45
45
|
|
|
46
|
+
// What's currently on screen. update() MERGES over it, so a caller reporting one
|
|
47
|
+
// figure doesn't blank the others: a pose-only edit ran no build and no L2 ops,
|
|
48
|
+
// and would otherwise wipe the last build's timing back to "—" on its way to
|
|
49
|
+
// showing a posed count.
|
|
50
|
+
const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0 };
|
|
51
|
+
|
|
46
52
|
return {
|
|
47
|
-
update(
|
|
53
|
+
update(counts = {}) {
|
|
54
|
+
Object.assign(shown, counts);
|
|
55
|
+
const { ms, hits, misses, skipped, rebuilt, posed } = shown;
|
|
48
56
|
const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
|
|
49
57
|
readout.textContent =
|
|
50
58
|
`build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
|
|
51
59
|
`L2 ops: ${l2}\n` +
|
|
52
|
-
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
|
|
60
|
+
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed`;
|
|
53
61
|
},
|
|
54
62
|
detach: () => box.remove(),
|
|
55
63
|
};
|
|
@@ -25,6 +25,7 @@ const opentype = typeof opentypeNamespace.parse === "function"
|
|
|
25
25
|
import { KernelCapabilityError } from "./errors.js";
|
|
26
26
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
27
27
|
import { textGlyphs } from "./text2d.js";
|
|
28
|
+
import { beveledExtrude } from "./rim-bevel.js";
|
|
28
29
|
import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
29
30
|
import { convexHull, hullPoints } from "./hull.js";
|
|
30
31
|
|
|
@@ -45,6 +46,16 @@ export function finishKernel(k) {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// extrude({ bevel }) desugars here — before the spec-wrapped op ever sees it —
|
|
50
|
+
// into extrude + loft + intersect (rim-bevel.js), so both backends share one
|
|
51
|
+
// implementation and the probe records no CAD-only op. beveledExtrude calls
|
|
52
|
+
// back into the wrapped k.extrude/k.loft, so caching and validation apply.
|
|
53
|
+
const specExtrude = k.extrude;
|
|
54
|
+
k.extrude = (...a) =>
|
|
55
|
+
a.length === 1 && isPlainOptions(a[0]) && a[0].bevel !== undefined
|
|
56
|
+
? beveledExtrude(k, a[0])
|
|
57
|
+
: specExtrude(...a);
|
|
58
|
+
|
|
48
59
|
k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
|
|
49
60
|
k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
|
|
50
61
|
|
|
@@ -99,7 +99,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
99
99
|
* @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
|
|
100
100
|
* @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
|
|
101
101
|
* @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
|
|
102
|
-
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number}) => Solid} extrude polygon-with-holes region from z=0; legacy (profile,h,opts) accepted until v2
|
|
102
|
+
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted until v2
|
|
103
103
|
* @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections; legacy (rings,opts) accepted until v2
|
|
104
104
|
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
|
|
105
105
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
|
|
@@ -42,7 +42,9 @@ export function createOcctRepair(measureVolume) {
|
|
|
42
42
|
// WASM heap doesn't grow across regenerates.
|
|
43
43
|
const validChamfer = (shape, finderFn, distance) => {
|
|
44
44
|
if (!(distance > 0)) return shape.clone();
|
|
45
|
+
let attempts = 0;
|
|
45
46
|
const tryAt = (d) => {
|
|
47
|
+
attempts++;
|
|
46
48
|
const probe = shape.clone();
|
|
47
49
|
let res;
|
|
48
50
|
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
@@ -50,6 +52,7 @@ export function createOcctRepair(measureVolume) {
|
|
|
50
52
|
res.delete?.();
|
|
51
53
|
return null;
|
|
52
54
|
};
|
|
55
|
+
const t0 = performance.now();
|
|
53
56
|
let best = tryAt(distance);
|
|
54
57
|
if (best) return best; // requested distance is valid
|
|
55
58
|
let lo = 0, hi = distance, bestD = 0;
|
|
@@ -58,7 +61,12 @@ export function createOcctRepair(measureVolume) {
|
|
|
58
61
|
const res = tryAt(mid);
|
|
59
62
|
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
// The rescue re-ran the chamfer per attempt — on a many-edge selection that
|
|
65
|
+
// multiplies an already-expensive op by ~8x, so make the cost loud enough to
|
|
66
|
+
// act on (lower the distance, or bevel profile rims with a loft instead).
|
|
67
|
+
const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
|
|
68
|
+
if (best) { console.warn(`partforge: chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
|
|
69
|
+
console.warn(`partforge: chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
|
|
62
70
|
return shape.clone(); // nothing valid — skip the chamfer
|
|
63
71
|
};
|
|
64
72
|
|
|
@@ -103,7 +103,10 @@ export function prismArgs(o) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export function extrudeArgs(o) {
|
|
106
|
-
|
|
106
|
+
// `bevel` is accepted here so the validating probe (lint) doesn't flag it, but
|
|
107
|
+
// it never reaches the positional backend op — kernel-front.js desugars a
|
|
108
|
+
// bevel call into extrude + loft + intersect before this normalizer runs.
|
|
109
|
+
checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop", "bevel"]);
|
|
107
110
|
return [req("extrude", o, "profile"), req("extrude", o, "h"), ...tail(o, ["twist", "scaleTop"])];
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
// the underlying B-rep shape; composePose folds them (in application order) into
|
|
4
4
|
// one column-major mat4, and transformPositions re-poses a cached tessellation's
|
|
5
5
|
// vertices with it. Pure JS — unit-testable without booting a kernel.
|
|
6
|
+
//
|
|
7
|
+
// The same math backs the viewer's pose fast path: when a param change only
|
|
8
|
+
// moves a sub-part, `poseDelta` (via `invertRigid`) gives the matrix carrying an
|
|
9
|
+
// already-delivered mesh from the pose it was built at to the new one, so the
|
|
10
|
+
// viewer re-poses instead of rebuilding.
|
|
6
11
|
|
|
7
12
|
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
8
13
|
|
|
9
14
|
// column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
|
|
10
|
-
function
|
|
15
|
+
function mulMat4(A, B) {
|
|
11
16
|
const o = new Array(16);
|
|
12
17
|
for (let c = 0; c < 4; c++)
|
|
13
18
|
for (let r = 0; r < 4; r++)
|
|
@@ -28,13 +33,13 @@ function rotationAbout(deg, center, axis) {
|
|
|
28
33
|
x * z * C + y * s, y * z * C - x * s, c + z * z * C, 0,
|
|
29
34
|
0, 0, 0, 1,
|
|
30
35
|
];
|
|
31
|
-
return
|
|
36
|
+
return mulMat4(translation(center), mulMat4(R, translation([-center[0], -center[1], -center[2]])));
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
const stepMatrix = (s) => (s.t === "translate" ? translation(s.v) : rotationAbout(s.deg, s.center, s.axis));
|
|
35
40
|
|
|
36
41
|
// Fold steps so the EARLIEST step applies first: p' = Mn · … · M1 · p.
|
|
37
|
-
export const composePose = (steps) => steps.reduce((m, s) =>
|
|
42
|
+
export const composePose = (steps) => steps.reduce((m, s) => mulMat4(stepMatrix(s), m), IDENTITY);
|
|
38
43
|
|
|
39
44
|
// Apply a mat4 to an interleaved xyz Float32Array in place.
|
|
40
45
|
export function transformPositions(positions, m) {
|
|
@@ -45,3 +50,30 @@ export function transformPositions(positions, m) {
|
|
|
45
50
|
positions[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
|
|
54
|
+
// Invert a rigid mat4 (rotation + translation only): Rᵀ, t' = −Rᵀ·t. Valid only
|
|
55
|
+
// for matrices produced by composePose — transposing the 3x3 block inverts a
|
|
56
|
+
// rotation, so any scale or shear in it yields garbage rather than an inverse.
|
|
57
|
+
export function invertRigid(m) {
|
|
58
|
+
const r0 = m[0], r1 = m[1], r2 = m[2],
|
|
59
|
+
r4 = m[4], r5 = m[5], r6 = m[6],
|
|
60
|
+
r8 = m[8], r9 = m[9], r10 = m[10],
|
|
61
|
+
tx = m[12], ty = m[13], tz = m[14];
|
|
62
|
+
return [
|
|
63
|
+
r0, r4, r8, 0,
|
|
64
|
+
r1, r5, r9, 0,
|
|
65
|
+
r2, r6, r10, 0,
|
|
66
|
+
-(r0 * tx + r1 * ty + r2 * tz),
|
|
67
|
+
-(r4 * tx + r5 * ty + r6 * tz),
|
|
68
|
+
-(r8 * tx + r9 * ty + r10 * tz),
|
|
69
|
+
1,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The matrix that carries a mesh delivered at `oldSteps` to the pose `newSteps`:
|
|
74
|
+
// compose(new) · compose(old)⁻¹. Both step lists come from the pose probe.
|
|
75
|
+
export const poseDelta = (newSteps, oldSteps) => {
|
|
76
|
+
const target = composePose(newSteps);
|
|
77
|
+
const inv = invertRigid(composePose(oldSteps));
|
|
78
|
+
return mulMat4(target, inv);
|
|
79
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Rim bevel for extruded profiles — extrude({ profile, h, bevel }). Composed
|
|
2
|
+
// entirely from existing kernel ops (extrude + loft + intersect/cut) at the
|
|
3
|
+
// backend-shared front, so both backends get identical semantics for free and
|
|
4
|
+
// the probe sees no CAD-only op: a beveled extrusion stays on the fast Manifold
|
|
5
|
+
// backend instead of routing the whole part to OCCT. (OCCT's native chamfer is
|
|
6
|
+
// per-edge; on a many-point profile rim — a gear — one call costs seconds. The
|
|
7
|
+
// loft envelope costs one boolean regardless of point count.)
|
|
8
|
+
//
|
|
9
|
+
// Accepted profiles: point array, arc/curve contour, { outer, holes } region,
|
|
10
|
+
// or a Shape2D (multi-region Shape2Ds bevel each region and union). Curved
|
|
11
|
+
// profiles are MATERIALIZED to point rings first — the loft envelope needs
|
|
12
|
+
// rings in 1:1 point correspondence — so a beveled extrusion is faceted at the
|
|
13
|
+
// sampling LOD even in STEP export (the same fidelity class as loft rings).
|
|
14
|
+
// Arc contours sample at a fixed pure-JS LOD (backend-identical, like hull);
|
|
15
|
+
// a Shape2D materializes via its own backend's toRegions (hull's Shape2D
|
|
16
|
+
// parity class). The body is built from the SAME materialized rings as the
|
|
17
|
+
// envelope — mixing a curve-exact body with a faceted envelope would leave
|
|
18
|
+
// sliver artifacts where the smooth wall crosses the faceted one.
|
|
19
|
+
//
|
|
20
|
+
// Geometry: a 45° bevel, exactly what chamfer({d, edges:{inPlane}}) would cut.
|
|
21
|
+
// The outer rim insets toward the material; a hole's rim flares the other way
|
|
22
|
+
// (the opening is larger at the face). Outer bevels come from intersecting a
|
|
23
|
+
// loft envelope extended 1 mm past both faces (so its end caps never coincide
|
|
24
|
+
// with the extrusion's faces — coincident caps leave sliver-triangle shading
|
|
25
|
+
// artifacts). Hole bevels are separate per-rim flare cutters that meet the
|
|
26
|
+
// hole wall only along a ring (a curve, not a face), keeping the booleans away
|
|
27
|
+
// from coincident-face degeneracies on the B-rep backend.
|
|
28
|
+
import { offsetPolygon } from "./polygon.js";
|
|
29
|
+
import { tessellateProfile } from "./profile.js";
|
|
30
|
+
import { ringArea } from "./shape2d-regions.js";
|
|
31
|
+
|
|
32
|
+
// Fixed pure-JS sampling LOD for arc/curve contours — hull.js precedent: not a
|
|
33
|
+
// backend's own segment count, so both backends materialize bit-identically.
|
|
34
|
+
const BEVEL_SEGS = 64;
|
|
35
|
+
|
|
36
|
+
// Normalize `bevel` (number = both rims, {bottom, top} = per rim) and validate
|
|
37
|
+
// against the height. Exported for direct unit testing.
|
|
38
|
+
export function resolveBevel(bevel, h) {
|
|
39
|
+
let bottom, top;
|
|
40
|
+
if (typeof bevel === "number") { bottom = bevel; top = bevel; }
|
|
41
|
+
else if (bevel !== null && typeof bevel === "object") {
|
|
42
|
+
for (const key of Object.keys(bevel)) if (key !== "bottom" && key !== "top")
|
|
43
|
+
throw new Error(`extrude: unknown bevel option ${JSON.stringify(key)} (valid: bottom, top)`);
|
|
44
|
+
bottom = bevel.bottom ?? 0; top = bevel.top ?? 0;
|
|
45
|
+
} else throw new Error("extrude: bevel must be a number or { bottom?, top? }");
|
|
46
|
+
if (!(Number.isFinite(bottom) && bottom >= 0) || !(Number.isFinite(top) && top >= 0))
|
|
47
|
+
throw new Error("extrude: bevel distances must be finite numbers >= 0");
|
|
48
|
+
if (bottom + top >= h)
|
|
49
|
+
throw new Error("extrude: bevel must fit the height (bottom + top < h)");
|
|
50
|
+
return { bottom, top };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// offsetPolygon needs CCW input; toRegions/hand-written holes may wind either
|
|
54
|
+
// way. Tessellated arc contours close back onto their start point — drop that
|
|
55
|
+
// duplicate, or the offset ring's point count never matches and fit() gives up.
|
|
56
|
+
const ccw = (r) => {
|
|
57
|
+
const [x0, y0] = r[0], [xn, yn] = r[r.length - 1];
|
|
58
|
+
const ring = Math.abs(x0 - xn) < 1e-9 && Math.abs(y0 - yn) < 1e-9 ? r.slice(0, -1) : r;
|
|
59
|
+
return ringArea(ring) >= 0 ? ring : [...ring].reverse();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The largest offset (inset for the outer rim, outset for a hole rim — the sign
|
|
63
|
+
// of `delta`) the ring can take, starting from the requested distance. Narrow
|
|
64
|
+
// features cap the bevel (the same geometric limit OCCT's chamfer hits — see
|
|
65
|
+
// ERROR-PATTERNS.md#chamfer-rescue-bisection), but each attempt here is pure JS
|
|
66
|
+
// on the 2-D outline, not a kernel op, so backing off is effectively free. The
|
|
67
|
+
// loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
|
|
68
|
+
// offset 1:1 with the input points — loft stitching requires every ring to
|
|
69
|
+
// share the profile's exact point count (a mismatch is treated as a failed try).
|
|
70
|
+
const fit = (ring, delta, what) => {
|
|
71
|
+
const requested = Math.abs(delta), sign = Math.sign(delta);
|
|
72
|
+
let c = requested;
|
|
73
|
+
for (;;) {
|
|
74
|
+
try {
|
|
75
|
+
const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
|
|
76
|
+
if (off.length === ring.length) {
|
|
77
|
+
if (c < requested)
|
|
78
|
+
console.warn(`partforge: extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
|
|
79
|
+
return { ring: off, c };
|
|
80
|
+
}
|
|
81
|
+
} catch { /* offset collapsed or self-intersected — try smaller */ }
|
|
82
|
+
c *= 0.85;
|
|
83
|
+
if (c < 0.05) {
|
|
84
|
+
console.warn(`partforge: extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const outerRings = (outer, h, b, t) => {
|
|
91
|
+
const rings = [];
|
|
92
|
+
if (b) rings.push({ polygon: b.ring, z: -1 }, { polygon: b.ring, z: 0 }, { polygon: outer, z: b.c });
|
|
93
|
+
else rings.push({ polygon: outer, z: -1 });
|
|
94
|
+
if (t) rings.push({ polygon: outer, z: h - t.c }, { polygon: t.ring, z: h }, { polygon: t.ring, z: h + 1 });
|
|
95
|
+
else rings.push({ polygon: outer, z: h + 1 });
|
|
96
|
+
return rings;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const bevelRegion = (k, region, h, bottom, top) => {
|
|
100
|
+
const outer = ccw(region.outer);
|
|
101
|
+
const holes = (region.holes ?? []).map(ccw);
|
|
102
|
+
let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
|
|
103
|
+
const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
|
|
104
|
+
const t = top > 0 ? fit(outer, -top, "profile") : null;
|
|
105
|
+
if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t) }));
|
|
106
|
+
const cutters = [];
|
|
107
|
+
for (const hole of holes) {
|
|
108
|
+
const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
|
|
109
|
+
if (hb) cutters.push(k.loft({ rings: [
|
|
110
|
+
{ polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
|
|
111
|
+
] }));
|
|
112
|
+
const ht = top > 0 ? fit(hole, top, "hole") : null;
|
|
113
|
+
if (ht) cutters.push(k.loft({ rings: [
|
|
114
|
+
{ polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
|
|
115
|
+
] }));
|
|
116
|
+
}
|
|
117
|
+
return cutters.length ? s.cutAll(cutters) : s;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
|
|
121
|
+
if (twist !== undefined || scaleTop !== undefined)
|
|
122
|
+
throw new Error("extrude: bevel cannot combine with twist or scaleTop");
|
|
123
|
+
const { bottom, top } = resolveBevel(bevel, h);
|
|
124
|
+
// bevel: 0 must hash identically to the plain call — hand the original
|
|
125
|
+
// profile straight through (curve-exact on OCCT, no materialization).
|
|
126
|
+
if (bottom === 0 && top === 0) return k.extrude({ profile, h });
|
|
127
|
+
const regions = profile != null && typeof profile.toRegions === "function"
|
|
128
|
+
? profile.toRegions()
|
|
129
|
+
: [tessellateProfile(profile, BEVEL_SEGS)];
|
|
130
|
+
if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
|
|
131
|
+
return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
|
|
132
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveDerived } from "./derive.js";
|
|
|
15
15
|
import { detectBackend } from "./geometry/probe.js";
|
|
16
16
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
17
17
|
import { createRegenLoop } from "./regen-loop.js";
|
|
18
|
+
import { createPoseFastPath } from "./pose-fast-path.js";
|
|
18
19
|
import { createStatusUi } from "./status-ui.js";
|
|
19
20
|
import { createViewTabs } from "./view-tabs.js";
|
|
20
21
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
@@ -22,8 +23,8 @@ import { createPickRequestClient } from "./pick-request/index.js";
|
|
|
22
23
|
|
|
23
24
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
24
25
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
25
|
-
export function makeHandle({ ready, dispose, viewer }) {
|
|
26
|
-
return { ready, dispose, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
|
|
26
|
+
export function makeHandle({ ready, dispose, viewer, setParams }) {
|
|
27
|
+
return { ready, dispose, setParams, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function createCleanupStack() {
|
|
@@ -58,7 +59,10 @@ function createCleanupStack() {
|
|
|
58
59
|
// Embedding contract (0.12.0):
|
|
59
60
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick });
|
|
60
61
|
// await runtime.ready; // first successful build of the default view
|
|
62
|
+
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
61
63
|
// runtime.dispose(); // full teardown
|
|
64
|
+
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
65
|
+
// those are repaired in the viewer and produce no build at all.
|
|
62
66
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
63
67
|
// exactly once here — submodules take element refs and never query the document.
|
|
64
68
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
@@ -126,7 +130,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
126
130
|
const qs = new URLSearchParams(location.search);
|
|
127
131
|
const debug = qs.has("debug");
|
|
128
132
|
let cachingOn = !(debug && qs.has("nocache"));
|
|
129
|
-
let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
|
|
133
|
+
let lastGen = { skipped: 0, rebuilt: 0, posed: 0 }; // Layer-0/1 counts for the most recent generate
|
|
134
|
+
// Sub-parts the pose fast path has repaired since the last build was dispatched.
|
|
135
|
+
// A SET of names, not a running total: a slider drag re-repairs the same
|
|
136
|
+
// sub-part on every input event, and "247 posed" for a one-sub-part app would
|
|
137
|
+
// be nonsense. A build dispatched in the SAME dirty cycle takes the count into
|
|
138
|
+
// its report — a mixed edit re-poses one sub-part and rebuilds another, and the
|
|
139
|
+
// overlay should say so. Anything that kicks a build for an unrelated reason
|
|
140
|
+
// (view switch / forceRegen) clears it first, so it can't be miscredited.
|
|
141
|
+
const pendingPosed = new Set();
|
|
130
142
|
const dbg = debug
|
|
131
143
|
? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
|
|
132
144
|
: null;
|
|
@@ -135,7 +147,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
135
147
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
136
148
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
137
149
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
138
|
-
onChange: () => { cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
|
|
150
|
+
onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
|
|
139
151
|
});
|
|
140
152
|
cleanup.defer(() => tabsCtl.detach());
|
|
141
153
|
const view = () => tabsCtl.current();
|
|
@@ -199,13 +211,21 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
199
211
|
missingParts,
|
|
200
212
|
send: (missing) => {
|
|
201
213
|
const needed = viewSubParts(part, view(), params);
|
|
202
|
-
|
|
214
|
+
// for the overlay; this build reports the poses repaired in its own cycle.
|
|
215
|
+
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
216
|
+
pendingPosed.clear(); // consumed — never counted against a second build
|
|
203
217
|
ui.showBusy("generating");
|
|
204
218
|
service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
|
|
205
219
|
},
|
|
206
220
|
});
|
|
207
221
|
cleanup.defer(() => loop.dispose());
|
|
208
222
|
|
|
223
|
+
// Pose fast path (Layer 0): a param edit that only re-poses a sub-part is
|
|
224
|
+
// repaired synchronously in the viewer — no debounce, no worker job.
|
|
225
|
+
const fastPath = createPoseFastPath(part, viewer, cache, {
|
|
226
|
+
params, getView: view, getParamsVersion: () => loop.version(),
|
|
227
|
+
});
|
|
228
|
+
|
|
209
229
|
// First-build readiness: resolves on the first accepted meshes result, rejects on
|
|
210
230
|
// a first-build error. Guarded against unhandled rejection when never awaited.
|
|
211
231
|
let readySettled = false;
|
|
@@ -272,13 +292,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
272
292
|
for (const m of data.meshes) {
|
|
273
293
|
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
274
294
|
cache.record(m.name);
|
|
295
|
+
// Stamp the fast path's pose baseline. Only here, inside the
|
|
296
|
+
// non-stale branch: the stamp must describe the geometry actually
|
|
297
|
+
// delivered, which buildDone() true guarantees is at the live params.
|
|
298
|
+
fastPath.recordDelivered(m.name);
|
|
275
299
|
}
|
|
276
300
|
ui.hideBusy();
|
|
277
301
|
refreshView();
|
|
278
302
|
if (data.ms && missingParts().length === 0) {
|
|
279
303
|
ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
|
|
280
304
|
}
|
|
281
|
-
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
|
|
305
|
+
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
282
306
|
onBuild?.({ status: "success", ms: data.ms });
|
|
283
307
|
if (!readySettled) { readySettled = true; resolveReady(); }
|
|
284
308
|
}
|
|
@@ -319,15 +343,37 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
319
343
|
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
|
|
320
344
|
updateRelevance(); // initial view
|
|
321
345
|
|
|
346
|
+
// The ONLY caller of fastPath.repair(). It must never move into the regen /
|
|
347
|
+
// forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
|
|
348
|
+
// version, so a repair there would re-stamp everything current off the memoized
|
|
349
|
+
// probe and the forced rebuild would silently no-op.
|
|
322
350
|
function onParamChange() {
|
|
323
351
|
loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
|
|
352
|
+
// Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
|
|
353
|
+
// when caching is off — ?debug&nocache is there to measure true uncached
|
|
354
|
+
// rebuilds, which the fast path would otherwise hide.
|
|
355
|
+
const posed = cachingOn ? fastPath.repair() : [];
|
|
356
|
+
if (posed.length) {
|
|
357
|
+
for (const name of posed) pendingPosed.add(name);
|
|
358
|
+
dbg?.update({ posed: pendingPosed.size }); // partial: merges over the last build's numbers
|
|
359
|
+
}
|
|
324
360
|
refreshView(); // keep showing the now-stale mesh (no flicker); disable export
|
|
325
361
|
updateRelevance();
|
|
326
362
|
}
|
|
327
363
|
|
|
364
|
+
// Programmatic param entry point — the animation-system hook. Same change
|
|
365
|
+
// path as a slider edit: pose-only changes repair synchronously (no worker
|
|
366
|
+
// job, no debounce); geometry changes fall through to the regen loop.
|
|
367
|
+
function setParams(partial) {
|
|
368
|
+
Object.assign(params, partial);
|
|
369
|
+
panel.syncValues(Object.keys(partial));
|
|
370
|
+
onParamChange();
|
|
371
|
+
}
|
|
372
|
+
|
|
328
373
|
// Re-run the active view under the current caching setting, so toggling the
|
|
329
374
|
// ?debug switch updates the readout for the same design without a param change.
|
|
330
375
|
function forceRegen() {
|
|
376
|
+
pendingPosed.clear(); // this rebuild is not the pose edit's — don't credit it
|
|
331
377
|
for (const n of viewSubParts(part, view(), params)) cache.forget(n);
|
|
332
378
|
refreshView();
|
|
333
379
|
loop.kick();
|
|
@@ -371,7 +417,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
371
417
|
cleanup.dispose();
|
|
372
418
|
}
|
|
373
419
|
|
|
374
|
-
return makeHandle({ ready, dispose, viewer });
|
|
420
|
+
return makeHandle({ ready, dispose, viewer, setParams });
|
|
375
421
|
} catch (error) {
|
|
376
422
|
try {
|
|
377
423
|
cleanup.dispose();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Decision layer of the pose fast path (no DOM, no three.js — viewer/cache are
|
|
2
|
+
// injected). At mesh delivery each subpart is STAMPED with its probe result at
|
|
3
|
+
// the delivered params; on a later edit, a stale subpart whose baseHash is
|
|
4
|
+
// unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
|
|
5
|
+
// delivered pose) and is re-stamped current — no worker job. Everything else
|
|
6
|
+
// falls through to the normal regen loop.
|
|
7
|
+
import { viewSubParts } from "./jobs.js";
|
|
8
|
+
import { probePoses } from "./pose-probe.js";
|
|
9
|
+
import { poseDelta } from "./geometry/pose.js";
|
|
10
|
+
|
|
11
|
+
export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
|
|
12
|
+
const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
|
|
13
|
+
|
|
14
|
+
// Memoize the probe per (paramsVersion, view) — same discipline as
|
|
15
|
+
// createMeshCache's readsFor; params is the live in-place-mutated object.
|
|
16
|
+
let probeKey = null, probeMap = null;
|
|
17
|
+
const probeFor = () => {
|
|
18
|
+
const key = `${getParamsVersion()}|${getView()}`;
|
|
19
|
+
if (probeKey !== key) { probeKey = key; probeMap = probePoses(part, getView(), params); }
|
|
20
|
+
return probeMap;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
// Stamp a freshly delivered mesh with its probe result at the current params.
|
|
25
|
+
// (The caller only records on non-stale builds — buildDone() guarantees the
|
|
26
|
+
// live params are the ones the worker built with.)
|
|
27
|
+
recordDelivered(name) {
|
|
28
|
+
stamps[name] = probeFor().get(name);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// Re-pose every visible stale subpart whose base geometry is unchanged.
|
|
32
|
+
// Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
|
|
33
|
+
// count: a slider drag repairs the same subpart on every input event, so only
|
|
34
|
+
// the caller's set union across a drag is a meaningful "how many were posed".
|
|
35
|
+
repair() {
|
|
36
|
+
const posed = [];
|
|
37
|
+
const poses = probeFor();
|
|
38
|
+
for (const name of viewSubParts(part, getView(), params)) {
|
|
39
|
+
if (cache.isCurrent(name) || !viewer.hasSubMesh(name)) continue;
|
|
40
|
+
const now = poses.get(name), was = stamps[name];
|
|
41
|
+
if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
|
|
42
|
+
viewer.setSubPose(name, poseDelta(now.pose, was.pose));
|
|
43
|
+
cache.record(name); // current again at these params — regen loop sees nothing missing
|
|
44
|
+
posed.push(name);
|
|
45
|
+
}
|
|
46
|
+
return posed;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
|
|
2
|
+
// whose token solids carry (a) a content-hash chain built with the shared h() and
|
|
3
|
+
// (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
|
|
4
|
+
// The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
|
|
5
|
+
// are never compared to backend hashes, so they only need to be stable and to
|
|
6
|
+
// fold every geometry-affecting argument.
|
|
7
|
+
//
|
|
8
|
+
// Trust model: any query op (boundingBox/volume/…) during a build marks that
|
|
9
|
+
// subpart untrusted — a query result could feed geometry OR pose, and the probe
|
|
10
|
+
// returns dummies, so neither hash stability nor pose values can be believed.
|
|
11
|
+
// A FUNCTION passed as (or nested inside) an op argument is untrusted for the
|
|
12
|
+
// same reason the OCCT backend refuses to hash function selectors (see `selKey`
|
|
13
|
+
// in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
|
|
14
|
+
// same source text at every value of `p.z`, so hashing it would hold baseHash
|
|
15
|
+
// stable while the real geometry changed — precisely the false-positive the fast
|
|
16
|
+
// path must never make. Untrusted subparts simply take the normal regen path.
|
|
17
|
+
import { h } from "./geometry/solid-hash.js";
|
|
18
|
+
import { addSugar } from "./geometry/solid-sugar.js";
|
|
19
|
+
import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
20
|
+
import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
|
|
21
|
+
import { viewSubParts, resolveParams } from "./jobs.js";
|
|
22
|
+
|
|
23
|
+
const NAN3 = () => [NaN, NaN, NaN];
|
|
24
|
+
|
|
25
|
+
function makeProbeSession() {
|
|
26
|
+
const state = { count: 0, queried: false, unhashable: false };
|
|
27
|
+
const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
|
|
28
|
+
|
|
29
|
+
// Queries return dummies AND poison trust (see module comment).
|
|
30
|
+
const QUERY_DUMMIES = {
|
|
31
|
+
boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
|
|
32
|
+
volume: () => NaN,
|
|
33
|
+
genus: () => NaN,
|
|
34
|
+
isEmpty: () => false,
|
|
35
|
+
area: () => NaN,
|
|
36
|
+
toRegions: () => [],
|
|
37
|
+
simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
|
|
38
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
|
|
39
|
+
toSTL: () => new ArrayBuffer(0),
|
|
40
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Operand tokens fold into a hash key by their own (pose-folded) hash; plain
|
|
44
|
+
// data canonicalizes via h(). Functions can't be hashed at all (see the module
|
|
45
|
+
// comment), so they poison trust AND get a per-call unique key — belt and
|
|
46
|
+
// braces, so the hash can't collide even before the trust check is consulted.
|
|
47
|
+
//
|
|
48
|
+
// The walk mirrors h()'s `canon` exactly (array → elements, other object → own
|
|
49
|
+
// enumerable values), because a function nested inside an options object —
|
|
50
|
+
// `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
|
|
51
|
+
// by canon, not by the top-level argument check.
|
|
52
|
+
let unhashable = 0;
|
|
53
|
+
const argKey = (a) => {
|
|
54
|
+
if (a && a.__poseToken) return a.__folded();
|
|
55
|
+
if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
|
|
56
|
+
if (Array.isArray(a)) return a.map(argKey);
|
|
57
|
+
if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
|
|
58
|
+
return a;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function token(hash, pose) {
|
|
62
|
+
const folded = () => (pose.length ? h("posed", hash, pose) : hash);
|
|
63
|
+
const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
|
|
64
|
+
const t = {
|
|
65
|
+
__poseToken: true,
|
|
66
|
+
__folded: folded,
|
|
67
|
+
_hash: hash,
|
|
68
|
+
_pose: pose,
|
|
69
|
+
// The rigid vocabulary stays out of the hash: recorded as pending steps,
|
|
70
|
+
// exactly like the OCCT backend's pose-lazy wrap. All transform sugar
|
|
71
|
+
// (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
|
|
72
|
+
translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
|
|
73
|
+
rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
|
|
74
|
+
clone: () => t, // tokens are immutable — sharing is safe
|
|
75
|
+
// regions() is a data-returning query in disguise: on a real backend the
|
|
76
|
+
// scission ARRAY LENGTH is param-dependent data, so a build branching on
|
|
77
|
+
// `regions().length` could hold baseHash stable while geometry changed.
|
|
78
|
+
// It therefore poisons trust like any other query; the single token is
|
|
79
|
+
// still returned so op chains on regions()[0] don't crash mid-probe.
|
|
80
|
+
regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
|
|
81
|
+
};
|
|
82
|
+
for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
|
|
83
|
+
t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
|
|
84
|
+
// Every other contract op folds pose + args into a fresh hash. Generated from
|
|
85
|
+
// the kernel-contract lists so new ops can never silently drift out of the probe.
|
|
86
|
+
for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
|
|
87
|
+
t[op] ??= foldOp(op);
|
|
88
|
+
return addSugar(t);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Kernel: catch-all factory — any op makes a fresh token hashed from its args.
|
|
92
|
+
const kernelQueries = {
|
|
93
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
94
|
+
cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
|
|
95
|
+
cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
|
|
96
|
+
};
|
|
97
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
98
|
+
const kernel = new Proxy({}, {
|
|
99
|
+
get(_t, key) {
|
|
100
|
+
if (ignore(key)) return undefined;
|
|
101
|
+
if (key in kernelQueries) return kernelQueries[key];
|
|
102
|
+
return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return { kernel, state };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
|
110
|
+
const stepsFinite = (steps) => steps.every((st) =>
|
|
111
|
+
st.t === "translate"
|
|
112
|
+
? finiteVec(st.v)
|
|
113
|
+
: Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
|
|
114
|
+
|
|
115
|
+
// Probe every subpart the view shows. Never throws; a failing/queried/weird
|
|
116
|
+
// subpart yields { trusted: false } and the others still probe.
|
|
117
|
+
export function probePoses(part, view, params) {
|
|
118
|
+
const out = new Map();
|
|
119
|
+
let resolved;
|
|
120
|
+
try { resolved = resolveParams(part, params); }
|
|
121
|
+
catch {
|
|
122
|
+
for (const name of viewSubParts(part, view, params)) out.set(name, { trusted: false });
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
const { p, d } = resolved;
|
|
126
|
+
for (const name of viewSubParts(part, view, params)) {
|
|
127
|
+
try {
|
|
128
|
+
const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
|
|
129
|
+
const sp = part.parts[name];
|
|
130
|
+
let s = sp.build(kernel, p, d);
|
|
131
|
+
if (sp.place) s = sp.place(s, { view, purpose: "display", p, d });
|
|
132
|
+
const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
|
|
133
|
+
out.set(name, ok ? { baseHash: s._hash, pose: s._pose, trusted: true } : { trusted: false });
|
|
134
|
+
} catch {
|
|
135
|
+
out.set(name, { trusted: false });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
@@ -75,9 +75,12 @@ export function attachHoverLabels(
|
|
|
75
75
|
emptyOverlayGeometry?.dispose();
|
|
76
76
|
emptyOverlayGeometry = null;
|
|
77
77
|
overlay.geometry = geometry;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Parent to the sub-part mesh, not to the group: the overlay geometry is a
|
|
79
|
+
// subset of the mesh's own (delivered-frame) vertices, so it must inherit
|
|
80
|
+
// whatever fast-path pose viewer.setSubPose has written onto that mesh.
|
|
81
|
+
if (overlayParent !== hit.mesh) {
|
|
82
|
+
hit.mesh.add(overlay);
|
|
83
|
+
overlayParent = hit.mesh;
|
|
81
84
|
}
|
|
82
85
|
overlay.visible = true;
|
|
83
86
|
}
|
|
@@ -7,13 +7,25 @@ const raycaster = new THREE.Raycaster();
|
|
|
7
7
|
const ndc = new THREE.Vector2();
|
|
8
8
|
|
|
9
9
|
// Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
|
|
10
|
-
// shared-frame CAD coords — the same frame build() models in.
|
|
10
|
+
// shared-frame CAD coords — the same frame build() models in. `worldToLocal` also
|
|
11
|
+
// undoes the mesh's own local matrix, which carries the viewer's fast-path pose
|
|
12
|
+
// (setSubPose), so re-apply that matrix to land in the CURRENT shared frame rather
|
|
13
|
+
// than the frame the delivered mesh was baked in.
|
|
11
14
|
export function worldToSubPartLocal(mesh, world) {
|
|
12
15
|
const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
|
|
13
16
|
mesh.worldToLocal(v);
|
|
17
|
+
v.applyMatrix4(mesh.matrix);
|
|
14
18
|
return [v.x, v.y, v.z];
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
const _normal = new THREE.Vector3();
|
|
22
|
+
|
|
23
|
+
// A geometry-frame direction in shared-frame CAD coords (see pointLocal above).
|
|
24
|
+
function normalInSubPartFrame(mesh, normal) {
|
|
25
|
+
_normal.copy(normal).transformDirection(mesh.matrix);
|
|
26
|
+
return [_normal.x, _normal.y, _normal.z];
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
// The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
|
|
18
30
|
export function featureAt(mesh, triIndex) {
|
|
19
31
|
const { featureIds, features } = mesh.geometry.userData;
|
|
@@ -38,9 +50,10 @@ export function raycastViewer(viewer, clientX, clientY) {
|
|
|
38
50
|
triIndex: hit.faceIndex,
|
|
39
51
|
pointWorld: hit.point,
|
|
40
52
|
pointLocal: worldToSubPartLocal(hit.object, hit.point),
|
|
41
|
-
// face.normal is in the geometry's
|
|
42
|
-
//
|
|
43
|
-
|
|
53
|
+
// face.normal is in the geometry's own frame; the mesh's local matrix (identity,
|
|
54
|
+
// or a rigid fast-path pose) carries it into the shared CAD frame. The pose is
|
|
55
|
+
// rigid, so rotating the direction by that matrix is exact.
|
|
56
|
+
normalLocal: hit.face ? normalInSubPartFrame(hit.object, hit.face.normal) : [0, 0, 0],
|
|
44
57
|
feature: featureAt(hit.object, hit.faceIndex),
|
|
45
58
|
};
|
|
46
59
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -209,6 +209,7 @@ export function createViewer(container, part) {
|
|
|
209
209
|
const subCache = Object.fromEntries(names.map((n) => [n, null]));
|
|
210
210
|
|
|
211
211
|
function setSubGeometry(name, payload) {
|
|
212
|
+
setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
|
|
212
213
|
const prev = subCache[name];
|
|
213
214
|
const next = buildGeometry(payload);
|
|
214
215
|
subCache[name] = next;
|
|
@@ -218,17 +219,38 @@ export function createViewer(container, part) {
|
|
|
218
219
|
if (prev) { prev.userData.edges?.dispose(); prev.dispose(); }
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// Presentational rigid pose for one sub-part (the pose fast path): applied to
|
|
223
|
+
// the mesh and its edge lines. `null` clears. Column-major mat16 (pose.js /
|
|
224
|
+
// three.js Matrix4 convention). Never affects exports or geometry — the worker
|
|
225
|
+
// owns real placement; this only re-poses the delivered mesh.
|
|
226
|
+
function setSubPose(name, mat16) {
|
|
227
|
+
for (const obj of [subMesh[name], subLines[name]]) {
|
|
228
|
+
if (!obj) continue;
|
|
229
|
+
obj.matrixAutoUpdate = false;
|
|
230
|
+
if (mat16) obj.matrix.fromArray(mat16);
|
|
231
|
+
else obj.matrix.identity();
|
|
232
|
+
obj.matrixWorldNeedsUpdate = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
// Cache queries for the app's regenerate loop (so it never reaches into subCache).
|
|
222
237
|
const hasSubMesh = (name) => !!subCache[name];
|
|
223
238
|
const subTriangles = (name) => subCache[name]?.userData.triangles ?? 0;
|
|
224
239
|
|
|
225
240
|
// --- show / hide assembly -------------------------------------------------
|
|
226
241
|
const _box = new THREE.Box3();
|
|
242
|
+
const _posedBox = new THREE.Box3();
|
|
227
243
|
|
|
228
244
|
// Recentre the assembly on the pivot and frame the camera to the named parts.
|
|
245
|
+
// Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
|
|
246
|
+
// pose has to be applied before the union or framing ignores the re-posing.
|
|
229
247
|
function frameTo(visibleNames) {
|
|
230
248
|
_box.makeEmpty();
|
|
231
|
-
for (const name of visibleNames)
|
|
249
|
+
for (const name of visibleNames) {
|
|
250
|
+
if (!subCache[name]) continue;
|
|
251
|
+
_posedBox.copy(subCache[name].boundingBox).applyMatrix4(subMesh[name].matrix);
|
|
252
|
+
_box.union(_posedBox);
|
|
253
|
+
}
|
|
232
254
|
if (_box.isEmpty()) return;
|
|
233
255
|
const center = _box.getCenter(new THREE.Vector3());
|
|
234
256
|
partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
|
|
@@ -457,6 +479,7 @@ export function createViewer(container, part) {
|
|
|
457
479
|
showAssembly,
|
|
458
480
|
hideAssembly,
|
|
459
481
|
setSubGeometry,
|
|
482
|
+
setSubPose,
|
|
460
483
|
hasSubMesh,
|
|
461
484
|
subTriangles,
|
|
462
485
|
frame,
|