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,39 @@
|
|
|
1
|
+
// Shared drag-threshold state machine: distinguishes a click from a
|
|
2
|
+
// click-terminated orbit/pan gesture. Multiple pointers are tracked (a second
|
|
3
|
+
// finger joining mid-gesture must not un-arm the drag flag — see
|
|
4
|
+
// selection-pick.test.js's "another pointer joins" case); the drag flag only
|
|
5
|
+
// resets once every pointer has lifted. Lifted out of selection/pick.js so
|
|
6
|
+
// measure-mode.js shares one implementation and one threshold instead of a
|
|
7
|
+
// second, divergent copy.
|
|
8
|
+
export function createDragTracker({ thresholdSquared = 16 } = {}) {
|
|
9
|
+
const pointerStarts = new Map();
|
|
10
|
+
let dragged = false;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
onDown(ev) {
|
|
14
|
+
if (pointerStarts.size === 0) dragged = false;
|
|
15
|
+
pointerStarts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
16
|
+
},
|
|
17
|
+
onMove(ev) {
|
|
18
|
+
const start = pointerStarts.get(ev.pointerId);
|
|
19
|
+
if (!start || dragged) return;
|
|
20
|
+
const dx = ev.clientX - start.x, dy = ev.clientY - start.y;
|
|
21
|
+
dragged = dx * dx + dy * dy > thresholdSquared;
|
|
22
|
+
},
|
|
23
|
+
onUp(ev) {
|
|
24
|
+
pointerStarts.delete(ev.pointerId);
|
|
25
|
+
},
|
|
26
|
+
onCancel(ev) {
|
|
27
|
+
pointerStarts.delete(ev.pointerId);
|
|
28
|
+
if (pointerStarts.size === 0) dragged = false;
|
|
29
|
+
},
|
|
30
|
+
// Reads and resets drag state for a click: call at the top of the click
|
|
31
|
+
// handler, before deciding whether to act on it.
|
|
32
|
+
consumeClick() {
|
|
33
|
+
const wasDragged = dragged;
|
|
34
|
+
pointerStarts.clear();
|
|
35
|
+
dragged = false;
|
|
36
|
+
return wasDragged;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Shared surface-highlight helper: an overlay mesh tinting one feature's
|
|
2
|
+
// triangle subset (or a whole sub-part). Extracted from hover.js so the hover
|
|
3
|
+
// tooltip and measurement mode share one implementation and one subset cache.
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
|
|
6
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
7
|
+
|
|
8
|
+
const HIGHLIGHT = 0x4da3ff;
|
|
9
|
+
|
|
10
|
+
// Extract the subset of a geometry belonging to one feature id. Handles both
|
|
11
|
+
// non-indexed (Manifold) and indexed (OCCT) payloads.
|
|
12
|
+
function featureSubset(geometry, featureId) {
|
|
13
|
+
const { featureIds } = geometry.userData;
|
|
14
|
+
const pos = geometry.getAttribute("position");
|
|
15
|
+
const index = geometry.getIndex();
|
|
16
|
+
const vertAt = index ? (t, v) => index.getX(t * 3 + v) : (t, v) => t * 3 + v;
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (let t = 0; t < featureIds.length; t++) if (featureIds[t] === featureId) count++;
|
|
19
|
+
const out = new Float32Array(count * 9);
|
|
20
|
+
let o = 0;
|
|
21
|
+
for (let t = 0; t < featureIds.length; t++) {
|
|
22
|
+
if (featureIds[t] !== featureId) continue;
|
|
23
|
+
for (let v = 0; v < 3; v++) {
|
|
24
|
+
const i = vertAt(t, v);
|
|
25
|
+
out[o++] = pos.getX(i); out[o++] = pos.getY(i); out[o++] = pos.getZ(i);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const g = new THREE.BufferGeometry();
|
|
29
|
+
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
|
|
30
|
+
return g;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createFeatureHighlight(viewer) {
|
|
34
|
+
const material = new THREE.MeshBasicMaterial({
|
|
35
|
+
color: HIGHLIGHT, transparent: true, opacity: 0.35,
|
|
36
|
+
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
37
|
+
});
|
|
38
|
+
const unregisterCutaway = viewer.registerCutawayMaterial?.(material) ?? (() => {});
|
|
39
|
+
let emptyOverlayGeometry = new THREE.BufferGeometry();
|
|
40
|
+
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
41
|
+
overlay.visible = false;
|
|
42
|
+
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
43
|
+
let overlayParent = null;
|
|
44
|
+
// Subset cache per sub-part: rebuilt when the sub-part's geometry object
|
|
45
|
+
// changes (i.e. after a regenerate) — keyed on the geometry instance.
|
|
46
|
+
const subsets = new Map(); // subPart -> { geo, byId: Map(featureId -> BufferGeometry) }
|
|
47
|
+
|
|
48
|
+
function mount(geometry, mesh) {
|
|
49
|
+
emptyOverlayGeometry?.dispose();
|
|
50
|
+
emptyOverlayGeometry = null;
|
|
51
|
+
overlay.geometry = geometry;
|
|
52
|
+
// Parent to the sub-part mesh, not the scene: the overlay geometry is a
|
|
53
|
+
// subset of the mesh's own (delivered-frame) vertices, so it must inherit
|
|
54
|
+
// whatever fast-path pose viewer.setSubPose has written onto that mesh.
|
|
55
|
+
if (overlayParent !== mesh) {
|
|
56
|
+
mesh.add(overlay);
|
|
57
|
+
overlayParent = mesh;
|
|
58
|
+
}
|
|
59
|
+
overlay.visible = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
show(hit) {
|
|
64
|
+
if (!hit.feature) { mount(hit.mesh.geometry, hit.mesh); return; }
|
|
65
|
+
const cached = subsets.get(hit.subPart);
|
|
66
|
+
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
67
|
+
if (!byId) {
|
|
68
|
+
for (const g of cached?.byId.values() ?? []) g.dispose();
|
|
69
|
+
byId = new Map();
|
|
70
|
+
subsets.set(hit.subPart, { geo: hit.mesh.geometry, byId });
|
|
71
|
+
}
|
|
72
|
+
let g = byId.get(hit.feature.id);
|
|
73
|
+
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
74
|
+
mount(g, hit.mesh);
|
|
75
|
+
},
|
|
76
|
+
clear() { overlay.visible = false; },
|
|
77
|
+
dispose() {
|
|
78
|
+
// Every step isolated: a throw disposing one cached subset (or
|
|
79
|
+
// unregistering from cutaway) must not skip the rest — same discipline
|
|
80
|
+
// as hover.js's own cleanup list, which this dispose() call is itself
|
|
81
|
+
// one step of (test/selection-hover.test.js's aggregated-failure case
|
|
82
|
+
// exercises both layers together).
|
|
83
|
+
const steps = [
|
|
84
|
+
() => { overlay.visible = false; },
|
|
85
|
+
() => { overlayParent?.remove(overlay); },
|
|
86
|
+
];
|
|
87
|
+
for (const { byId } of subsets.values()) {
|
|
88
|
+
for (const g of byId.values()) steps.push(() => g.dispose());
|
|
89
|
+
}
|
|
90
|
+
steps.push(
|
|
91
|
+
() => subsets.clear(),
|
|
92
|
+
() => emptyOverlayGeometry?.dispose(),
|
|
93
|
+
unregisterCutaway,
|
|
94
|
+
() => material.dispose(),
|
|
95
|
+
);
|
|
96
|
+
runCleanupSteps(steps, "feature highlight cleanup failed");
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -2,42 +2,10 @@
|
|
|
2
2
|
// sub-part under the pointer, and an overlay mesh highlighting the feature's
|
|
3
3
|
// surface. Feature names come from Solid.label() in the part's build, carried
|
|
4
4
|
// per-triangle in the mesh payload (geometry.userData.featureIds/features).
|
|
5
|
-
import * as THREE from "three";
|
|
6
|
-
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
|
|
7
5
|
import { createTooltipPresenter } from "../tooltip.js";
|
|
8
6
|
import { raycastViewer } from "./raycast.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function runCleanupSteps(steps) {
|
|
13
|
-
const errors = [];
|
|
14
|
-
for (const step of steps) {
|
|
15
|
-
try { step(); } catch (error) { errors.push(error); }
|
|
16
|
-
}
|
|
17
|
-
if (errors.length === 1) throw errors[0];
|
|
18
|
-
if (errors.length > 1) {
|
|
19
|
-
throw new AggregateError(errors, "feature hover cleanup failed");
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Extract the subset of a non-indexed geometry belonging to one feature id.
|
|
24
|
-
function featureSubset(geometry, featureId) {
|
|
25
|
-
const { featureIds } = geometry.userData;
|
|
26
|
-
const pos = geometry.getAttribute("position");
|
|
27
|
-
let count = 0;
|
|
28
|
-
for (let t = 0; t < featureIds.length; t++) if (featureIds[t] === featureId) count++;
|
|
29
|
-
const out = new Float32Array(count * 9);
|
|
30
|
-
let o = 0;
|
|
31
|
-
for (let t = 0; t < featureIds.length; t++) {
|
|
32
|
-
if (featureIds[t] !== featureId) continue;
|
|
33
|
-
for (let v = 0; v < 3; v++) {
|
|
34
|
-
out[o++] = pos.getX(t * 3 + v); out[o++] = pos.getY(t * 3 + v); out[o++] = pos.getZ(t * 3 + v);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const g = new THREE.BufferGeometry();
|
|
38
|
-
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
|
|
39
|
-
return g;
|
|
40
|
-
}
|
|
7
|
+
import { createFeatureHighlight } from "./feature-highlight.js";
|
|
8
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
41
9
|
|
|
42
10
|
export function attachHoverLabels(
|
|
43
11
|
viewer,
|
|
@@ -51,67 +19,27 @@ export function attachHoverLabels(
|
|
|
51
19
|
let presentationToken;
|
|
52
20
|
let hasPresented = false;
|
|
53
21
|
|
|
54
|
-
const
|
|
55
|
-
color: HIGHLIGHT, transparent: true, opacity: 0.35,
|
|
56
|
-
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
57
|
-
});
|
|
58
|
-
const unregisterCutaway = viewer.registerCutawayMaterial?.(material) ?? (() => {});
|
|
59
|
-
let emptyOverlayGeometry = new THREE.BufferGeometry();
|
|
60
|
-
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
61
|
-
overlay.visible = false;
|
|
62
|
-
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
63
|
-
let overlayParent = null;
|
|
64
|
-
// Subset cache per sub-part: rebuilt when the sub-part's geometry object changes
|
|
65
|
-
// (i.e. after a regenerate) — keyed on the geometry instance.
|
|
66
|
-
const subsets = new Map(); // subPart -> { geo, byId: Map(featureId -> BufferGeometry) }
|
|
22
|
+
const highlight = createFeatureHighlight(viewer);
|
|
67
23
|
|
|
68
24
|
const subLabel = (name) => part.parts[name]?.label ?? name;
|
|
69
25
|
|
|
70
|
-
function clearHighlight() {
|
|
71
|
-
overlay.visible = false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function showHighlight(hit, geometry) {
|
|
75
|
-
emptyOverlayGeometry?.dispose();
|
|
76
|
-
emptyOverlayGeometry = null;
|
|
77
|
-
overlay.geometry = geometry;
|
|
78
|
-
// Parent to the sub-part mesh, not to the group: the overlay geometry is a
|
|
79
|
-
// subset of the mesh's own (delivered-frame) vertices, so it must inherit
|
|
80
|
-
// whatever fast-path pose viewer.setSubPose has written onto that mesh.
|
|
81
|
-
if (overlayParent !== hit.mesh) {
|
|
82
|
-
hit.mesh.add(overlay);
|
|
83
|
-
overlayParent = hit.mesh;
|
|
84
|
-
}
|
|
85
|
-
overlay.visible = true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
26
|
function hide() {
|
|
89
27
|
if (hasPresented) {
|
|
90
28
|
hasPresented = false;
|
|
91
29
|
tooltipPresenter.hide(presentationToken);
|
|
92
30
|
presentationToken = undefined;
|
|
93
31
|
}
|
|
94
|
-
|
|
32
|
+
highlight.clear();
|
|
95
33
|
}
|
|
96
34
|
|
|
97
35
|
function show(hit, x, y) {
|
|
98
36
|
let content;
|
|
99
37
|
if (hit.feature) {
|
|
100
38
|
content = { title: hit.feature.label, subtitle: subLabel(hit.subPart) };
|
|
101
|
-
const cached = subsets.get(hit.subPart);
|
|
102
|
-
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
103
|
-
if (!byId) {
|
|
104
|
-
for (const g of cached?.byId.values() ?? []) g.dispose();
|
|
105
|
-
byId = new Map();
|
|
106
|
-
subsets.set(hit.subPart, { geo: hit.mesh.geometry, byId });
|
|
107
|
-
}
|
|
108
|
-
let g = byId.get(hit.feature.id);
|
|
109
|
-
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
110
|
-
showHighlight(hit, g);
|
|
111
39
|
} else {
|
|
112
40
|
content = { title: subLabel(hit.subPart), subtitle: "" };
|
|
113
|
-
showHighlight(hit, hit.mesh.geometry);
|
|
114
41
|
}
|
|
42
|
+
highlight.show(hit);
|
|
115
43
|
if (hasPresented) {
|
|
116
44
|
hasPresented = false;
|
|
117
45
|
tooltipPresenter.hide(presentationToken);
|
|
@@ -127,6 +55,7 @@ export function attachHoverLabels(
|
|
|
127
55
|
let down = false;
|
|
128
56
|
let detached = false;
|
|
129
57
|
let suppressed = false;
|
|
58
|
+
let externallySuppressed = false;
|
|
130
59
|
|
|
131
60
|
function invalidatePendingWork() {
|
|
132
61
|
pending = null;
|
|
@@ -144,7 +73,7 @@ export function attachHoverLabels(
|
|
|
144
73
|
function onMove(ev) {
|
|
145
74
|
if (detached) return;
|
|
146
75
|
if (ev.pointerType === "touch") return;
|
|
147
|
-
if (down || suppressed) return;
|
|
76
|
+
if (down || suppressed || externallySuppressed) return;
|
|
148
77
|
pending = { x: ev.clientX, y: ev.clientY };
|
|
149
78
|
if (frameScheduled) return;
|
|
150
79
|
frameScheduled = true;
|
|
@@ -154,7 +83,7 @@ export function attachHoverLabels(
|
|
|
154
83
|
frameScheduled = false;
|
|
155
84
|
const p = pending;
|
|
156
85
|
pending = null;
|
|
157
|
-
if (detached || !p || down || suppressed) return;
|
|
86
|
+
if (detached || !p || down || suppressed || externallySuppressed) return;
|
|
158
87
|
const hit = raycastViewer(viewer, p.x, p.y);
|
|
159
88
|
if (hit) show(hit, p.x, p.y); else hide();
|
|
160
89
|
});
|
|
@@ -169,14 +98,14 @@ export function attachHoverLabels(
|
|
|
169
98
|
viewer.domElement.addEventListener("pointerleave", onLeave);
|
|
170
99
|
|
|
171
100
|
return {
|
|
101
|
+
setSuppressed(on) {
|
|
102
|
+
externallySuppressed = !!on;
|
|
103
|
+
if (externallySuppressed) { invalidatePendingWork(); hide(); }
|
|
104
|
+
},
|
|
172
105
|
detach: () => {
|
|
173
106
|
if (detached) return;
|
|
174
107
|
detached = true;
|
|
175
108
|
invalidatePendingWork();
|
|
176
|
-
const subsetGeometries = [...subsets.values()]
|
|
177
|
-
.flatMap(({ byId }) => [...byId.values()]);
|
|
178
|
-
const initialGeometry = emptyOverlayGeometry;
|
|
179
|
-
emptyOverlayGeometry = null;
|
|
180
109
|
runCleanupSteps([
|
|
181
110
|
unsubscribeHandleHover,
|
|
182
111
|
() => viewer.domElement.removeEventListener("pointermove", onMove),
|
|
@@ -184,14 +113,9 @@ export function attachHoverLabels(
|
|
|
184
113
|
() => viewer.domElement.removeEventListener("pointerup", onUp),
|
|
185
114
|
() => viewer.domElement.removeEventListener("pointerleave", onLeave),
|
|
186
115
|
hide,
|
|
187
|
-
() =>
|
|
188
|
-
...subsetGeometries.map((geometry) => () => geometry.dispose()),
|
|
189
|
-
() => subsets.clear(),
|
|
190
|
-
() => initialGeometry?.dispose(),
|
|
191
|
-
unregisterCutaway,
|
|
192
|
-
() => material.dispose(),
|
|
116
|
+
() => highlight.dispose(),
|
|
193
117
|
() => { if (ownsTooltip) tooltipPresenter.dispose(); },
|
|
194
|
-
]);
|
|
118
|
+
], "feature hover cleanup failed");
|
|
195
119
|
},
|
|
196
120
|
};
|
|
197
121
|
}
|
|
@@ -6,3 +6,4 @@ export { attachPicker, worldToSubPartLocal } from "./pick.js";
|
|
|
6
6
|
export { attachPickToggle } from "./pick-toggle.js";
|
|
7
7
|
export { raycastViewer, featureAt } from "./raycast.js";
|
|
8
8
|
export { attachHoverLabels } from "./hover.js";
|
|
9
|
+
export { createFeatureHighlight } from "./feature-highlight.js";
|
|
@@ -2,42 +2,16 @@
|
|
|
2
2
|
// selection raycast, and hands a resolved Selection to onPick.
|
|
3
3
|
import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
|
|
4
4
|
import { resolveSelection } from "./resolve.js";
|
|
5
|
+
import { createDragTracker } from "./drag-tracker.js";
|
|
5
6
|
|
|
6
7
|
export { worldToSubPartLocal };
|
|
7
8
|
|
|
8
|
-
const DRAG_THRESHOLD_SQUARED = 4 ** 2;
|
|
9
|
-
|
|
10
9
|
export function attachPicker(viewer, { part, getContext, onPick }) {
|
|
11
10
|
let active = false;
|
|
12
|
-
const
|
|
13
|
-
let dragged = false;
|
|
14
|
-
|
|
15
|
-
function onPointerDown(ev) {
|
|
16
|
-
if (pointerStarts.size === 0) dragged = false;
|
|
17
|
-
pointerStarts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function onPointerMove(ev) {
|
|
21
|
-
const pointerStart = pointerStarts.get(ev.pointerId);
|
|
22
|
-
if (!pointerStart || dragged) return;
|
|
23
|
-
const dx = ev.clientX - pointerStart.x;
|
|
24
|
-
const dy = ev.clientY - pointerStart.y;
|
|
25
|
-
dragged = dx * dx + dy * dy > DRAG_THRESHOLD_SQUARED;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function onPointerUp(ev) {
|
|
29
|
-
pointerStarts.delete(ev.pointerId);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function onPointerCancel(ev) {
|
|
33
|
-
pointerStarts.delete(ev.pointerId);
|
|
34
|
-
if (pointerStarts.size === 0) dragged = false;
|
|
35
|
-
}
|
|
11
|
+
const drag = createDragTracker();
|
|
36
12
|
|
|
37
13
|
function onClick(ev) {
|
|
38
|
-
const wasDragged =
|
|
39
|
-
pointerStarts.clear();
|
|
40
|
-
dragged = false;
|
|
14
|
+
const wasDragged = drag.consumeClick();
|
|
41
15
|
if (!active || wasDragged) return;
|
|
42
16
|
const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
|
|
43
17
|
if (!hit) return;
|
|
@@ -46,18 +20,18 @@ export function attachPicker(viewer, { part, getContext, onPick }) {
|
|
|
46
20
|
onPick(selection);
|
|
47
21
|
}
|
|
48
22
|
|
|
49
|
-
viewer.domElement.addEventListener("pointerdown",
|
|
50
|
-
viewer.domElement.addEventListener("pointermove",
|
|
51
|
-
viewer.domElement.addEventListener("pointerup",
|
|
52
|
-
viewer.domElement.addEventListener("pointercancel",
|
|
23
|
+
viewer.domElement.addEventListener("pointerdown", drag.onDown);
|
|
24
|
+
viewer.domElement.addEventListener("pointermove", drag.onMove);
|
|
25
|
+
viewer.domElement.addEventListener("pointerup", drag.onUp);
|
|
26
|
+
viewer.domElement.addEventListener("pointercancel", drag.onCancel);
|
|
53
27
|
viewer.domElement.addEventListener("click", onClick);
|
|
54
28
|
return {
|
|
55
29
|
setActive: (on) => { active = !!on; },
|
|
56
30
|
detach: () => {
|
|
57
|
-
viewer.domElement.removeEventListener("pointerdown",
|
|
58
|
-
viewer.domElement.removeEventListener("pointermove",
|
|
59
|
-
viewer.domElement.removeEventListener("pointerup",
|
|
60
|
-
viewer.domElement.removeEventListener("pointercancel",
|
|
31
|
+
viewer.domElement.removeEventListener("pointerdown", drag.onDown);
|
|
32
|
+
viewer.domElement.removeEventListener("pointermove", drag.onMove);
|
|
33
|
+
viewer.domElement.removeEventListener("pointerup", drag.onUp);
|
|
34
|
+
viewer.domElement.removeEventListener("pointercancel", drag.onCancel);
|
|
61
35
|
viewer.domElement.removeEventListener("click", onClick);
|
|
62
36
|
},
|
|
63
37
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared teardown helpers: run a list of cleanup steps in isolation (one
|
|
2
|
+
// step's throw must never skip the others), and capture/restore a DOM
|
|
3
|
+
// element's attributes across an attach/detach cycle. Lifted out of
|
|
4
|
+
// cutaway-controls.js (the original) so measure-controls.js and
|
|
5
|
+
// selection/hover.js — and anything else that wraps host DOM — share one
|
|
6
|
+
// implementation instead of drifting copies.
|
|
7
|
+
|
|
8
|
+
export function runCleanupSteps(steps, message) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
for (const step of steps) {
|
|
11
|
+
try { step(); } catch (error) { errors.push(error); }
|
|
12
|
+
}
|
|
13
|
+
if (errors.length === 1) throw errors[0];
|
|
14
|
+
if (errors.length > 1) throw new AggregateError(errors, message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function captureAttributes(element, names) {
|
|
18
|
+
return new Map(names.map((name) => [name, {
|
|
19
|
+
present: element.hasAttribute(name),
|
|
20
|
+
value: element.getAttribute(name),
|
|
21
|
+
}]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function restoreAttributes(element, attributes) {
|
|
25
|
+
for (const [name, { present, value }] of attributes) {
|
|
26
|
+
if (present) element.setAttribute(name, value);
|
|
27
|
+
else element.removeAttribute(name);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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`. */
|