partforge 0.52.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.
@@ -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
+ }
@@ -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
- const cutawayChrome = attachCutawayControls(viewer, {
220
- cutaway: els.chrome.cutaway,
221
- }, { tooltip });
222
- cleanup.defer(() => cutawayChrome.detach());
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
@@ -644,7 +691,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
644
691
  // renders it in a throwaway scene via viewer.renderMeshPayloads. Never
645
692
  // touches the active tab, getView(), or the live scene — best-effort: any
646
693
  // failure, including a resolved-null from a worker build failure (4A
647
- // settles rather than throwing), returns null.
694
+ // settles rather than throwing), returns null. `opts` is spread last, so
695
+ // renderMeshPayloads' own options (including `background`) pass straight
696
+ // through from the caller.
648
697
  const captureView = async (viewName, opts = {}) => {
649
698
  try {
650
699
  const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
@@ -679,6 +728,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
679
728
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
680
729
  exportParts: (opts) => exportCtl.exportParts(opts),
681
730
  animation: animCtl?.runtime ?? null,
731
+ measure: {
732
+ isEnabled: measureMode.isEnabled,
733
+ setEnabled: measureMode.setEnabled,
734
+ clearPins: measureMode.clearPins,
735
+ pinCount: measureMode.pinCount,
736
+ },
682
737
  });
683
738
  } catch (error) {
684
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
- const HIGHLIGHT = 0x4da3ff;
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 material = new THREE.MeshBasicMaterial({
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
- clearHighlight();
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
- () => overlayParent?.remove(overlay),
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 pointerStarts = new Map();
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 = dragged;
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", onPointerDown);
50
- viewer.domElement.addEventListener("pointermove", onPointerMove);
51
- viewer.domElement.addEventListener("pointerup", onPointerUp);
52
- viewer.domElement.addEventListener("pointercancel", onPointerCancel);
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", onPointerDown);
58
- viewer.domElement.removeEventListener("pointermove", onPointerMove);
59
- viewer.domElement.removeEventListener("pointerup", onPointerUp);
60
- viewer.domElement.removeEventListener("pointercancel", onPointerCancel);
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
  };