partforge 0.53.0 → 0.55.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,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
  };
@@ -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
+ }
@@ -39,13 +39,15 @@ export function srgbEncodeInPlace(data) {
39
39
  // `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
40
40
  // target → readback → JPEG data URL); injected so this is unit-testable without
41
41
  // a GL context. The grid is hidden for the whole synchronous pass and restored.
42
- export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds }) {
42
+ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, hidden = [] }) {
43
43
  const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
44
44
  .filter((v) => CANONICAL_VIEWS.includes(v))
45
45
  .slice(0, CANONICAL_VIEWS.length);
46
46
  const before = liveCamera.position.clone();
47
47
  const gridWasVisible = grid?.visible;
48
48
  if (grid) grid.visible = false;
49
+ const hiddenWas = hidden.map((o) => o.visible);
50
+ for (const o of hidden) o.visible = false;
49
51
  try {
50
52
  return views.map((view) => ({
51
53
  view,
@@ -53,6 +55,7 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
53
55
  }));
54
56
  } finally {
55
57
  if (grid) grid.visible = gridWasVisible;
58
+ hidden.forEach((o, i) => { o.visible = hiddenWas[i]; });
56
59
  liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
57
60
  }
58
61
  }
@@ -139,6 +142,10 @@ export function createViewer(container, part) {
139
142
  };
140
143
  scene.background = new THREE.Color(THEME.dark.bg);
141
144
 
145
+ let currentTheme = "dark";
146
+ const themeListeners = new Set();
147
+ function onThemeChange(cb) { themeListeners.add(cb); return () => themeListeners.delete(cb); }
148
+
142
149
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
143
150
  camera.position.set(18, 12, 18);
144
151
 
@@ -536,6 +543,8 @@ export function createViewer(container, part) {
536
543
  for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
537
544
  cutaway.setTheme(mode, t.line);
538
545
  reassertLiveFades(); // setTheme re-clones every section's materials and reassigns them
546
+ currentTheme = THEME[mode] ? mode : "dark";
547
+ for (const cb of [...themeListeners]) cb(currentTheme);
539
548
  }
540
549
 
541
550
  function hideAssembly() {
@@ -640,6 +649,15 @@ export function createViewer(container, part) {
640
649
  return canvas.toDataURL("image/jpeg", quality);
641
650
  }
642
651
 
652
+ // Objects excluded from CANONICAL captures only (agent renders must stay
653
+ // dimension-free); captureCurrent — the user-framed showcase capture —
654
+ // deliberately does NOT consult this set.
655
+ const canonicalCaptureHidden = new Set();
656
+ function registerCanonicalCaptureHidden(obj) {
657
+ canonicalCaptureHidden.add(obj);
658
+ return () => canonicalCaptureHidden.delete(obj);
659
+ }
660
+
643
661
  // Render the canonical camera angles offscreen, framed to whatever is visible,
644
662
  // without disturbing the user's live view. Returns [{ view, dataUrl }].
645
663
  function captureCanonicalViews(viewNames) {
@@ -653,6 +671,7 @@ export function createViewer(container, part) {
653
671
  renderer: { renderOffscreen },
654
672
  liveCamera: camera,
655
673
  grid,
674
+ hidden: [...canonicalCaptureHidden],
656
675
  bounds: { center, radius },
657
676
  });
658
677
  }
@@ -877,6 +896,8 @@ export function createViewer(container, part) {
877
896
  controls.removeEventListener("start", onControlsStart);
878
897
  cameraStartListeners.clear();
879
898
  frameListeners.clear();
899
+ themeListeners.clear();
900
+ canonicalCaptureHidden.clear();
880
901
  camTween.cancel();
881
902
  controls.dispose();
882
903
  for (const t of flashTimers) clearTimeout(t);
@@ -930,6 +951,8 @@ export function createViewer(container, part) {
930
951
  setActive,
931
952
  onContextLost,
932
953
  setTheme,
954
+ onThemeChange,
955
+ getTheme: () => currentTheme,
933
956
  getCameraState,
934
957
  setCameraState,
935
958
  onCameraEnd,
@@ -946,6 +969,7 @@ export function createViewer(container, part) {
946
969
  resetCutaway: cutaway.reset,
947
970
  isWorldPointVisible: cutaway.isPointVisible,
948
971
  registerCutawayMaterial: cutaway.registerClippableMaterial,
972
+ registerCanonicalCaptureHidden,
949
973
  onCutawayHandleHover: cutaway.onHandleHoverChange,
950
974
  dispose,
951
975
  };
package/src/testing.js CHANGED
@@ -23,3 +23,8 @@ export { renderViews, RENDER_VIEWS } from "./testing/render.js";
23
23
  export { verify } from "./framework/oracle/verify.js";
24
24
  export { buildBVH } from "./framework/oracle/bvh.js";
25
25
  export { minWall } from "./framework/oracle/min-wall.js";
26
+ // Silhouette match scoring — also worker-reachable (the `inspect` job scores
27
+ // `matchTargets` with exactly these), re-exported so a downstream harness can build
28
+ // the same masks and reproduce a score outside the job loop.
29
+ export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
30
+ export { matchMasks, matchViews } from "./framework/oracle/match.js";
package/types/index.d.ts CHANGED
@@ -87,6 +87,7 @@ export interface MountElements {
87
87
  reframe?: HTMLElement | null;
88
88
  theme?: HTMLElement | null;
89
89
  cutaway?: HTMLElement | null;
90
+ measure?: HTMLElement | null;
90
91
  railToggle?: HTMLElement | null;
91
92
  };
92
93
  }
@@ -142,6 +143,19 @@ export interface CaptureViewOptions {
142
143
  angle?: CanonicalView | string;
143
144
  }
144
145
 
146
+ /**
147
+ * Measurement mode runtime controls. Dimensioned captures come straight from
148
+ * `captureCurrent()` while the mode is enabled — in-scene dims render into
149
+ * the frame natively (canonical-view captures and thumbnails never include
150
+ * them).
151
+ */
152
+ export interface MeasureRuntime {
153
+ isEnabled(): boolean;
154
+ setEnabled(on: boolean): void;
155
+ clearPins(): void;
156
+ pinCount(): number;
157
+ }
158
+
145
159
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
146
160
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
147
161
 
@@ -253,6 +267,8 @@ export interface PartRuntime {
253
267
  * active view has none, where `state().animation` reads `null`.
254
268
  */
255
269
  animation: AnimationRuntime | null;
270
+ /** Measurement mode's runtime-controls API — mode on/off, unit, and pin state; dimensioned captures come from `captureCurrent()` while enabled. Always present (a no-op stand-in outside `makeHandle` tests). */
271
+ measure: MeasureRuntime;
256
272
  }
257
273
 
258
274
  /** Mount a full parametric-part app from a `PartDefinition`. */