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,110 @@
1
+ // Viewbar chrome for measurement mode: the ruler toggle + contextual actions
2
+ // ("Clear" when pins exist, a unit toggle cycling the dimension display
3
+ // between millimetres and inches — display only; the rail stays mm). A direct sibling of
4
+ // cutaway-controls.js — same no-op-without-button contract, same attribute
5
+ // restore discipline on detach. The mode object (measure-mode.js) owns all
6
+ // behavior; this file only puts it on screen.
7
+ import { attachButtonTooltips } from "../tooltip.js";
8
+ import { runCleanupSteps, captureAttributes, restoreAttributes } from "../teardown.js";
9
+
10
+ const BUTTON_ATTRIBUTES = ["type", "aria-pressed", "aria-label", "title", "disabled"];
11
+ const RULER_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.3 8.7 8.7 21.3c-.4.4-1 .4-1.4 0l-4.6-4.6c-.4-.4-.4-1 0-1.4L15.3 2.7c.4-.4 1-.4 1.4 0l4.6 4.6c.4.4.4 1 0 1.4Z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>`;
12
+
13
+ const noop = () => {};
14
+
15
+ // escapeScope: when a host places cutaway's Flip/Reset/etc. buttons as
16
+ // canvas SIBLINGS in a shared #viewbar (not descendants of the canvas),
17
+ // attaching Escape to viewer.domElement alone leaves those buttons dead —
18
+ // nothing containing them ever sees the keydown. Attaching to a shared
19
+ // ancestor instead (mount.js passes the whole viewer stage) lets it bubble
20
+ // from canvas and viewbar buttons alike.
21
+ export function attachMeasureControls(viewer, mode, { measure: button } = {}, { tooltip, escapeScope } = {}) {
22
+ if (!button) return { detach: noop };
23
+
24
+ const hostAttributes = captureAttributes(button, BUTTON_ATTRIBUTES);
25
+ const hostHtml = button.innerHTML;
26
+ const hostOn = button.classList.contains("on");
27
+
28
+ button.type = "button";
29
+ button.innerHTML = RULER_ICON;
30
+ button.setAttribute("aria-pressed", "false");
31
+ if (!tooltip && !button.hasAttribute("title")) button.title = "Toggle measurements";
32
+
33
+ const actions = document.createElement("span");
34
+ actions.className = "pf-measure-actions";
35
+ const clearButton = document.createElement("button");
36
+ clearButton.type = "button";
37
+ clearButton.textContent = "Clear";
38
+ clearButton.title = "Remove all pinned measurements";
39
+ clearButton.setAttribute("aria-label", "Remove all pinned measurements");
40
+ const unitButton = document.createElement("button");
41
+ unitButton.type = "button";
42
+ unitButton.className = "pf-measure-unit";
43
+ actions.append(clearButton, unitButton);
44
+ button.after(actions);
45
+
46
+ const tooltipBinding = tooltip
47
+ ? attachButtonTooltips(tooltip, [button, clearButton, unitButton].map((element) => ({ element })))
48
+ : null;
49
+
50
+ function sync() {
51
+ const on = mode.isEnabled();
52
+ button.setAttribute("aria-pressed", String(on));
53
+ button.setAttribute("aria-label", on ? "Hide measurements" : "Show measurements");
54
+ button.classList.toggle("on", on);
55
+ actions.hidden = !on;
56
+ clearButton.hidden = mode.pinCount() === 0;
57
+ const u = mode.getUnits?.() ?? "mm";
58
+ unitButton.textContent = u;
59
+ const unitLabel = u === "mm" ? "Show measurements in inches" : "Show measurements in millimetres";
60
+ unitButton.setAttribute("aria-label", unitLabel);
61
+ if (!tooltip) unitButton.title = unitLabel;
62
+ tooltipBinding?.sync();
63
+ }
64
+
65
+ const onToggle = () => { mode.setEnabled(!mode.isEnabled()); sync(); };
66
+ const onClear = () => { mode.clearPins(); sync(); };
67
+ const onUnit = () => { mode.setUnits?.(mode.getUnits?.() === "mm" ? "in" : "mm"); sync(); };
68
+ const onEscape = (event) => {
69
+ if (event.key !== "Escape" || !mode.isEnabled()) return;
70
+ event.preventDefault();
71
+ // Consume the keystroke: with measure attached first, cutaway's listener
72
+ // on the same element would otherwise read the guard AFTER we've disabled
73
+ // and close too. Order-independent together with cutaway's escapeGuard
74
+ // (which covers the cutaway-attached-first order).
75
+ event.stopImmediatePropagation();
76
+ mode.setEnabled(false);
77
+ sync();
78
+ tooltipBinding?.hide();
79
+ };
80
+ const offPins = mode.onPinsChange(sync);
81
+ const offMode = mode.onModeChange(sync);
82
+
83
+ button.addEventListener("click", onToggle);
84
+ clearButton.addEventListener("click", onClear);
85
+ unitButton.addEventListener("click", onUnit);
86
+ const escapeTargets = [escapeScope ?? viewer.domElement, button, clearButton, unitButton];
87
+ for (const element of escapeTargets) element.addEventListener("keydown", onEscape);
88
+ sync();
89
+
90
+ let detached = false;
91
+ return {
92
+ detach() {
93
+ if (detached) return;
94
+ detached = true;
95
+ runCleanupSteps([
96
+ offPins,
97
+ offMode,
98
+ () => button.removeEventListener("click", onToggle),
99
+ () => clearButton.removeEventListener("click", onClear),
100
+ () => unitButton.removeEventListener("click", onUnit),
101
+ ...escapeTargets.map((element) => () => element.removeEventListener("keydown", onEscape)),
102
+ () => tooltipBinding?.detach(),
103
+ () => actions.remove(),
104
+ () => restoreAttributes(button, hostAttributes),
105
+ () => { button.innerHTML = hostHtml; },
106
+ () => button.classList.toggle("on", hostOn),
107
+ ], "measure control cleanup failed");
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,546 @@
1
+ // Measurement-mode orchestrator: the one measure module that touches both
2
+ // three.js and the DOM. Owns mode state and drives the pipeline
3
+ // raycast hit -> feature-dims spec -> param-link -> dim3-place -> dim3-scene
4
+ // with a per-frame dirty check. Dimensions are real scene objects parented
5
+ // under the meshes' shared group, so they ride the pivot rotation, the pose
6
+ // fast path and animations for free; the frame loop only has to notice mesh
7
+ // regenerates/visibility flips (rebuild) and camera moves that flip a side
8
+ // choice (re-score, rebuild only if a choice actually changed). Pins live in
9
+ // the pure pin store, per view, and survive mode toggles; `Clear` (chrome) is
10
+ // the only thing that empties them.
11
+ import * as THREE from "three";
12
+ import { raycastViewer } from "../selection/raycast.js";
13
+ import { createFeatureHighlight } from "../selection/feature-highlight.js";
14
+ import { createDragTracker } from "../selection/drag-tracker.js";
15
+ import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
16
+ import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
17
+ import { paramMatches } from "./param-link.js";
18
+ import { createPinStore, occurrenceOf } from "./pins.js";
19
+ import { evaluateChoices, choicesEqual, placeDims, specSig, laneCounts } from "./dim3-place.js";
20
+ import { createDimScene } from "./dim3-scene.js";
21
+
22
+ export function createMeasureMode(viewer, { part, getContext, revealParams, getParamsVersion, schedule = (cb) => requestAnimationFrame(cb) }) {
23
+ const pins = createPinStore();
24
+ const pinListeners = new Set();
25
+ const notifyPins = () => { for (const cb of [...pinListeners]) cb(); };
26
+ const modeListeners = new Set();
27
+ const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
28
+
29
+ let enabled = false;
30
+ let units = "mm"; // display only; values stay mm internally
31
+ let scene = null; // created on first enable, kept across toggles
32
+ let highlight = null;
33
+ let hover = null; // { item, key } for the currently hovered spec
34
+ let detached = false;
35
+
36
+ // ---- spec cache: (geometry instance, featureId) -> core spec -------------
37
+ // WeakMap: identity lookup only, and old geometries (with their typed
38
+ // arrays) drop out on their own once no mesh references them anymore —
39
+ // nothing here needs to clear it on detach.
40
+ const specCache = new WeakMap(); // geometry -> Map(featureId -> spec|null)
41
+ function featureSpec(mesh, featureId) {
42
+ let byId = specCache.get(mesh.geometry);
43
+ if (!byId) { byId = new Map(); specCache.set(mesh.geometry, byId); }
44
+ if (!byId.has(featureId)) {
45
+ const { featureIds } = mesh.geometry.userData;
46
+ const positions = mesh.geometry.getAttribute("position").array;
47
+ const indices = mesh.geometry.getIndex()?.array;
48
+ byId.set(featureId, featureIds
49
+ ? classifyFeature({ positions, indices, featureIds }, featureId)
50
+ : null);
51
+ }
52
+ return byId.get(featureId);
53
+ }
54
+
55
+ // ---- spec frames ---------------------------------------------------------
56
+ // Feature/bbox specs come out of classifyFeature in the MESH's own geometry
57
+ // frame; dim3-place works entirely in the PARTS frame (the meshes' shared
58
+ // parent), so compose the mesh's local matrix — which carries the viewer's
59
+ // fast-path pose — into every anchor before handing a spec over.
60
+ const _m3 = new THREE.Matrix3();
61
+ const _tv = new THREE.Vector3();
62
+ function transformSpec(spec, matrix) {
63
+ // identity fast path: poses are identity outside animations
64
+ if (matrix.determinant() === 1 && matrix.elements[12] === 0 && matrix.elements[13] === 0
65
+ && matrix.elements[14] === 0 && matrix.elements[0] === 1 && matrix.elements[5] === 1
66
+ && matrix.elements[10] === 1) return spec;
67
+ const pt = (p) => _tv.set(p[0], p[1], p[2]).applyMatrix4(matrix).toArray();
68
+ // directions take the matrix's linear part only — exact here because
69
+ // setSubPose poses are rigid (rotation + translation, no shear or scale).
70
+ const dir = (d) => _tv.set(d[0], d[1], d[2]).applyMatrix3(_m3.setFromMatrix4(matrix)).normalize().toArray();
71
+ if (spec.kind === "plane") {
72
+ return { ...spec, anchors: {
73
+ width: { a: pt(spec.anchors.width.a), b: pt(spec.anchors.width.b) },
74
+ height: { a: pt(spec.anchors.height.a), b: pt(spec.anchors.height.b) },
75
+ normal: dir(spec.anchors.normal),
76
+ } };
77
+ }
78
+ if (spec.kind === "cylinder") {
79
+ return { ...spec, anchors: {
80
+ center: pt(spec.anchors.center), axis: dir(spec.anchors.axis),
81
+ top: pt(spec.anchors.top), bottom: pt(spec.anchors.bottom),
82
+ rimDir: spec.anchors.rimDir ? dir(spec.anchors.rimDir) : undefined,
83
+ } };
84
+ }
85
+ if (spec.kind === "bbox") {
86
+ const b = new THREE.Box3(
87
+ new THREE.Vector3(...spec.anchors.min), new THREE.Vector3(...spec.anchors.max),
88
+ ).applyMatrix4(matrix); // AABB of the posed box, same as viewer.frameTo
89
+ return bboxSpec(b.min.toArray(), b.max.toArray());
90
+ }
91
+ return spec;
92
+ }
93
+
94
+ const visibleMeshes = () => Object.entries(viewer._subMeshes)
95
+ .filter(([, m]) => m.visible && m.geometry.getAttribute("position")?.count);
96
+
97
+ // ---- param linking (scoped like selection/resolve.js scopeParams) --------
98
+ // Memoize the per-sub-part read-key map: subPartReadKeys runs probe builds
99
+ // (see mesh-cache.js's readsFor), so it must run once per (view, params)
100
+ // change, not once per pinned item per frame. mount's getContext() returns
101
+ // the SAME live params object every call (mutated in place on every edit),
102
+ // so identity is stable across edits and can't key the memo the way
103
+ // mesh-cache.js's paramsVersion getter does. mount hands in the SAME
104
+ // late-bound version thunk it gives createMeshCache/createPoseFastPath
105
+ // (`() => loop.version()`) so this keys on the cheap integer instead of
106
+ // hashing the whole params object every call; a caller that omits it (or a
107
+ // direct test) falls back to the content hash.
108
+ let readsKey = null, readsMap = null;
109
+ function readsFor(view, params) {
110
+ const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params)}`;
111
+ if (readsKey !== key) {
112
+ readsKey = key;
113
+ try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
114
+ }
115
+ return readsMap;
116
+ }
117
+ function readKeysFor(subPart) {
118
+ const { view, params } = getContext();
119
+ const reads = readsFor(view, params);
120
+ if (!reads) return Object.keys(params);
121
+ return reads === RELEVANT_ALL
122
+ ? Object.keys(params)
123
+ : [...(reads.get(subPart) ?? Object.keys(params))];
124
+ }
125
+ // Clicking a measurement flashes the controls whose value ACTUALLY matches
126
+ // it (within the display quantum; radius-style params match a diameter at
127
+ // value*2), scoped to the spanned sub-parts' read keys so an unrelated
128
+ // part's coincidental value can't light up. One match also takes keyboard
129
+ // focus; several flash without a focus steal; none flashes nothing — a
130
+ // sub-part's whole read set proved far too coarse (a single build function
131
+ // reads every param, so even the drainage toggle lit up for a width click).
132
+ // Truthful derived-value attribution (which params MOVE this value) would
133
+ // need a per-param sensitivity probe in the worker — deliberately not done.
134
+ function revealRelevant(subParts, value) {
135
+ if (value == null) return;
136
+ const keySet = new Set();
137
+ for (const sp of subParts ?? []) for (const k of readKeysFor(sp)) keySet.add(k);
138
+ if (!keySet.size) return;
139
+ const matches = paramMatches([...keySet], getContext().params, { value });
140
+ if (!matches.length) return;
141
+ revealParams?.(matches, matches.length === 1 ? matches[0] : null);
142
+ }
143
+
144
+ // ---- pin resolution: stable key -> a live spec + its mesh ----------------
145
+ function resolvePin(key) {
146
+ const mesh = viewer._subMeshes[key.subPart];
147
+ if (!mesh || !mesh.visible) return null;
148
+ if (key.featureLabel == null) {
149
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
150
+ const { min, max } = mesh.geometry.boundingBox;
151
+ return { spec: bboxSpec([min.x, min.y, min.z], [max.x, max.y, max.z]), mesh };
152
+ }
153
+ const { features = [], featureIds } = mesh.geometry.userData;
154
+ if (!featureIds) return null;
155
+ // find the (occurrence+1)-th feature id carrying this label — dormant when gone
156
+ let seen = 0;
157
+ for (let i = 0; i < features.length; i++) {
158
+ if (features[i] !== key.featureLabel) continue;
159
+ if (seen === key.occurrence) {
160
+ const spec = featureSpec(mesh, i + 1);
161
+ return spec ? { spec, mesh } : null;
162
+ }
163
+ seen++;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // Items handed to dim3-place: every spec already in the parts frame, and
169
+ // `meshes` indexing into the meshData built alongside (so a feature dim
170
+ // scans only its own sub-part). `_key` rides along for un-pinning by label
171
+ // pick; dim3-place ignores unknown fields.
172
+ function buildItems() {
173
+ const items = [];
174
+ const meshes = visibleMeshes();
175
+ if (meshes.length === 0) return { items, meshes };
176
+ // always-on overall bounds (posed, like viewer.frameTo)
177
+ const boundsList = meshes.map(([, m]) => {
178
+ if (!m.geometry.boundingBox) m.geometry.computeBoundingBox();
179
+ const b = m.geometry.boundingBox.clone().applyMatrix4(m.matrix);
180
+ return { min: [b.min.x, b.min.y, b.min.z], max: [b.max.x, b.max.y, b.max.z] };
181
+ });
182
+ const u = unionBounds(boundsList);
183
+ items.push({
184
+ id: "overall", tier: "static", spec: bboxSpec(u.min, u.max),
185
+ meshes: meshes.map((_, i) => i),
186
+ subParts: meshes.map(([n]) => n),
187
+ });
188
+ const { view } = getContext();
189
+ pins.list(view).forEach((key) => {
190
+ const live = resolvePin(key);
191
+ if (!live) return; // dormant
192
+ const meshIndex = meshes.findIndex(([n]) => n === key.subPart);
193
+ items.push({
194
+ id: `pin:${key.subPart}:${key.featureLabel ?? "bbox"}:${key.occurrence}`,
195
+ tier: "pinned", pinned: true,
196
+ spec: transformSpec(live.spec, live.mesh.matrix),
197
+ meshes: meshIndex >= 0 ? [meshIndex] : [],
198
+ subParts: [key.subPart], _key: key,
199
+ });
200
+ });
201
+ if (hover) {
202
+ const meshIndex = meshes.findIndex(([n]) => n === hover.subPart);
203
+ items.push({
204
+ ...hover.item,
205
+ spec: transformSpec(hover.item.spec, hover.mesh.matrix),
206
+ meshes: meshIndex >= 0 ? [meshIndex] : [],
207
+ });
208
+ }
209
+ return { items, meshes, bounds: u };
210
+ }
211
+
212
+ // ---- placement environment + rebuild -------------------------------------
213
+ let choices = {};
214
+ let lastItems = []; // for label-pick resolution + cheap re-scoring
215
+ let lastBounds = null;
216
+ let themeEpoch = 0;
217
+ // The always-on + pinned dims, which change rarely, cached apart from the
218
+ // hover dim, which changes on every pointer move. placeBox scans every
219
+ // vertex of the meshes it covers (tens of ms on a big part), so re-placing
220
+ // the whole set per hover frame is the one path that must not exist.
221
+ const baseCache = { key: null, drawings: [] };
222
+ const _rc = new THREE.Raycaster();
223
+ const _origin = new THREE.Vector3();
224
+ const _dir = new THREE.Vector3();
225
+ const _camLocal = new THREE.Vector3();
226
+
227
+ function partsParent(meshes) { return meshes[0]?.[1].parent ?? null; }
228
+
229
+ // Everything dim3-place needs from the live scene, expressed in the parts
230
+ // frame: raw vertex arrays + their pose matrices, a surface raycast (parts
231
+ // frame in, parts frame out) and the camera position.
232
+ function buildEnv(meshes) {
233
+ const parent = partsParent(meshes);
234
+ parent?.updateWorldMatrix(true, false);
235
+ const meshData = meshes.map(([, m]) => ({
236
+ positions: m.geometry.getAttribute("position").array,
237
+ matrix: m.matrix,
238
+ }));
239
+ const hittable = meshes.map(([, m]) => m);
240
+ for (const m of hittable) m.updateWorldMatrix(true, false);
241
+ const surfaceHit = (origin, dir) => {
242
+ if (!parent) return null;
243
+ _origin.copy(origin).applyMatrix4(parent.matrixWorld);
244
+ _dir.copy(dir).transformDirection(parent.matrixWorld);
245
+ _rc.set(_origin, _dir);
246
+ const hit = _rc.intersectObjects(hittable, false)[0];
247
+ return hit ? parent.worldToLocal(hit.point.clone()) : null;
248
+ };
249
+ const camPos = parent
250
+ ? _camLocal.copy(viewer.camera.position).applyMatrix4(parent.matrixWorld.clone().invert()).toArray()
251
+ : viewer.camera.position.toArray();
252
+ return { meshData, surfaceHit, camPos };
253
+ }
254
+
255
+ const centerOf = (bounds) => [
256
+ (bounds.min[0] + bounds.max[0]) / 2,
257
+ (bounds.min[1] + bounds.max[1]) / 2,
258
+ (bounds.min[2] + bounds.max[2]) / 2,
259
+ ];
260
+
261
+ // Everything the base drawings depend on, in one string: the mesh signature
262
+ // (geometry, poses, visibility, active view), the theme, which items are in
263
+ // the base set and what they link to, and the side choices scored for them.
264
+ // The hover item's own choice entries are skipped — they come and go with
265
+ // the pointer and must not evict the cache.
266
+ function baseCacheKey(sig, baseItems) {
267
+ let key = `${sig}|t${themeEpoch}`;
268
+ for (const item of baseItems) key += `|${item.id}`;
269
+ for (const ck of Object.keys(choices)) {
270
+ if (ck.startsWith("hover|")) continue;
271
+ const c = choices[ck];
272
+ key += `|${ck}=${c.key ?? ""}${c.du ? `,${c.du.map((n) => n.toFixed(4))}` : ""}`;
273
+ }
274
+ return key;
275
+ }
276
+
277
+ function rebuild() {
278
+ if (!enabled || !scene) return;
279
+ const { items, meshes, bounds } = buildItems();
280
+ lastItems = items;
281
+ lastBounds = bounds ?? null;
282
+ if (!items.length || !bounds) { scene.clear(); baseCache.key = null; return; }
283
+ const env = buildEnv(meshes);
284
+ choices = evaluateChoices(items, { camPos: env.camPos, center: centerOf(bounds), prev: choices });
285
+ const place = (list, suppress, lanes) =>
286
+ placeDims(list, { meshData: env.meshData, surfaceHit: env.surfaceHit, bounds, suppress, lanes, units }, choices);
287
+ const hoverItem = items.find((i) => i.id === "hover");
288
+ const baseItems = hoverItem ? items.filter((i) => i !== hoverItem) : items;
289
+ const key = baseCacheKey(`${units}|${meshSig()}`, baseItems);
290
+ if (baseCache.key !== key) {
291
+ baseCache.key = key;
292
+ baseCache.drawings = place(baseItems);
293
+ }
294
+ // The hover pass can't see the base pass's items (they're cached), so it
295
+ // hands over (a) their sigs as `suppress` — a hover duplicating an
296
+ // already-drawn measurement (the sub-part bounds over the overall, a
297
+ // hovered pin) draws nothing instead of doubling it — and (b) their lane
298
+ // occupancy, so a hovered dim staggers into the SAME lane it will occupy
299
+ // once pinned (pins append after the base items in the same order) and
300
+ // clicking never moves it.
301
+ scene.update(hoverItem
302
+ ? baseCache.drawings.concat(place(
303
+ [hoverItem],
304
+ new Set(baseItems.map((i) => specSig(i.spec))),
305
+ laneCounts(baseCache.drawings),
306
+ ))
307
+ : baseCache.drawings);
308
+ }
309
+
310
+ // ---- frame dirty check ---------------------------------------------------
311
+ // The camera is deliberately NOT part of the signature: the dims live in the
312
+ // scene, so an orbit re-renders them for free. All a camera move can change
313
+ // is WHICH side each dim is drawn on — cheap to re-score every frame
314
+ // (dot products + hysteresis), and only a genuine flip costs a rebuild.
315
+ let lastSig = "";
316
+ // Seeded with the ACTIVE VIEW: pins are per view, so switching views changes
317
+ // what must be drawn even when the visible mesh set is byte-identical (two
318
+ // views over the same sub-parts). v1 caught that incidentally through its
319
+ // camera hash; without the camera here it has to be explicit.
320
+ // The pose is hashed as its full rotation basis + translation, not just the
321
+ // two diagonal terms — θ and −θ about X share cos θ on the diagonal, and
322
+ // there is no camera hash left to notice the difference.
323
+ function meshSig() {
324
+ let sig = getContext().view;
325
+ for (const [name, m] of Object.entries(viewer._subMeshes)) {
326
+ const e = m.matrix.elements;
327
+ sig += `|${name}:${m.visible ? 1 : 0}:${m.geometry.id}:${e[0]},${e[1]},${e[2]},${e[4]},${e[5]},${e[6]},${e[12]},${e[13]},${e[14]}`;
328
+ }
329
+ return sig;
330
+ }
331
+ const offFrame = viewer.onFrame(() => {
332
+ if (!enabled || !scene) return;
333
+ const sig = meshSig();
334
+ if (sig !== lastSig) {
335
+ lastSig = sig;
336
+ // geometry identity is part of the signature, so a regenerate lands here;
337
+ // visibility (e.g. a cutaway/view toggle hiding the sub-part) is hashed
338
+ // too but changes NOTHING else about the mesh, so the signature alone
339
+ // can't tell "regenerated" from "still the same geometry, just hidden" —
340
+ // check both explicitly. Either way the hovered mesh is no longer a
341
+ // valid target: drop the stale hover (and its highlight) and re-render.
342
+ const m = viewer._subMeshes[hover?.subPart];
343
+ if (hover && (!m || !m.visible || hover.geometry !== m.geometry)) {
344
+ hover = null;
345
+ highlight?.clear();
346
+ }
347
+ rebuild();
348
+ } else if (lastItems.length) {
349
+ // cheap per-frame: has a side choice flipped?
350
+ const meshes = visibleMeshes();
351
+ const parent = partsParent(meshes);
352
+ if (parent && lastBounds) {
353
+ parent.updateWorldMatrix(true, false);
354
+ const camPos = viewer.camera.position.clone()
355
+ .applyMatrix4(parent.matrixWorld.clone().invert()).toArray();
356
+ const next = evaluateChoices(lastItems, { camPos, center: centerOf(lastBounds), prev: choices });
357
+ if (!choicesEqual(next, choices)) { choices = next; rebuild(); }
358
+ }
359
+ }
360
+ scene.tick();
361
+ });
362
+
363
+ // ---- pointer handling (drag threshold: the click-picker idiom) -----------
364
+ const drag = createDragTracker(); // same shared threshold as selection/pick.js
365
+ let pendingMove = null;
366
+ let moveScheduled = false;
367
+ let suppressed = false; // cutaway gizmo drag in progress
368
+
369
+ // Mirrors hover.js's own subscription: a cutaway handle drag takes over the
370
+ // pointer, so drop any pending hover and stop reacting to moves until it lets go.
371
+ const unsubscribeHandleHover = viewer.onCutawayHandleHover?.((handle) => {
372
+ suppressed = handle != null;
373
+ if (!suppressed) return;
374
+ pendingMove = null;
375
+ hover = null;
376
+ highlight?.clear();
377
+ rebuild();
378
+ }) ?? (() => {});
379
+
380
+ function hitToHover(hit) {
381
+ let spec, key;
382
+ if (hit.feature) {
383
+ spec = featureSpec(hit.mesh, hit.feature.id);
384
+ const { features } = hit.mesh.geometry.userData;
385
+ key = { subPart: hit.subPart, featureLabel: hit.feature.label,
386
+ occurrence: occurrenceOf(features, hit.feature.id) };
387
+ }
388
+ if (!spec) {
389
+ if (!hit.mesh.geometry.boundingBox) hit.mesh.geometry.computeBoundingBox();
390
+ const { min, max } = hit.mesh.geometry.boundingBox;
391
+ spec = bboxSpec([min.x, min.y, min.z], [max.x, max.y, max.z]);
392
+ key = { subPart: hit.subPart, featureLabel: null, occurrence: 0 };
393
+ }
394
+ return {
395
+ key,
396
+ geometry: hit.mesh.geometry,
397
+ subPart: hit.subPart,
398
+ mesh: hit.mesh,
399
+ // spec stays in the mesh's own frame here; buildItems poses it.
400
+ item: { id: "hover", tier: "hover", spec, subParts: [hit.subPart] },
401
+ };
402
+ }
403
+
404
+ function onMove(ev) {
405
+ if (!enabled || ev.pointerType === "touch" || suppressed) return;
406
+ drag.onMove(ev);
407
+ pendingMove = { x: ev.clientX, y: ev.clientY };
408
+ if (moveScheduled) return;
409
+ moveScheduled = true;
410
+ schedule(() => {
411
+ moveScheduled = false;
412
+ const p = pendingMove;
413
+ pendingMove = null;
414
+ if (!enabled || detached || !p || suppressed) return;
415
+ // A pointer over any dim label parks the hover state: labels usually
416
+ // float OFF the geometry, so re-raycasting here would clear the hover
417
+ // (and its label) out from under a cursor travelling toward it — the
418
+ // label must survive its own approach to be clickable. The pointer
419
+ // cursor is the click affordance a canvas can give.
420
+ if (scene?.pickLabel(p.x, p.y)) {
421
+ dom.style.cursor = "pointer";
422
+ return;
423
+ }
424
+ dom.style.cursor = "";
425
+ const hit = raycastViewer(viewer, p.x, p.y);
426
+ if (hit) {
427
+ hover = hitToHover(hit);
428
+ highlight.show(hit);
429
+ } else {
430
+ hover = null;
431
+ highlight.clear();
432
+ }
433
+ rebuild();
434
+ });
435
+ }
436
+ const onLeave = () => { hover = null; highlight?.clear(); dom.style.cursor = ""; rebuild(); };
437
+
438
+ function togglePin(key, subParts, value) {
439
+ const { view } = getContext();
440
+ const added = pins.toggle(view, key);
441
+ if (added) revealRelevant(subParts, value);
442
+ notifyPins();
443
+ rebuild();
444
+ }
445
+
446
+ // Label pick first, then geometry: a dimension's text plane is a real object
447
+ // in the scene, so clicking it must un-pin (or pin the hovered dim) rather
448
+ // than fall through to whatever surface sits behind it.
449
+ function onClick(ev) {
450
+ const wasDragged = drag.consumeClick();
451
+ if (!enabled || wasDragged) return;
452
+ const picked = scene?.pickLabel(ev.clientX, ev.clientY);
453
+ if (picked) {
454
+ // Every label is a button to the controls that drive its measurement:
455
+ // clicking flashes them (focusing an exact value match). A hover label
456
+ // also pins. Labels never unpin — that stays on re-clicking the
457
+ // geometry, or Clear — so the one visible affordance always means the
458
+ // same thing.
459
+ if (picked.itemId === "hover") {
460
+ if (hover) togglePin(hover.key, hover.item.subParts, picked.value);
461
+ return;
462
+ }
463
+ const item = lastItems.find((i) => i.id === picked.itemId);
464
+ if (item) revealRelevant(item.subParts, picked.value);
465
+ return;
466
+ }
467
+ const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
468
+ if (!hit) return;
469
+ const h = hitToHover(hit);
470
+ togglePin(h.key, h.item.subParts, null);
471
+ }
472
+
473
+ const dom = viewer.domElement;
474
+ dom.addEventListener("pointermove", onMove);
475
+ dom.addEventListener("pointerdown", drag.onDown);
476
+ dom.addEventListener("pointerup", drag.onUp);
477
+ dom.addEventListener("pointercancel", drag.onCancel);
478
+ dom.addEventListener("pointerleave", onLeave);
479
+ dom.addEventListener("click", onClick);
480
+
481
+ const offTheme = viewer.onThemeChange?.((mode) => {
482
+ themeEpoch++; // drop the cached base drawings so the repaint is unconditional
483
+ scene?.setTheme(mode);
484
+ rebuild();
485
+ }) ?? (() => {});
486
+
487
+ function setEnabled(on) {
488
+ if (detached || on === enabled) return;
489
+ enabled = !!on;
490
+ if (enabled) {
491
+ scene ??= createDimScene(viewer);
492
+ highlight ??= createFeatureHighlight(viewer);
493
+ rebuild();
494
+ // Adopt the signature this placement was built from, so the very next
495
+ // frame doesn't read as "changed" and redo the whole vertex scan.
496
+ lastSig = meshSig();
497
+ } else {
498
+ hover = null;
499
+ highlight?.clear();
500
+ scene?.clear();
501
+ lastItems = [];
502
+ lastBounds = null;
503
+ baseCache.key = null;
504
+ baseCache.drawings = [];
505
+ dom.style.cursor = "";
506
+ }
507
+ notifyMode();
508
+ }
509
+
510
+ return {
511
+ setEnabled,
512
+ isEnabled: () => enabled,
513
+ clearPins() {
514
+ pins.clear(getContext().view);
515
+ notifyPins();
516
+ rebuild();
517
+ },
518
+ pinCount: () => pins.count(getContext().view),
519
+ getUnits: () => units,
520
+ setUnits(next) {
521
+ if (next === units || !(next in { mm: 1, in: 1 })) return;
522
+ units = next;
523
+ rebuild(); // labels re-render in the new unit; cache key carries it
524
+ },
525
+ onPinsChange: (cb) => { pinListeners.add(cb); return () => pinListeners.delete(cb); },
526
+ onModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
527
+ detach() {
528
+ if (detached) return;
529
+ detached = true;
530
+ enabled = false;
531
+ offFrame();
532
+ unsubscribeHandleHover();
533
+ offTheme();
534
+ dom.removeEventListener("pointermove", onMove);
535
+ dom.removeEventListener("pointerdown", drag.onDown);
536
+ dom.removeEventListener("pointerup", drag.onUp);
537
+ dom.removeEventListener("pointercancel", drag.onCancel);
538
+ dom.removeEventListener("pointerleave", onLeave);
539
+ dom.removeEventListener("click", onClick);
540
+ highlight?.dispose();
541
+ scene?.dispose();
542
+ pinListeners.clear();
543
+ modeListeners.clear();
544
+ },
545
+ };
546
+ }