partforge 0.19.0 → 0.20.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 +97 -1
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +3 -1
- package/src/app-bracket.js +9 -0
- package/src/app-nameplate.js +9 -0
- package/src/app-text-smoke.js +10 -0
- package/src/bracket-worker.js +3 -0
- package/src/framework/app.css +23 -6
- package/src/framework/cutaway-controls.js +155 -0
- package/src/framework/cutaway-gizmo.js +686 -0
- package/src/framework/cutaway-math.js +53 -0
- package/src/framework/cutaway-render.js +338 -0
- package/src/framework/cutaway.js +469 -0
- package/src/framework/fonts.js +36 -0
- package/src/framework/geometry/curve-fill.js +86 -0
- package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
- package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
- package/src/framework/geometry/fonts/default-font.js +3 -0
- package/src/framework/geometry/kernel-front.js +53 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/text2d.js +98 -0
- package/src/framework/geometry-service.js +21 -2
- package/src/framework/jobs.js +9 -0
- package/src/framework/mount.js +278 -223
- package/src/framework/selection/hover.js +102 -36
- package/src/framework/selection/raycast.js +4 -1
- package/src/framework/tooltip.js +282 -0
- package/src/framework/viewer-controls.js +25 -2
- package/src/framework/viewer-lighting.js +13 -0
- package/src/framework/viewer.js +83 -10
- package/src/nameplate-worker.js +3 -0
- package/src/parts/bracket.js +76 -0
- package/src/parts/nameplate.js +67 -0
- package/src/parts/text-smoke.js +21 -0
- package/src/testing/manifold.js +6 -2
- package/src/testing/occt.js +6 -2
- package/src/text-smoke-worker.js +3 -0
|
@@ -3,10 +3,23 @@
|
|
|
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
5
|
import * as THREE from "three";
|
|
6
|
+
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
|
|
7
|
+
import { createTooltipPresenter } from "../tooltip.js";
|
|
6
8
|
import { raycastViewer } from "./raycast.js";
|
|
7
9
|
|
|
8
10
|
const HIGHLIGHT = 0x4da3ff;
|
|
9
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
|
+
|
|
10
23
|
// Extract the subset of a non-indexed geometry belonging to one feature id.
|
|
11
24
|
function featureSubset(geometry, featureId) {
|
|
12
25
|
const { featureIds } = geometry.userData;
|
|
@@ -26,25 +39,27 @@ function featureSubset(geometry, featureId) {
|
|
|
26
39
|
return g;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
export function attachHoverLabels(
|
|
42
|
+
export function attachHoverLabels(
|
|
43
|
+
viewer,
|
|
44
|
+
{ part, schedule = (cb) => requestAnimationFrame(cb), tooltip } = {},
|
|
45
|
+
) {
|
|
30
46
|
// Hover is a mouse idiom — skip entirely on touch-only devices.
|
|
31
47
|
if (globalThis.matchMedia && !matchMedia("(hover: hover)").matches) return { detach: () => {} };
|
|
32
48
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
sub.className = "pf-hover-sub";
|
|
38
|
-
tip.append(feat, sub);
|
|
39
|
-
document.body.appendChild(tip);
|
|
49
|
+
const ownsTooltip = !tooltip;
|
|
50
|
+
const tooltipPresenter = tooltip ?? createTooltipPresenter();
|
|
51
|
+
let presentationToken;
|
|
52
|
+
let hasPresented = false;
|
|
40
53
|
|
|
41
54
|
const material = new THREE.MeshBasicMaterial({
|
|
42
55
|
color: HIGHLIGHT, transparent: true, opacity: 0.35,
|
|
43
56
|
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
44
57
|
});
|
|
45
|
-
const
|
|
58
|
+
const unregisterCutaway = viewer.registerCutawayMaterial?.(material) ?? (() => {});
|
|
59
|
+
let emptyOverlayGeometry = new THREE.BufferGeometry();
|
|
60
|
+
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
46
61
|
overlay.visible = false;
|
|
47
|
-
overlay.renderOrder =
|
|
62
|
+
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
48
63
|
let overlayParent = null;
|
|
49
64
|
// Subset cache per sub-part: rebuilt when the sub-part's geometry object changes
|
|
50
65
|
// (i.e. after a regenerate) — keyed on the geometry instance.
|
|
@@ -56,15 +71,30 @@ export function attachHoverLabels(viewer, { part, schedule = (cb) => requestAnim
|
|
|
56
71
|
overlay.visible = false;
|
|
57
72
|
}
|
|
58
73
|
|
|
74
|
+
function showHighlight(hit, geometry) {
|
|
75
|
+
emptyOverlayGeometry?.dispose();
|
|
76
|
+
emptyOverlayGeometry = null;
|
|
77
|
+
overlay.geometry = geometry;
|
|
78
|
+
if (overlayParent !== hit.mesh.parent) {
|
|
79
|
+
hit.mesh.parent.add(overlay);
|
|
80
|
+
overlayParent = hit.mesh.parent;
|
|
81
|
+
}
|
|
82
|
+
overlay.visible = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
function hide() {
|
|
60
|
-
|
|
86
|
+
if (hasPresented) {
|
|
87
|
+
hasPresented = false;
|
|
88
|
+
tooltipPresenter.hide(presentationToken);
|
|
89
|
+
presentationToken = undefined;
|
|
90
|
+
}
|
|
61
91
|
clearHighlight();
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
function show(hit, x, y) {
|
|
95
|
+
let content;
|
|
65
96
|
if (hit.feature) {
|
|
66
|
-
|
|
67
|
-
sub.textContent = subLabel(hit.subPart);
|
|
97
|
+
content = { title: hit.feature.label, subtitle: subLabel(hit.subPart) };
|
|
68
98
|
const cached = subsets.get(hit.subPart);
|
|
69
99
|
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
70
100
|
if (!byId) {
|
|
@@ -74,39 +104,61 @@ export function attachHoverLabels(viewer, { part, schedule = (cb) => requestAnim
|
|
|
74
104
|
}
|
|
75
105
|
let g = byId.get(hit.feature.id);
|
|
76
106
|
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
77
|
-
|
|
78
|
-
if (overlayParent !== hit.mesh.parent) { hit.mesh.parent.add(overlay); overlayParent = hit.mesh.parent; }
|
|
79
|
-
overlay.visible = true;
|
|
107
|
+
showHighlight(hit, g);
|
|
80
108
|
} else {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
109
|
+
content = { title: subLabel(hit.subPart), subtitle: "" };
|
|
110
|
+
showHighlight(hit, hit.mesh.geometry);
|
|
111
|
+
}
|
|
112
|
+
if (hasPresented) {
|
|
113
|
+
hasPresented = false;
|
|
114
|
+
tooltipPresenter.hide(presentationToken);
|
|
115
|
+
presentationToken = undefined;
|
|
84
116
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
tip.classList.add("show");
|
|
117
|
+
presentationToken = tooltipPresenter.showPointer(content, x, y);
|
|
118
|
+
hasPresented = true;
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
let pending = null; // latest pointer position; one raycast per scheduled frame
|
|
122
|
+
let frameScheduled = false;
|
|
123
|
+
let workVersion = 0;
|
|
91
124
|
let down = false;
|
|
125
|
+
let detached = false;
|
|
126
|
+
let suppressed = false;
|
|
127
|
+
|
|
128
|
+
function invalidatePendingWork() {
|
|
129
|
+
pending = null;
|
|
130
|
+
frameScheduled = false;
|
|
131
|
+
workVersion += 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const unsubscribeHandleHover = viewer.onCutawayHandleHover?.((handle) => {
|
|
135
|
+
suppressed = handle != null;
|
|
136
|
+
if (!suppressed) return;
|
|
137
|
+
invalidatePendingWork();
|
|
138
|
+
hide();
|
|
139
|
+
}) ?? (() => {});
|
|
92
140
|
|
|
93
141
|
function onMove(ev) {
|
|
142
|
+
if (detached) return;
|
|
94
143
|
if (ev.pointerType === "touch") return;
|
|
95
|
-
if (down) return;
|
|
96
|
-
const had = pending;
|
|
144
|
+
if (down || suppressed) return;
|
|
97
145
|
pending = { x: ev.clientX, y: ev.clientY };
|
|
98
|
-
if (
|
|
146
|
+
if (frameScheduled) return;
|
|
147
|
+
frameScheduled = true;
|
|
148
|
+
const scheduledVersion = workVersion;
|
|
99
149
|
schedule(() => {
|
|
150
|
+
if (scheduledVersion !== workVersion) return;
|
|
151
|
+
frameScheduled = false;
|
|
100
152
|
const p = pending;
|
|
101
153
|
pending = null;
|
|
102
|
-
if (!p || down) return;
|
|
154
|
+
if (detached || !p || down || suppressed) return;
|
|
103
155
|
const hit = raycastViewer(viewer, p.x, p.y);
|
|
104
156
|
if (hit) show(hit, p.x, p.y); else hide();
|
|
105
157
|
});
|
|
106
158
|
}
|
|
107
|
-
const onDown = () => { down = true; hide(); };
|
|
159
|
+
const onDown = () => { down = true; invalidatePendingWork(); hide(); };
|
|
108
160
|
const onUp = () => { down = false; };
|
|
109
|
-
const onLeave = () => hide();
|
|
161
|
+
const onLeave = () => { invalidatePendingWork(); hide(); };
|
|
110
162
|
|
|
111
163
|
viewer.domElement.addEventListener("pointermove", onMove);
|
|
112
164
|
viewer.domElement.addEventListener("pointerdown", onDown);
|
|
@@ -115,14 +167,28 @@ export function attachHoverLabels(viewer, { part, schedule = (cb) => requestAnim
|
|
|
115
167
|
|
|
116
168
|
return {
|
|
117
169
|
detach: () => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
170
|
+
if (detached) return;
|
|
171
|
+
detached = true;
|
|
172
|
+
invalidatePendingWork();
|
|
173
|
+
const subsetGeometries = [...subsets.values()]
|
|
174
|
+
.flatMap(({ byId }) => [...byId.values()]);
|
|
175
|
+
const initialGeometry = emptyOverlayGeometry;
|
|
176
|
+
emptyOverlayGeometry = null;
|
|
177
|
+
runCleanupSteps([
|
|
178
|
+
unsubscribeHandleHover,
|
|
179
|
+
() => viewer.domElement.removeEventListener("pointermove", onMove),
|
|
180
|
+
() => viewer.domElement.removeEventListener("pointerdown", onDown),
|
|
181
|
+
() => viewer.domElement.removeEventListener("pointerup", onUp),
|
|
182
|
+
() => viewer.domElement.removeEventListener("pointerleave", onLeave),
|
|
183
|
+
hide,
|
|
184
|
+
() => overlayParent?.remove(overlay),
|
|
185
|
+
...subsetGeometries.map((geometry) => () => geometry.dispose()),
|
|
186
|
+
() => subsets.clear(),
|
|
187
|
+
() => initialGeometry?.dispose(),
|
|
188
|
+
unregisterCutaway,
|
|
189
|
+
() => material.dispose(),
|
|
190
|
+
() => { if (ownsTooltip) tooltipPresenter.dispose(); },
|
|
191
|
+
]);
|
|
126
192
|
},
|
|
127
193
|
};
|
|
128
194
|
}
|
|
@@ -27,7 +27,10 @@ export function raycastViewer(viewer, clientX, clientY) {
|
|
|
27
27
|
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
28
28
|
raycaster.setFromCamera(ndc, viewer.camera);
|
|
29
29
|
const meshes = Object.values(viewer._subMeshes).filter((m) => m.visible);
|
|
30
|
-
const
|
|
30
|
+
const hits = raycaster.intersectObjects(meshes, false);
|
|
31
|
+
const hit = hits.find((candidate) =>
|
|
32
|
+
viewer.isWorldPointVisible?.(candidate.point) ?? true
|
|
33
|
+
);
|
|
31
34
|
if (!hit) return null;
|
|
32
35
|
return {
|
|
33
36
|
mesh: hit.object,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const VIEWPORT_MARGIN = 8;
|
|
2
|
+
const ANCHOR_GAP = 8;
|
|
3
|
+
|
|
4
|
+
function throwCollected(errors, message) {
|
|
5
|
+
if (errors.length === 1) throw errors[0];
|
|
6
|
+
if (errors.length > 1) throw new AggregateError(errors, message);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTooltipPresenter({ id = "pf-hover-tip" } = {}) {
|
|
10
|
+
const element = document.createElement("div");
|
|
11
|
+
if (id != null && id !== "") element.id = id;
|
|
12
|
+
element.className = "pf-hover-tip";
|
|
13
|
+
|
|
14
|
+
const title = document.createElement("b");
|
|
15
|
+
const subtitle = document.createElement("span");
|
|
16
|
+
subtitle.className = "pf-hover-sub";
|
|
17
|
+
element.append(title, subtitle);
|
|
18
|
+
document.body.appendChild(element);
|
|
19
|
+
let disposed = false;
|
|
20
|
+
const claims = [];
|
|
21
|
+
|
|
22
|
+
function setContent(content) {
|
|
23
|
+
title.textContent = content.title;
|
|
24
|
+
subtitle.textContent = content.subtitle ?? "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function measureVisible(content, anchored) {
|
|
28
|
+
setContent(content);
|
|
29
|
+
element.style.left = `${VIEWPORT_MARGIN}px`;
|
|
30
|
+
element.style.top = `${VIEWPORT_MARGIN}px`;
|
|
31
|
+
element.classList.toggle("pf-tooltip-anchored", anchored);
|
|
32
|
+
element.classList.add("show");
|
|
33
|
+
const rect = element.getBoundingClientRect();
|
|
34
|
+
return {
|
|
35
|
+
width: Math.max(0, rect.width || 0),
|
|
36
|
+
height: Math.max(0, rect.height || 0),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderClaim(claim) {
|
|
41
|
+
const viewportWidth = Math.max(0, globalThis.innerWidth || 0);
|
|
42
|
+
const viewportHeight = Math.max(0, globalThis.innerHeight || 0);
|
|
43
|
+
const { width, height } = measureVisible(claim.content, claim.kind === "anchor");
|
|
44
|
+
const maxLeft = Math.max(
|
|
45
|
+
VIEWPORT_MARGIN,
|
|
46
|
+
viewportWidth - VIEWPORT_MARGIN - width,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (claim.kind === "pointer") {
|
|
50
|
+
const maxTop = Math.max(
|
|
51
|
+
VIEWPORT_MARGIN,
|
|
52
|
+
viewportHeight - VIEWPORT_MARGIN - height,
|
|
53
|
+
);
|
|
54
|
+
element.style.left = `${Math.min(Math.max(claim.x + 14, VIEWPORT_MARGIN), maxLeft)}px`;
|
|
55
|
+
element.style.top = `${Math.min(Math.max(claim.y + 14, VIEWPORT_MARGIN), maxTop)}px`;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rect = claim.anchor.getBoundingClientRect();
|
|
60
|
+
const centeredLeft = (rect.left + rect.right - width) / 2;
|
|
61
|
+
element.style.left = `${Math.min(Math.max(centeredLeft, VIEWPORT_MARGIN), maxLeft)}px`;
|
|
62
|
+
const belowTop = rect.bottom + ANCHOR_GAP;
|
|
63
|
+
const fitsBelow = belowTop + height <= viewportHeight - VIEWPORT_MARGIN;
|
|
64
|
+
const top = fitsBelow
|
|
65
|
+
? Math.max(VIEWPORT_MARGIN, belowTop)
|
|
66
|
+
: Math.max(VIEWPORT_MARGIN, rect.top - ANCHOR_GAP - height);
|
|
67
|
+
element.style.top = `${top}px`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function addClaim(claim) {
|
|
71
|
+
const token = Symbol("tooltip presentation");
|
|
72
|
+
claims.push({ ...claim, token });
|
|
73
|
+
renderClaim(claims.at(-1));
|
|
74
|
+
return token;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
showPointer(content, x, y) {
|
|
79
|
+
if (disposed) return;
|
|
80
|
+
return addClaim({ kind: "pointer", content, x, y });
|
|
81
|
+
},
|
|
82
|
+
showAnchor(content, anchor) {
|
|
83
|
+
if (disposed) return;
|
|
84
|
+
return addClaim({ kind: "anchor", content, anchor });
|
|
85
|
+
},
|
|
86
|
+
hide(token) {
|
|
87
|
+
if (disposed) return;
|
|
88
|
+
const index = token === undefined
|
|
89
|
+
? claims.length - 1
|
|
90
|
+
: claims.findIndex((claim) => claim.token === token);
|
|
91
|
+
if (index < 0) return;
|
|
92
|
+
const wasActive = index === claims.length - 1;
|
|
93
|
+
claims.splice(index, 1);
|
|
94
|
+
if (!wasActive) return;
|
|
95
|
+
const next = claims.at(-1);
|
|
96
|
+
if (next) renderClaim(next);
|
|
97
|
+
else element.classList.remove("show");
|
|
98
|
+
},
|
|
99
|
+
dispose() {
|
|
100
|
+
if (disposed) return;
|
|
101
|
+
disposed = true;
|
|
102
|
+
claims.length = 0;
|
|
103
|
+
element.remove();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function attachButtonTooltips(tooltip, entries) {
|
|
109
|
+
const attached = [];
|
|
110
|
+
|
|
111
|
+
for (const entry of entries ?? []) {
|
|
112
|
+
const element = entry?.element;
|
|
113
|
+
if (!element) continue;
|
|
114
|
+
|
|
115
|
+
const originalTitle = {
|
|
116
|
+
present: element.hasAttribute("title"),
|
|
117
|
+
value: element.getAttribute("title"),
|
|
118
|
+
};
|
|
119
|
+
const originalAriaLabel = {
|
|
120
|
+
present: element.hasAttribute("aria-label"),
|
|
121
|
+
value: element.getAttribute("aria-label"),
|
|
122
|
+
};
|
|
123
|
+
if (!originalAriaLabel.present && originalTitle.present) {
|
|
124
|
+
element.setAttribute("aria-label", originalTitle.value);
|
|
125
|
+
}
|
|
126
|
+
element.removeAttribute("title");
|
|
127
|
+
|
|
128
|
+
let hovered = false;
|
|
129
|
+
let focused = false;
|
|
130
|
+
let touchActivation = false;
|
|
131
|
+
let dismissed = false;
|
|
132
|
+
let presentationToken;
|
|
133
|
+
let hasPresented = false;
|
|
134
|
+
const isUnavailable = () => (
|
|
135
|
+
element.disabled
|
|
136
|
+
|| element.getAttribute("aria-disabled")?.toLowerCase() === "true"
|
|
137
|
+
);
|
|
138
|
+
const showIfNeeded = () => {
|
|
139
|
+
if (hasPresented || dismissed || touchActivation || isUnavailable() || (!hovered && !focused)) return;
|
|
140
|
+
const label = entry.getLabel?.()
|
|
141
|
+
?? element.getAttribute("aria-label")
|
|
142
|
+
?? originalTitle.value
|
|
143
|
+
?? "";
|
|
144
|
+
presentationToken = tooltip.showAnchor({ title: label }, element);
|
|
145
|
+
hasPresented = true;
|
|
146
|
+
};
|
|
147
|
+
const onPointerEnter = (event) => {
|
|
148
|
+
if (event.pointerType === "touch") return;
|
|
149
|
+
hovered = true;
|
|
150
|
+
dismissed = false;
|
|
151
|
+
showIfNeeded();
|
|
152
|
+
};
|
|
153
|
+
const hidePresentation = () => {
|
|
154
|
+
if (!hasPresented) return;
|
|
155
|
+
const token = presentationToken;
|
|
156
|
+
hasPresented = false;
|
|
157
|
+
presentationToken = undefined;
|
|
158
|
+
tooltip.hide(token);
|
|
159
|
+
};
|
|
160
|
+
const syncVisibility = () => {
|
|
161
|
+
if (dismissed || isUnavailable() || (!hovered && !focused)) hidePresentation();
|
|
162
|
+
else showIfNeeded();
|
|
163
|
+
};
|
|
164
|
+
const onPointerLeave = (event) => {
|
|
165
|
+
if (event.pointerType === "touch") return;
|
|
166
|
+
hovered = false;
|
|
167
|
+
syncVisibility();
|
|
168
|
+
};
|
|
169
|
+
const onPointerDown = (event) => {
|
|
170
|
+
if (event.pointerType !== "touch") return;
|
|
171
|
+
touchActivation = true;
|
|
172
|
+
dismissed = true;
|
|
173
|
+
hidePresentation();
|
|
174
|
+
};
|
|
175
|
+
const onPointerCancel = (event) => {
|
|
176
|
+
if (event.pointerType !== "touch") return;
|
|
177
|
+
touchActivation = false;
|
|
178
|
+
dismissed = true;
|
|
179
|
+
hidePresentation();
|
|
180
|
+
};
|
|
181
|
+
const onFocus = () => {
|
|
182
|
+
focused = true;
|
|
183
|
+
if (touchActivation) return;
|
|
184
|
+
dismissed = false;
|
|
185
|
+
showIfNeeded();
|
|
186
|
+
};
|
|
187
|
+
const onBlur = () => {
|
|
188
|
+
focused = false;
|
|
189
|
+
touchActivation = false;
|
|
190
|
+
syncVisibility();
|
|
191
|
+
};
|
|
192
|
+
const dismiss = () => {
|
|
193
|
+
dismissed = true;
|
|
194
|
+
touchActivation = false;
|
|
195
|
+
hidePresentation();
|
|
196
|
+
};
|
|
197
|
+
element.addEventListener("pointerenter", onPointerEnter);
|
|
198
|
+
element.addEventListener("pointerleave", onPointerLeave);
|
|
199
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
200
|
+
element.addEventListener("pointercancel", onPointerCancel);
|
|
201
|
+
element.addEventListener("focus", onFocus);
|
|
202
|
+
element.addEventListener("blur", onBlur);
|
|
203
|
+
element.addEventListener("click", dismiss);
|
|
204
|
+
attached.push({
|
|
205
|
+
element,
|
|
206
|
+
originalTitle,
|
|
207
|
+
originalAriaLabel,
|
|
208
|
+
onPointerEnter,
|
|
209
|
+
onPointerLeave,
|
|
210
|
+
onPointerDown,
|
|
211
|
+
onPointerCancel,
|
|
212
|
+
onFocus,
|
|
213
|
+
onBlur,
|
|
214
|
+
dismiss,
|
|
215
|
+
syncVisibility,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let detached = false;
|
|
220
|
+
const runAll = (operation) => {
|
|
221
|
+
const errors = [];
|
|
222
|
+
for (const binding of attached) {
|
|
223
|
+
try { operation(binding); } catch (error) { errors.push(error); }
|
|
224
|
+
}
|
|
225
|
+
return errors;
|
|
226
|
+
};
|
|
227
|
+
const hide = () => {
|
|
228
|
+
if (detached) return;
|
|
229
|
+
throwCollected(
|
|
230
|
+
runAll((binding) => binding.dismiss()),
|
|
231
|
+
"button tooltip hide failed",
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
hide,
|
|
236
|
+
sync() {
|
|
237
|
+
if (detached) return;
|
|
238
|
+
throwCollected(
|
|
239
|
+
runAll((binding) => binding.syncVisibility()),
|
|
240
|
+
"button tooltip sync failed",
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
detach() {
|
|
244
|
+
if (detached) return;
|
|
245
|
+
detached = true;
|
|
246
|
+
const errors = runAll((binding) => binding.dismiss());
|
|
247
|
+
for (const binding of attached) {
|
|
248
|
+
const {
|
|
249
|
+
element,
|
|
250
|
+
originalTitle,
|
|
251
|
+
originalAriaLabel,
|
|
252
|
+
onPointerEnter,
|
|
253
|
+
onPointerLeave,
|
|
254
|
+
onPointerDown,
|
|
255
|
+
onPointerCancel,
|
|
256
|
+
onFocus,
|
|
257
|
+
onBlur,
|
|
258
|
+
dismiss,
|
|
259
|
+
} = binding;
|
|
260
|
+
try {
|
|
261
|
+
element.removeEventListener("pointerenter", onPointerEnter);
|
|
262
|
+
element.removeEventListener("pointerleave", onPointerLeave);
|
|
263
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
264
|
+
element.removeEventListener("pointercancel", onPointerCancel);
|
|
265
|
+
element.removeEventListener("focus", onFocus);
|
|
266
|
+
element.removeEventListener("blur", onBlur);
|
|
267
|
+
element.removeEventListener("click", dismiss);
|
|
268
|
+
if (originalTitle.present) element.setAttribute("title", originalTitle.value);
|
|
269
|
+
else element.removeAttribute("title");
|
|
270
|
+
if (originalAriaLabel.present) {
|
|
271
|
+
element.setAttribute("aria-label", originalAriaLabel.value);
|
|
272
|
+
} else {
|
|
273
|
+
element.removeAttribute("aria-label");
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
errors.push(error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
throwCollected(errors, "button tooltip detach failed");
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { loadRotating, saveRotating, saveCamera, loadTheme, saveTheme } from "./view-state.js";
|
|
2
|
+
import { attachButtonTooltips } from "./tooltip.js";
|
|
2
3
|
|
|
3
4
|
// Wire the optional viewer-chrome buttons (pause / reframe / theme) to the viewer,
|
|
4
5
|
// plus persist the camera pose. Element refs in (mount resolves defaults); each
|
|
5
6
|
// button is optional — pass nothing and its behavior is simply absent. Returns
|
|
6
7
|
// { detach } removing every listener this attached.
|
|
7
|
-
export function attachViewerControls(
|
|
8
|
+
export function attachViewerControls(
|
|
9
|
+
viewer,
|
|
10
|
+
{ pause: pauseBtn, reframe: reframeBtn, theme: themeBtn } = {},
|
|
11
|
+
{ tooltip } = {},
|
|
12
|
+
) {
|
|
13
|
+
const tooltipBinding = tooltip
|
|
14
|
+
? attachButtonTooltips(tooltip, [pauseBtn, reframeBtn, themeBtn].map((element) => ({ element })))
|
|
15
|
+
: null;
|
|
16
|
+
|
|
8
17
|
// Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
|
|
9
18
|
// scene together; remember the choice across reloads.
|
|
10
19
|
let theme = loadTheme();
|
|
@@ -13,7 +22,13 @@ export function attachViewerControls(viewer, { pause: pauseBtn, reframe: reframe
|
|
|
13
22
|
document.documentElement.dataset.theme = mode;
|
|
14
23
|
viewer.setTheme(mode);
|
|
15
24
|
themeBtn?.classList.toggle("on", mode === "light");
|
|
25
|
+
if (themeBtn) {
|
|
26
|
+
const label = mode === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
27
|
+
themeBtn.setAttribute("aria-label", label);
|
|
28
|
+
if (!tooltip) themeBtn.title = label;
|
|
29
|
+
}
|
|
16
30
|
saveTheme(mode);
|
|
31
|
+
tooltipBinding?.sync();
|
|
17
32
|
}
|
|
18
33
|
applyTheme(theme);
|
|
19
34
|
const onThemeClick = () => applyTheme(theme === "light" ? "dark" : "light");
|
|
@@ -25,7 +40,10 @@ export function attachViewerControls(viewer, { pause: pauseBtn, reframe: reframe
|
|
|
25
40
|
const syncPause = () => {
|
|
26
41
|
if (!pauseBtn) return;
|
|
27
42
|
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
28
|
-
|
|
43
|
+
const label = rotating ? "Pause rotation" : "Resume rotation";
|
|
44
|
+
pauseBtn.setAttribute("aria-label", label);
|
|
45
|
+
if (!tooltip) pauseBtn.title = label;
|
|
46
|
+
tooltipBinding?.sync();
|
|
29
47
|
};
|
|
30
48
|
syncPause();
|
|
31
49
|
const onPauseClick = () => {
|
|
@@ -37,6 +55,10 @@ export function attachViewerControls(viewer, { pause: pauseBtn, reframe: reframe
|
|
|
37
55
|
pauseBtn?.addEventListener("click", onPauseClick);
|
|
38
56
|
|
|
39
57
|
// Re-fit the camera to the current view.
|
|
58
|
+
if (reframeBtn) {
|
|
59
|
+
reframeBtn.setAttribute("aria-label", "Re-frame model");
|
|
60
|
+
if (!tooltip) reframeBtn.title = "Re-frame model";
|
|
61
|
+
}
|
|
40
62
|
const onReframeClick = () => viewer.frame();
|
|
41
63
|
reframeBtn?.addEventListener("click", onReframeClick);
|
|
42
64
|
|
|
@@ -52,6 +74,7 @@ export function attachViewerControls(viewer, { pause: pauseBtn, reframe: reframe
|
|
|
52
74
|
pauseBtn?.removeEventListener("click", onPauseClick);
|
|
53
75
|
reframeBtn?.removeEventListener("click", onReframeClick);
|
|
54
76
|
window.removeEventListener("pagehide", onPageHide);
|
|
77
|
+
tooltipBinding?.detach();
|
|
55
78
|
// the onCameraEnd listener lives on the OrbitControls object, which
|
|
56
79
|
// viewer.dispose() destroys — nothing to remove here
|
|
57
80
|
},
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
export function addViewerLights(scene) {
|
|
4
|
+
const hemisphere = new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
5
|
+
const key = new THREE.DirectionalLight(0xffffff, 1.45);
|
|
6
|
+
key.position.set(8, 14, 10);
|
|
7
|
+
const fill = new THREE.DirectionalLight(0xe5efff, 0.65);
|
|
8
|
+
fill.position.set(-10, 6, -8);
|
|
9
|
+
|
|
10
|
+
scene.add(hemisphere, key, fill);
|
|
11
|
+
|
|
12
|
+
return { hemisphere, key, fill };
|
|
13
|
+
}
|