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.
- 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/jobs.js +75 -2
- 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/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- 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/src/testing.js +5 -0
- package/types/index.d.ts +16 -0
- package/types/testing.d.ts +116 -1
|
@@ -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
|
+
}
|