partforge 0.53.0 → 0.54.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 +12 -2
- package/package.json +1 -1
- package/src/framework/app.css +27 -2
- package/src/framework/cutaway-controls.js +4 -27
- package/src/framework/cutaway-gizmo-scene.js +3 -1
- package/src/framework/measure/dim3-place.js +455 -0
- package/src/framework/measure/dim3-scene.js +438 -0
- package/src/framework/measure/feature-dims.js +258 -0
- package/src/framework/measure/measure-controls.js +110 -0
- package/src/framework/measure/measure-mode.js +546 -0
- package/src/framework/measure/param-link.js +32 -0
- package/src/framework/measure/pins.js +36 -0
- package/src/framework/mount.js +58 -5
- package/src/framework/panel/render.js +41 -0
- package/src/framework/selection/drag-tracker.js +39 -0
- package/src/framework/selection/feature-highlight.js +99 -0
- package/src/framework/selection/hover.js +14 -90
- package/src/framework/selection/index.js +1 -0
- package/src/framework/selection/pick.js +11 -37
- package/src/framework/teardown.js +29 -0
- package/src/framework/viewer.js +25 -1
- package/types/index.d.ts +16 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// PURE heuristic linking a measured dimension to the schema param driving it.
|
|
2
|
+
// Candidates come from the sub-part's read keys (param-deps.subPartReadKeys,
|
|
3
|
+
// resolved by the caller); a candidate links when its current value matches a
|
|
4
|
+
// measured value within the display quantum (0.01), or at value*2 for
|
|
5
|
+
// radius-style params against measured diameters. Unique match or nothing —
|
|
6
|
+
// never guess between two.
|
|
7
|
+
const QUANTUM = 0.005; // half the 0.01 display quantum: |a-b| < 0.005 rounds equal
|
|
8
|
+
|
|
9
|
+
// Every candidate whose current value matches a measured value (or matches
|
|
10
|
+
// diameter at value*2, the radius-style rule) — the set a measurement click
|
|
11
|
+
// flashes. linkParam keeps the stricter unique-or-nothing rule for focus.
|
|
12
|
+
export function paramMatches(keys, params, values) {
|
|
13
|
+
const measured = Object.entries(values)
|
|
14
|
+
.filter(([k, v]) => typeof v === "number" && k !== "partial")
|
|
15
|
+
.map(([, v]) => v);
|
|
16
|
+
const hasDiameter = "diameter" in values;
|
|
17
|
+
const matches = new Set();
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
const pv = params[key];
|
|
20
|
+
if (typeof pv !== "number") continue;
|
|
21
|
+
for (const mv of measured) {
|
|
22
|
+
if (Math.abs(pv - mv) < QUANTUM) { matches.add(key); break; }
|
|
23
|
+
if (hasDiameter && Math.abs(pv * 2 - values.diameter) < QUANTUM) { matches.add(key); break; }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...matches];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function linkParam(keys, params, values) {
|
|
30
|
+
const matches = paramMatches(keys, params, values);
|
|
31
|
+
return matches.length === 1 ? matches[0] : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// PURE pin store for measurement mode. Pins are PER-VIEW and keyed on stable
|
|
2
|
+
// identity — (subPart, featureLabel|null, occurrence) — not on geometry, so a
|
|
3
|
+
// regenerate re-resolves them by label (dormant when the label is gone, revived
|
|
4
|
+
// when it returns). `occurrence` disambiguates duplicate labels: it counts
|
|
5
|
+
// same-label features earlier in the features table.
|
|
6
|
+
const keyString = ({ subPart, featureLabel, occurrence }) =>
|
|
7
|
+
`${subPart}\n${featureLabel}\n${occurrence}`;
|
|
8
|
+
|
|
9
|
+
export function occurrenceOf(features, featureId) {
|
|
10
|
+
const label = features[featureId - 1];
|
|
11
|
+
let n = 0;
|
|
12
|
+
for (let i = 0; i < featureId - 1; i++) if (features[i] === label) n++;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPinStore() {
|
|
17
|
+
const byView = new Map(); // view -> Map(keyString -> key)
|
|
18
|
+
const viewMap = (view) => {
|
|
19
|
+
let m = byView.get(view);
|
|
20
|
+
if (!m) { m = new Map(); byView.set(view, m); }
|
|
21
|
+
return m;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
// -> true when the pin was added, false when it was removed
|
|
25
|
+
toggle(view, key) {
|
|
26
|
+
const m = viewMap(view), ks = keyString(key);
|
|
27
|
+
if (m.has(ks)) { m.delete(ks); return false; }
|
|
28
|
+
m.set(ks, { ...key, occurrence: key.occurrence ?? 0 });
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
has: (view, key) => viewMap(view).has(keyString(key)),
|
|
32
|
+
list: (view) => [...viewMap(view).values()],
|
|
33
|
+
clear: (view) => { viewMap(view).clear(); },
|
|
34
|
+
count: (view) => viewMap(view).size,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -26,14 +26,19 @@ import { createExportController, backendForFormat } from "./export-controller.js
|
|
|
26
26
|
import { createCaptureBuild } from "./capture-build.js";
|
|
27
27
|
import { attachAnimationControls } from "./animation-controls.js";
|
|
28
28
|
import { resolveDefaultView } from "./default-view.js";
|
|
29
|
+
import { createMeasureMode } from "./measure/measure-mode.js";
|
|
30
|
+
import { attachMeasureControls } from "./measure/measure-controls.js";
|
|
29
31
|
|
|
30
32
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
31
33
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
32
34
|
// The default no-op tooltip binding, so a host can hold on to whatever
|
|
33
35
|
// attachTooltips returned without caring whether this mount resolved one.
|
|
34
36
|
const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {} };
|
|
37
|
+
// Same no-op-default stance as attachTooltips/setHostPane below, for a
|
|
38
|
+
// makeHandle caller (or a direct test) that doesn't wire measure mode.
|
|
39
|
+
const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
|
|
35
40
|
|
|
36
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips }) {
|
|
41
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
|
|
37
42
|
return {
|
|
38
43
|
ready, dispose, setParams,
|
|
39
44
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -70,6 +75,11 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
70
75
|
// title/aria-label); returns { sync, hide, detach }. Same no-op default
|
|
71
76
|
// stance as setHostPane above.
|
|
72
77
|
attachTooltips: attachTooltips ?? (() => NOOP_TOOLTIP_BINDING),
|
|
78
|
+
// Measurement-mode API (spec Goal 3): { isEnabled, setEnabled, clearPins,
|
|
79
|
+
// pinCount } — an embedder drives the mode without the built-in ruler
|
|
80
|
+
// button. Dimensions render in the scene, so a dimensioned capture is just
|
|
81
|
+
// captureCurrent() taken while the mode is on.
|
|
82
|
+
measure: measure ?? NOOP_MEASURE,
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
85
|
|
|
@@ -206,6 +216,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
206
216
|
reframe: elements.chrome?.reframe ?? byId("reframe"),
|
|
207
217
|
theme: elements.chrome?.theme ?? byId("theme"),
|
|
208
218
|
cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
|
|
219
|
+
measure: elements.chrome?.measure ?? byId("measure"),
|
|
209
220
|
railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
|
|
210
221
|
},
|
|
211
222
|
};
|
|
@@ -216,10 +227,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
216
227
|
cleanup.defer(() => viewer.dispose());
|
|
217
228
|
const tooltip = createTooltipPresenter({ id: null });
|
|
218
229
|
cleanup.defer(() => tooltip.dispose());
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
230
|
+
// cutawayChrome + measureMode/measureChrome are attached further down (just
|
|
231
|
+
// after `params` exists — measure's getContext needs both `view()` and
|
|
232
|
+
// `params`, and cutaway needs measureMode for its escapeGuard). Nothing
|
|
233
|
+
// between here and there depends on cutawayChrome except the view-tabs
|
|
234
|
+
// onChange closure below, which only runs on a later user/programmatic tab
|
|
235
|
+
// change, never during this synchronous setup.
|
|
223
236
|
// Resizable/collapsible controls rail. No-ops when the host lays out the
|
|
224
237
|
// framework itself (no #panel / no elements.rail).
|
|
225
238
|
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell, tooltip });
|
|
@@ -283,6 +296,39 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
283
296
|
onViewChange?.(tabsCtl.current());
|
|
284
297
|
const params = { ...part.defaults };
|
|
285
298
|
|
|
299
|
+
// Measurement mode: in-scene dims + pins + dimension->controls reveal
|
|
300
|
+
// (clicking a measurement flashes every control that can drive it). The
|
|
301
|
+
// panel is built later in this function, so revealParams is a late-bound
|
|
302
|
+
// thunk — same idiom for getParamsVersion, which needs `loop` (created
|
|
303
|
+
// further below); readsFor's memo keys on it instead of hashing the whole
|
|
304
|
+
// params object per call, mirroring createMeshCache/createPoseFastPath.
|
|
305
|
+
let panelRef = null;
|
|
306
|
+
const measureMode = createMeasureMode(viewer, {
|
|
307
|
+
part,
|
|
308
|
+
getContext: () => ({ view: view(), params }),
|
|
309
|
+
revealParams: (keys, focusKey) => panelRef?.revealParams(keys, focusKey),
|
|
310
|
+
getParamsVersion: () => loop.version(),
|
|
311
|
+
});
|
|
312
|
+
cleanup.defer(() => measureMode.detach());
|
|
313
|
+
// escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
|
|
314
|
+
// #viewbar, not descendants of the canvas — attaching Escape to
|
|
315
|
+
// viewer.domElement alone would leave a guarded Escape from those buttons
|
|
316
|
+
// dead. els.viewer (the stage) is a shared ancestor of both, so Escape
|
|
317
|
+
// bubbles up from either.
|
|
318
|
+
const measureChrome = attachMeasureControls(viewer, measureMode, {
|
|
319
|
+
measure: els.chrome.measure,
|
|
320
|
+
}, { tooltip, escapeScope: els.viewer });
|
|
321
|
+
cleanup.defer(() => measureChrome.detach());
|
|
322
|
+
const cutawayChrome = attachCutawayControls(viewer, {
|
|
323
|
+
cutaway: els.chrome.cutaway,
|
|
324
|
+
}, { tooltip, escapeGuard: () => measureMode.isEnabled() });
|
|
325
|
+
cleanup.defer(() => cutawayChrome.detach());
|
|
326
|
+
// Suppress the always-on hover tooltip while measure mode is active — its
|
|
327
|
+
// own feature highlight + dims take over the pointer.
|
|
328
|
+
const offMeasureHover = measureMode.onModeChange(() =>
|
|
329
|
+
hover.setSuppressed(measureMode.isEnabled()));
|
|
330
|
+
cleanup.defer(offMeasureHover);
|
|
331
|
+
|
|
286
332
|
// Current selection context for the pickers: the active view + live params +
|
|
287
333
|
// derived values. Shared by every pick mode below.
|
|
288
334
|
const getContext = () => {
|
|
@@ -530,6 +576,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
530
576
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
531
577
|
: undefined);
|
|
532
578
|
cleanup.defer(() => panel.dispose());
|
|
579
|
+
panelRef = panel;
|
|
533
580
|
const updateRelevance = () => {
|
|
534
581
|
// A throwing derive() must not break every slider drag — mount's pick
|
|
535
582
|
// flow already guards its own resolveDerived call the same way
|
|
@@ -681,6 +728,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
681
728
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
682
729
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
683
730
|
animation: animCtl?.runtime ?? null,
|
|
731
|
+
measure: {
|
|
732
|
+
isEnabled: measureMode.isEnabled,
|
|
733
|
+
setEnabled: measureMode.setEnabled,
|
|
734
|
+
clearPins: measureMode.clearPins,
|
|
735
|
+
pinCount: measureMode.pinCount,
|
|
736
|
+
},
|
|
684
737
|
});
|
|
685
738
|
} catch (error) {
|
|
686
739
|
try {
|
|
@@ -37,6 +37,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
37
37
|
const nodeById = new Map(); // id -> node, for the reveal re-sync
|
|
38
38
|
const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
|
|
39
39
|
const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
|
|
40
|
+
const keyToId = new Map(); // param key -> node id, for revealParam
|
|
40
41
|
// Containers that own a disclosure: sections, and titled inner groups (the
|
|
41
42
|
// legacy "Advanced" fold). Both share the same anatomy — a header row with
|
|
42
43
|
// the aria-carrying button and a text-free chevron span — so `el` (the
|
|
@@ -226,6 +227,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
226
227
|
info,
|
|
227
228
|
});
|
|
228
229
|
nodeEls.set(node.id, widget.el);
|
|
230
|
+
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|
|
229
231
|
widgetSyncs.set(node.id, widget.sync);
|
|
230
232
|
container.append(widget.el);
|
|
231
233
|
|
|
@@ -310,6 +312,45 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
310
312
|
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
311
313
|
applyState();
|
|
312
314
|
},
|
|
315
|
+
// Measurement mode's dimension->control link: open whatever encloses the
|
|
316
|
+
// control, bring it on screen, hand it keyboard focus, and pulse the flash
|
|
317
|
+
// so the eye lands on it. DOM-containment (not tree walking) finds the
|
|
318
|
+
// enclosing disclosures, so section vs fold nesting needs no special case.
|
|
319
|
+
revealParam(key) {
|
|
320
|
+
return this.revealParams([key], key);
|
|
321
|
+
},
|
|
322
|
+
// Multi-control reveal: a measurement is usually a FUNCTION of several
|
|
323
|
+
// params, so clicking one flashes every control that can drive it.
|
|
324
|
+
// `focusKey` (when given and present) is the one whose value exactly
|
|
325
|
+
// matches the clicked dimension — it gets keyboard focus; otherwise the
|
|
326
|
+
// first flashed control is only scrolled to, no focus steal.
|
|
327
|
+
revealParams(keys, focusKey = null) {
|
|
328
|
+
const targets = [];
|
|
329
|
+
for (const key of keys) {
|
|
330
|
+
const id = keyToId.get(key);
|
|
331
|
+
const el = id && nodeEls.get(id);
|
|
332
|
+
if (el) targets.push([key, el]);
|
|
333
|
+
}
|
|
334
|
+
if (!targets.length) return false;
|
|
335
|
+
for (const [, target] of targets) {
|
|
336
|
+
for (const [, d] of disclosures) {
|
|
337
|
+
if (!d.body.contains(target) || !d.body.classList.contains("hidden")) continue;
|
|
338
|
+
d.body.classList.remove("hidden");
|
|
339
|
+
d.button.setAttribute("aria-expanded", "true");
|
|
340
|
+
d.el.classList.remove("collapsed");
|
|
341
|
+
}
|
|
342
|
+
target.classList.remove("pf-param-flash");
|
|
343
|
+
void target.offsetWidth; // restart the animation on repeat reveals
|
|
344
|
+
target.classList.add("pf-param-flash");
|
|
345
|
+
target.addEventListener("animationend", () => target.classList.remove("pf-param-flash"), { once: true });
|
|
346
|
+
}
|
|
347
|
+
const [primaryKey, primary] = targets.find(([k]) => k === focusKey) ?? targets[0];
|
|
348
|
+
primary.scrollIntoView?.({ block: "center" });
|
|
349
|
+
if (primaryKey === focusKey) {
|
|
350
|
+
primary.querySelector("input, select, textarea, .seg button")?.focus({ preventScroll: true });
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
},
|
|
313
354
|
dispose: () => { info.dispose(); root.replaceChildren(); },
|
|
314
355
|
};
|
|
315
356
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Shared drag-threshold state machine: distinguishes a click from a
|
|
2
|
+
// click-terminated orbit/pan gesture. Multiple pointers are tracked (a second
|
|
3
|
+
// finger joining mid-gesture must not un-arm the drag flag — see
|
|
4
|
+
// selection-pick.test.js's "another pointer joins" case); the drag flag only
|
|
5
|
+
// resets once every pointer has lifted. Lifted out of selection/pick.js so
|
|
6
|
+
// measure-mode.js shares one implementation and one threshold instead of a
|
|
7
|
+
// second, divergent copy.
|
|
8
|
+
export function createDragTracker({ thresholdSquared = 16 } = {}) {
|
|
9
|
+
const pointerStarts = new Map();
|
|
10
|
+
let dragged = false;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
onDown(ev) {
|
|
14
|
+
if (pointerStarts.size === 0) dragged = false;
|
|
15
|
+
pointerStarts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
16
|
+
},
|
|
17
|
+
onMove(ev) {
|
|
18
|
+
const start = pointerStarts.get(ev.pointerId);
|
|
19
|
+
if (!start || dragged) return;
|
|
20
|
+
const dx = ev.clientX - start.x, dy = ev.clientY - start.y;
|
|
21
|
+
dragged = dx * dx + dy * dy > thresholdSquared;
|
|
22
|
+
},
|
|
23
|
+
onUp(ev) {
|
|
24
|
+
pointerStarts.delete(ev.pointerId);
|
|
25
|
+
},
|
|
26
|
+
onCancel(ev) {
|
|
27
|
+
pointerStarts.delete(ev.pointerId);
|
|
28
|
+
if (pointerStarts.size === 0) dragged = false;
|
|
29
|
+
},
|
|
30
|
+
// Reads and resets drag state for a click: call at the top of the click
|
|
31
|
+
// handler, before deciding whether to act on it.
|
|
32
|
+
consumeClick() {
|
|
33
|
+
const wasDragged = dragged;
|
|
34
|
+
pointerStarts.clear();
|
|
35
|
+
dragged = false;
|
|
36
|
+
return wasDragged;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Shared surface-highlight helper: an overlay mesh tinting one feature's
|
|
2
|
+
// triangle subset (or a whole sub-part). Extracted from hover.js so the hover
|
|
3
|
+
// tooltip and measurement mode share one implementation and one subset cache.
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
|
|
6
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
7
|
+
|
|
8
|
+
const HIGHLIGHT = 0x4da3ff;
|
|
9
|
+
|
|
10
|
+
// Extract the subset of a geometry belonging to one feature id. Handles both
|
|
11
|
+
// non-indexed (Manifold) and indexed (OCCT) payloads.
|
|
12
|
+
function featureSubset(geometry, featureId) {
|
|
13
|
+
const { featureIds } = geometry.userData;
|
|
14
|
+
const pos = geometry.getAttribute("position");
|
|
15
|
+
const index = geometry.getIndex();
|
|
16
|
+
const vertAt = index ? (t, v) => index.getX(t * 3 + v) : (t, v) => t * 3 + v;
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (let t = 0; t < featureIds.length; t++) if (featureIds[t] === featureId) count++;
|
|
19
|
+
const out = new Float32Array(count * 9);
|
|
20
|
+
let o = 0;
|
|
21
|
+
for (let t = 0; t < featureIds.length; t++) {
|
|
22
|
+
if (featureIds[t] !== featureId) continue;
|
|
23
|
+
for (let v = 0; v < 3; v++) {
|
|
24
|
+
const i = vertAt(t, v);
|
|
25
|
+
out[o++] = pos.getX(i); out[o++] = pos.getY(i); out[o++] = pos.getZ(i);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const g = new THREE.BufferGeometry();
|
|
29
|
+
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
|
|
30
|
+
return g;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createFeatureHighlight(viewer) {
|
|
34
|
+
const material = new THREE.MeshBasicMaterial({
|
|
35
|
+
color: HIGHLIGHT, transparent: true, opacity: 0.35,
|
|
36
|
+
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
37
|
+
});
|
|
38
|
+
const unregisterCutaway = viewer.registerCutawayMaterial?.(material) ?? (() => {});
|
|
39
|
+
let emptyOverlayGeometry = new THREE.BufferGeometry();
|
|
40
|
+
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
41
|
+
overlay.visible = false;
|
|
42
|
+
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
43
|
+
let overlayParent = null;
|
|
44
|
+
// Subset cache per sub-part: rebuilt when the sub-part's geometry object
|
|
45
|
+
// changes (i.e. after a regenerate) — keyed on the geometry instance.
|
|
46
|
+
const subsets = new Map(); // subPart -> { geo, byId: Map(featureId -> BufferGeometry) }
|
|
47
|
+
|
|
48
|
+
function mount(geometry, mesh) {
|
|
49
|
+
emptyOverlayGeometry?.dispose();
|
|
50
|
+
emptyOverlayGeometry = null;
|
|
51
|
+
overlay.geometry = geometry;
|
|
52
|
+
// Parent to the sub-part mesh, not the scene: the overlay geometry is a
|
|
53
|
+
// subset of the mesh's own (delivered-frame) vertices, so it must inherit
|
|
54
|
+
// whatever fast-path pose viewer.setSubPose has written onto that mesh.
|
|
55
|
+
if (overlayParent !== mesh) {
|
|
56
|
+
mesh.add(overlay);
|
|
57
|
+
overlayParent = mesh;
|
|
58
|
+
}
|
|
59
|
+
overlay.visible = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
show(hit) {
|
|
64
|
+
if (!hit.feature) { mount(hit.mesh.geometry, hit.mesh); return; }
|
|
65
|
+
const cached = subsets.get(hit.subPart);
|
|
66
|
+
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
67
|
+
if (!byId) {
|
|
68
|
+
for (const g of cached?.byId.values() ?? []) g.dispose();
|
|
69
|
+
byId = new Map();
|
|
70
|
+
subsets.set(hit.subPart, { geo: hit.mesh.geometry, byId });
|
|
71
|
+
}
|
|
72
|
+
let g = byId.get(hit.feature.id);
|
|
73
|
+
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
74
|
+
mount(g, hit.mesh);
|
|
75
|
+
},
|
|
76
|
+
clear() { overlay.visible = false; },
|
|
77
|
+
dispose() {
|
|
78
|
+
// Every step isolated: a throw disposing one cached subset (or
|
|
79
|
+
// unregistering from cutaway) must not skip the rest — same discipline
|
|
80
|
+
// as hover.js's own cleanup list, which this dispose() call is itself
|
|
81
|
+
// one step of (test/selection-hover.test.js's aggregated-failure case
|
|
82
|
+
// exercises both layers together).
|
|
83
|
+
const steps = [
|
|
84
|
+
() => { overlay.visible = false; },
|
|
85
|
+
() => { overlayParent?.remove(overlay); },
|
|
86
|
+
];
|
|
87
|
+
for (const { byId } of subsets.values()) {
|
|
88
|
+
for (const g of byId.values()) steps.push(() => g.dispose());
|
|
89
|
+
}
|
|
90
|
+
steps.push(
|
|
91
|
+
() => subsets.clear(),
|
|
92
|
+
() => emptyOverlayGeometry?.dispose(),
|
|
93
|
+
unregisterCutaway,
|
|
94
|
+
() => material.dispose(),
|
|
95
|
+
);
|
|
96
|
+
runCleanupSteps(steps, "feature highlight cleanup failed");
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -2,42 +2,10 @@
|
|
|
2
2
|
// sub-part under the pointer, and an overlay mesh highlighting the feature's
|
|
3
3
|
// surface. Feature names come from Solid.label() in the part's build, carried
|
|
4
4
|
// per-triangle in the mesh payload (geometry.userData.featureIds/features).
|
|
5
|
-
import * as THREE from "three";
|
|
6
|
-
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
|
|
7
5
|
import { createTooltipPresenter } from "../tooltip.js";
|
|
8
6
|
import { raycastViewer } from "./raycast.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function runCleanupSteps(steps) {
|
|
13
|
-
const errors = [];
|
|
14
|
-
for (const step of steps) {
|
|
15
|
-
try { step(); } catch (error) { errors.push(error); }
|
|
16
|
-
}
|
|
17
|
-
if (errors.length === 1) throw errors[0];
|
|
18
|
-
if (errors.length > 1) {
|
|
19
|
-
throw new AggregateError(errors, "feature hover cleanup failed");
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Extract the subset of a non-indexed geometry belonging to one feature id.
|
|
24
|
-
function featureSubset(geometry, featureId) {
|
|
25
|
-
const { featureIds } = geometry.userData;
|
|
26
|
-
const pos = geometry.getAttribute("position");
|
|
27
|
-
let count = 0;
|
|
28
|
-
for (let t = 0; t < featureIds.length; t++) if (featureIds[t] === featureId) count++;
|
|
29
|
-
const out = new Float32Array(count * 9);
|
|
30
|
-
let o = 0;
|
|
31
|
-
for (let t = 0; t < featureIds.length; t++) {
|
|
32
|
-
if (featureIds[t] !== featureId) continue;
|
|
33
|
-
for (let v = 0; v < 3; v++) {
|
|
34
|
-
out[o++] = pos.getX(t * 3 + v); out[o++] = pos.getY(t * 3 + v); out[o++] = pos.getZ(t * 3 + v);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const g = new THREE.BufferGeometry();
|
|
38
|
-
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
|
|
39
|
-
return g;
|
|
40
|
-
}
|
|
7
|
+
import { createFeatureHighlight } from "./feature-highlight.js";
|
|
8
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
41
9
|
|
|
42
10
|
export function attachHoverLabels(
|
|
43
11
|
viewer,
|
|
@@ -51,67 +19,27 @@ export function attachHoverLabels(
|
|
|
51
19
|
let presentationToken;
|
|
52
20
|
let hasPresented = false;
|
|
53
21
|
|
|
54
|
-
const
|
|
55
|
-
color: HIGHLIGHT, transparent: true, opacity: 0.35,
|
|
56
|
-
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
57
|
-
});
|
|
58
|
-
const unregisterCutaway = viewer.registerCutawayMaterial?.(material) ?? (() => {});
|
|
59
|
-
let emptyOverlayGeometry = new THREE.BufferGeometry();
|
|
60
|
-
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
61
|
-
overlay.visible = false;
|
|
62
|
-
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
63
|
-
let overlayParent = null;
|
|
64
|
-
// Subset cache per sub-part: rebuilt when the sub-part's geometry object changes
|
|
65
|
-
// (i.e. after a regenerate) — keyed on the geometry instance.
|
|
66
|
-
const subsets = new Map(); // subPart -> { geo, byId: Map(featureId -> BufferGeometry) }
|
|
22
|
+
const highlight = createFeatureHighlight(viewer);
|
|
67
23
|
|
|
68
24
|
const subLabel = (name) => part.parts[name]?.label ?? name;
|
|
69
25
|
|
|
70
|
-
function clearHighlight() {
|
|
71
|
-
overlay.visible = false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function showHighlight(hit, geometry) {
|
|
75
|
-
emptyOverlayGeometry?.dispose();
|
|
76
|
-
emptyOverlayGeometry = null;
|
|
77
|
-
overlay.geometry = geometry;
|
|
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;
|
|
84
|
-
}
|
|
85
|
-
overlay.visible = true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
26
|
function hide() {
|
|
89
27
|
if (hasPresented) {
|
|
90
28
|
hasPresented = false;
|
|
91
29
|
tooltipPresenter.hide(presentationToken);
|
|
92
30
|
presentationToken = undefined;
|
|
93
31
|
}
|
|
94
|
-
|
|
32
|
+
highlight.clear();
|
|
95
33
|
}
|
|
96
34
|
|
|
97
35
|
function show(hit, x, y) {
|
|
98
36
|
let content;
|
|
99
37
|
if (hit.feature) {
|
|
100
38
|
content = { title: hit.feature.label, subtitle: subLabel(hit.subPart) };
|
|
101
|
-
const cached = subsets.get(hit.subPart);
|
|
102
|
-
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
103
|
-
if (!byId) {
|
|
104
|
-
for (const g of cached?.byId.values() ?? []) g.dispose();
|
|
105
|
-
byId = new Map();
|
|
106
|
-
subsets.set(hit.subPart, { geo: hit.mesh.geometry, byId });
|
|
107
|
-
}
|
|
108
|
-
let g = byId.get(hit.feature.id);
|
|
109
|
-
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
110
|
-
showHighlight(hit, g);
|
|
111
39
|
} else {
|
|
112
40
|
content = { title: subLabel(hit.subPart), subtitle: "" };
|
|
113
|
-
showHighlight(hit, hit.mesh.geometry);
|
|
114
41
|
}
|
|
42
|
+
highlight.show(hit);
|
|
115
43
|
if (hasPresented) {
|
|
116
44
|
hasPresented = false;
|
|
117
45
|
tooltipPresenter.hide(presentationToken);
|
|
@@ -127,6 +55,7 @@ export function attachHoverLabels(
|
|
|
127
55
|
let down = false;
|
|
128
56
|
let detached = false;
|
|
129
57
|
let suppressed = false;
|
|
58
|
+
let externallySuppressed = false;
|
|
130
59
|
|
|
131
60
|
function invalidatePendingWork() {
|
|
132
61
|
pending = null;
|
|
@@ -144,7 +73,7 @@ export function attachHoverLabels(
|
|
|
144
73
|
function onMove(ev) {
|
|
145
74
|
if (detached) return;
|
|
146
75
|
if (ev.pointerType === "touch") return;
|
|
147
|
-
if (down || suppressed) return;
|
|
76
|
+
if (down || suppressed || externallySuppressed) return;
|
|
148
77
|
pending = { x: ev.clientX, y: ev.clientY };
|
|
149
78
|
if (frameScheduled) return;
|
|
150
79
|
frameScheduled = true;
|
|
@@ -154,7 +83,7 @@ export function attachHoverLabels(
|
|
|
154
83
|
frameScheduled = false;
|
|
155
84
|
const p = pending;
|
|
156
85
|
pending = null;
|
|
157
|
-
if (detached || !p || down || suppressed) return;
|
|
86
|
+
if (detached || !p || down || suppressed || externallySuppressed) return;
|
|
158
87
|
const hit = raycastViewer(viewer, p.x, p.y);
|
|
159
88
|
if (hit) show(hit, p.x, p.y); else hide();
|
|
160
89
|
});
|
|
@@ -169,14 +98,14 @@ export function attachHoverLabels(
|
|
|
169
98
|
viewer.domElement.addEventListener("pointerleave", onLeave);
|
|
170
99
|
|
|
171
100
|
return {
|
|
101
|
+
setSuppressed(on) {
|
|
102
|
+
externallySuppressed = !!on;
|
|
103
|
+
if (externallySuppressed) { invalidatePendingWork(); hide(); }
|
|
104
|
+
},
|
|
172
105
|
detach: () => {
|
|
173
106
|
if (detached) return;
|
|
174
107
|
detached = true;
|
|
175
108
|
invalidatePendingWork();
|
|
176
|
-
const subsetGeometries = [...subsets.values()]
|
|
177
|
-
.flatMap(({ byId }) => [...byId.values()]);
|
|
178
|
-
const initialGeometry = emptyOverlayGeometry;
|
|
179
|
-
emptyOverlayGeometry = null;
|
|
180
109
|
runCleanupSteps([
|
|
181
110
|
unsubscribeHandleHover,
|
|
182
111
|
() => viewer.domElement.removeEventListener("pointermove", onMove),
|
|
@@ -184,14 +113,9 @@ export function attachHoverLabels(
|
|
|
184
113
|
() => viewer.domElement.removeEventListener("pointerup", onUp),
|
|
185
114
|
() => viewer.domElement.removeEventListener("pointerleave", onLeave),
|
|
186
115
|
hide,
|
|
187
|
-
() =>
|
|
188
|
-
...subsetGeometries.map((geometry) => () => geometry.dispose()),
|
|
189
|
-
() => subsets.clear(),
|
|
190
|
-
() => initialGeometry?.dispose(),
|
|
191
|
-
unregisterCutaway,
|
|
192
|
-
() => material.dispose(),
|
|
116
|
+
() => highlight.dispose(),
|
|
193
117
|
() => { if (ownsTooltip) tooltipPresenter.dispose(); },
|
|
194
|
-
]);
|
|
118
|
+
], "feature hover cleanup failed");
|
|
195
119
|
},
|
|
196
120
|
};
|
|
197
121
|
}
|
|
@@ -6,3 +6,4 @@ export { attachPicker, worldToSubPartLocal } from "./pick.js";
|
|
|
6
6
|
export { attachPickToggle } from "./pick-toggle.js";
|
|
7
7
|
export { raycastViewer, featureAt } from "./raycast.js";
|
|
8
8
|
export { attachHoverLabels } from "./hover.js";
|
|
9
|
+
export { createFeatureHighlight } from "./feature-highlight.js";
|
|
@@ -2,42 +2,16 @@
|
|
|
2
2
|
// selection raycast, and hands a resolved Selection to onPick.
|
|
3
3
|
import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
|
|
4
4
|
import { resolveSelection } from "./resolve.js";
|
|
5
|
+
import { createDragTracker } from "./drag-tracker.js";
|
|
5
6
|
|
|
6
7
|
export { worldToSubPartLocal };
|
|
7
8
|
|
|
8
|
-
const DRAG_THRESHOLD_SQUARED = 4 ** 2;
|
|
9
|
-
|
|
10
9
|
export function attachPicker(viewer, { part, getContext, onPick }) {
|
|
11
10
|
let active = false;
|
|
12
|
-
const
|
|
13
|
-
let dragged = false;
|
|
14
|
-
|
|
15
|
-
function onPointerDown(ev) {
|
|
16
|
-
if (pointerStarts.size === 0) dragged = false;
|
|
17
|
-
pointerStarts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function onPointerMove(ev) {
|
|
21
|
-
const pointerStart = pointerStarts.get(ev.pointerId);
|
|
22
|
-
if (!pointerStart || dragged) return;
|
|
23
|
-
const dx = ev.clientX - pointerStart.x;
|
|
24
|
-
const dy = ev.clientY - pointerStart.y;
|
|
25
|
-
dragged = dx * dx + dy * dy > DRAG_THRESHOLD_SQUARED;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function onPointerUp(ev) {
|
|
29
|
-
pointerStarts.delete(ev.pointerId);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function onPointerCancel(ev) {
|
|
33
|
-
pointerStarts.delete(ev.pointerId);
|
|
34
|
-
if (pointerStarts.size === 0) dragged = false;
|
|
35
|
-
}
|
|
11
|
+
const drag = createDragTracker();
|
|
36
12
|
|
|
37
13
|
function onClick(ev) {
|
|
38
|
-
const wasDragged =
|
|
39
|
-
pointerStarts.clear();
|
|
40
|
-
dragged = false;
|
|
14
|
+
const wasDragged = drag.consumeClick();
|
|
41
15
|
if (!active || wasDragged) return;
|
|
42
16
|
const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
|
|
43
17
|
if (!hit) return;
|
|
@@ -46,18 +20,18 @@ export function attachPicker(viewer, { part, getContext, onPick }) {
|
|
|
46
20
|
onPick(selection);
|
|
47
21
|
}
|
|
48
22
|
|
|
49
|
-
viewer.domElement.addEventListener("pointerdown",
|
|
50
|
-
viewer.domElement.addEventListener("pointermove",
|
|
51
|
-
viewer.domElement.addEventListener("pointerup",
|
|
52
|
-
viewer.domElement.addEventListener("pointercancel",
|
|
23
|
+
viewer.domElement.addEventListener("pointerdown", drag.onDown);
|
|
24
|
+
viewer.domElement.addEventListener("pointermove", drag.onMove);
|
|
25
|
+
viewer.domElement.addEventListener("pointerup", drag.onUp);
|
|
26
|
+
viewer.domElement.addEventListener("pointercancel", drag.onCancel);
|
|
53
27
|
viewer.domElement.addEventListener("click", onClick);
|
|
54
28
|
return {
|
|
55
29
|
setActive: (on) => { active = !!on; },
|
|
56
30
|
detach: () => {
|
|
57
|
-
viewer.domElement.removeEventListener("pointerdown",
|
|
58
|
-
viewer.domElement.removeEventListener("pointermove",
|
|
59
|
-
viewer.domElement.removeEventListener("pointerup",
|
|
60
|
-
viewer.domElement.removeEventListener("pointercancel",
|
|
31
|
+
viewer.domElement.removeEventListener("pointerdown", drag.onDown);
|
|
32
|
+
viewer.domElement.removeEventListener("pointermove", drag.onMove);
|
|
33
|
+
viewer.domElement.removeEventListener("pointerup", drag.onUp);
|
|
34
|
+
viewer.domElement.removeEventListener("pointercancel", drag.onCancel);
|
|
61
35
|
viewer.domElement.removeEventListener("click", onClick);
|
|
62
36
|
},
|
|
63
37
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared teardown helpers: run a list of cleanup steps in isolation (one
|
|
2
|
+
// step's throw must never skip the others), and capture/restore a DOM
|
|
3
|
+
// element's attributes across an attach/detach cycle. Lifted out of
|
|
4
|
+
// cutaway-controls.js (the original) so measure-controls.js and
|
|
5
|
+
// selection/hover.js — and anything else that wraps host DOM — share one
|
|
6
|
+
// implementation instead of drifting copies.
|
|
7
|
+
|
|
8
|
+
export function runCleanupSteps(steps, message) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
for (const step of steps) {
|
|
11
|
+
try { step(); } catch (error) { errors.push(error); }
|
|
12
|
+
}
|
|
13
|
+
if (errors.length === 1) throw errors[0];
|
|
14
|
+
if (errors.length > 1) throw new AggregateError(errors, message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function captureAttributes(element, names) {
|
|
18
|
+
return new Map(names.map((name) => [name, {
|
|
19
|
+
present: element.hasAttribute(name),
|
|
20
|
+
value: element.getAttribute(name),
|
|
21
|
+
}]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function restoreAttributes(element, attributes) {
|
|
25
|
+
for (const [name, { present, value }] of attributes) {
|
|
26
|
+
if (present) element.setAttribute(name, value);
|
|
27
|
+
else element.removeAttribute(name);
|
|
28
|
+
}
|
|
29
|
+
}
|