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.
Files changed (37) hide show
  1. package/docs/AUTHORING-PARTS.md +97 -1
  2. package/docs/ERROR-PATTERNS.md +6 -0
  3. package/package.json +3 -1
  4. package/src/app-bracket.js +9 -0
  5. package/src/app-nameplate.js +9 -0
  6. package/src/app-text-smoke.js +10 -0
  7. package/src/bracket-worker.js +3 -0
  8. package/src/framework/app.css +23 -6
  9. package/src/framework/cutaway-controls.js +155 -0
  10. package/src/framework/cutaway-gizmo.js +686 -0
  11. package/src/framework/cutaway-math.js +53 -0
  12. package/src/framework/cutaway-render.js +338 -0
  13. package/src/framework/cutaway.js +469 -0
  14. package/src/framework/fonts.js +36 -0
  15. package/src/framework/geometry/curve-fill.js +86 -0
  16. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  17. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  18. package/src/framework/geometry/fonts/default-font.js +3 -0
  19. package/src/framework/geometry/kernel-front.js +53 -0
  20. package/src/framework/geometry/kernel.js +1 -1
  21. package/src/framework/geometry/text2d.js +98 -0
  22. package/src/framework/geometry-service.js +21 -2
  23. package/src/framework/jobs.js +9 -0
  24. package/src/framework/mount.js +278 -223
  25. package/src/framework/selection/hover.js +102 -36
  26. package/src/framework/selection/raycast.js +4 -1
  27. package/src/framework/tooltip.js +282 -0
  28. package/src/framework/viewer-controls.js +25 -2
  29. package/src/framework/viewer-lighting.js +13 -0
  30. package/src/framework/viewer.js +83 -10
  31. package/src/nameplate-worker.js +3 -0
  32. package/src/parts/bracket.js +76 -0
  33. package/src/parts/nameplate.js +67 -0
  34. package/src/parts/text-smoke.js +21 -0
  35. package/src/testing/manifold.js +6 -2
  36. package/src/testing/occt.js +6 -2
  37. 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(viewer, { part, schedule = (cb) => requestAnimationFrame(cb) }) {
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 tip = document.createElement("div");
34
- tip.id = "pf-hover-tip";
35
- const feat = document.createElement("b");
36
- const sub = document.createElement("span");
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 overlay = new THREE.Mesh(new THREE.BufferGeometry(), material);
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 = 2;
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
- tip.classList.remove("show");
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
- feat.textContent = hit.feature.label;
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
- overlay.geometry = g;
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
- feat.textContent = subLabel(hit.subPart);
82
- sub.textContent = "";
83
- clearHighlight();
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
- tip.style.left = `${x + 14}px`;
86
- tip.style.top = `${y + 14}px`;
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 (had) return; // a frame is already scheduled
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
- viewer.domElement.removeEventListener("pointermove", onMove);
119
- viewer.domElement.removeEventListener("pointerdown", onDown);
120
- viewer.domElement.removeEventListener("pointerup", onUp);
121
- viewer.domElement.removeEventListener("pointerleave", onLeave);
122
- tip.remove();
123
- overlayParent?.remove(overlay);
124
- for (const { byId } of subsets.values()) for (const g of byId.values()) g.dispose();
125
- material.dispose();
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 hit = raycaster.intersectObjects(meshes, false)[0];
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(viewer, { pause: pauseBtn, reframe: reframeBtn, theme: themeBtn } = {}) {
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
- pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
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
+ }