@preference-sl/pref-viewer 2.18.0 → 3.0.0-beta.1

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 (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +542 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +22 -4
  22. package/src/gltf-resolver.js +83 -13
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +291 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
@@ -0,0 +1,1012 @@
1
+ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
2
+ import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
3
+ import { PrefViewerViewCubeStyles } from "./styles.js";
4
+ import I18n from "./localization/i18n.js";
5
+ import BabylonJSController from "./babylonjs-controller.js";
6
+ import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
7
+
8
+ /**
9
+ * @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
10
+ */
11
+
12
+ /**
13
+ * @typedef {import("./babylonjs-camera-framing.js").CameraFramingBounds} ViewCubeBounds
14
+ */
15
+
16
+ /**
17
+ * One planar surface of a sharp-cube interaction region.
18
+ * @typedef {Object} ViewCubeSurface
19
+ * @property {Vector3} normal - Outward cube-local normal.
20
+ * @property {Vector3[]} vertices - Four cube-local polygon vertices.
21
+ */
22
+
23
+ /**
24
+ * Face, edge or corner orientation preset and its clickable geometry.
25
+ * @typedef {Object} ViewCubeRegion
26
+ * @property {string} id - Comma-separated signed axes identifying the preset.
27
+ * @property {Vector3} direction - Normalized direction from target toward observer.
28
+ * @property {"face"|"edge"|"corner"} kind - Interaction region category.
29
+ * @property {ViewCubeSurface[]} surfaces - One, two or three clickable planar surfaces.
30
+ * @property {ViewCubeSurface} [outline] - Full shell face, supplied only for face presets.
31
+ */
32
+
33
+ /**
34
+ * @typedef {import("./babylonjs-camera-framing.js").CameraFramingFit} ViewCubeFit
35
+ */
36
+
37
+ /**
38
+ * World-space pose snapshot for transition interpolation.
39
+ * @typedef {Object} ViewCubePose
40
+ * @property {Vector3} position - World-space camera position.
41
+ * @property {Quaternion} rotation - World-space camera orientation.
42
+ * @property {Vector3} target - Effective world-space target.
43
+ * @property {number} distance - Positive camera-to-target world distance.
44
+ * @property {number|null} left - Current orthographic left extent.
45
+ * @property {number|null} right - Current orthographic right extent.
46
+ * @property {number|null} bottom - Current orthographic bottom extent.
47
+ * @property {number|null} top - Current orthographic top extent.
48
+ */
49
+
50
+ /**
51
+ * Virtual camera inputs used to project cube surfaces into SVG.
52
+ * @typedef {Object} ViewCubeProjection
53
+ * @property {Matrix} view - Cube-local to virtual camera rotation matrix.
54
+ * @property {boolean} perspective - Whether to apply perspective scaling.
55
+ * @property {number} depthSign - Scene handedness depth direction.
56
+ * @property {number} eyeDistance - Virtual camera distance independent of model zoom.
57
+ */
58
+
59
+ /**
60
+ * BabylonJSViewCubeSVGController - Owns the selectable SVG orientation indicator and model-framing camera navigation.
61
+ *
62
+ * Displays 26 face/edge/corner presets with primary-color hover and nearest-view feedback.
63
+ * A final SVG label layer keeps face text above all interactive fills and highlight outlines.
64
+ * Synchronizes camera projection and orientation while locked cameras remain display-only.
65
+ * Stylesheets own widget dimensions; resize only caps them to the available viewport.
66
+ * Accepted clicks preserve projection, fit only model bounds and retain world +Y as camera up.
67
+ * Mouse or one-finger dragging orbits ArcRotateCamera directly, independently of canvas inputs, preserving target/radius/limits.
68
+ * Owns pointer capture and temporary touch hover until release or cancellation; full camera locks/XR forbid navigation.
69
+ *
70
+ * The host owns the camera, scene, assets and render loop. It calls tick() to advance transitions
71
+ * and sync() to refresh the indicator. The cube requests rendering but allocates no Engine,
72
+ * Scene or asset meshes. dispose() releases only the overlay and its listeners/observers.
73
+ *
74
+ * Members are ordered as properties, constructor, static methods, private methods and public methods.
75
+ * @class
76
+ */
77
+ export default class BabylonJSViewCubeSVGController {
78
+ static #SVG_NS = "http://www.w3.org/2000/svg";
79
+ static #DRAG_THRESHOLD = 4;
80
+ static #DRAG_ANGLE = Math.PI;
81
+
82
+ #cameraDistances = new WeakMap();
83
+ #host;
84
+ #root;
85
+ #svg;
86
+ #regions = BabylonJSViewCubeSVGController.createViewCubeRegions();
87
+ #nodes = new Map();
88
+ #shell = [];
89
+ #geometryKey = null;
90
+ #locale;
91
+ #locked;
92
+ #enabled = false;
93
+ #transition = null;
94
+ #camera = null;
95
+ #observer = null;
96
+ #syncing = false;
97
+ #disposed = false;
98
+ #hover = null;
99
+ #pointer = null;
100
+ #listeners = [];
101
+ #resizeObserver;
102
+ #viewportResizeObserver;
103
+ #layoutKey = null;
104
+ #drag = null;
105
+ #touchFeedback = null;
106
+ #suppressClick = false;
107
+ #clickRegion = null;
108
+
109
+ /**
110
+ * Creates the CSS-sized SVG overlay with a final text layer above highlights, then enables input/lifecycle observers.
111
+ * The host retains ownership of the camera, scene, assets and render loop. Callbacks resolve
112
+ * current state so camera/model replacement does not leave the cube with stale references.
113
+ *
114
+ * @public
115
+ * @param {Object} host - Internal integration contract supplied by the owning viewer.
116
+ * @param {HTMLCanvasElement} host.canvas - Connected scene canvas; its parent receives the cube overlay.
117
+ * @param {function(): (import("@babylonjs/core").Camera|null|undefined)} host.getCamera - Returns the effective active camera, or no camera while unavailable.
118
+ * @param {function(import("@babylonjs/core").Camera|null|undefined): boolean} host.isLocked - Reports whether navigation is forbidden for the supplied camera, including unavailable cameras or active XR.
119
+ * @param {function(): (ViewCubeBounds|null)} host.getBounds - Returns current model-only world bounds and their eight corners; null when geometry is unusable. Excludes the environment.
120
+ * @param {function(): {width: number, height: number}} host.getViewport - Returns the current scene viewport dimensions in CSS pixels.
121
+ * @param {function(): void} host.requestRender - Requests rendering through the host scheduler after accepted navigation.
122
+ * @param {function(): string} [host.getCulture] - Returns the current localization culture; omitted callbacks use the translator's default culture.
123
+ * @param {function(number, number): void} [host.onLayout] - Receives the measured cube SVG viewport height and scene viewport height, in CSS pixels; zero cube height clears the menu offset, and disposal reports (0, 0).
124
+ */
125
+ constructor(host) {
126
+ this.#host = host;
127
+ const doc = host.canvas.ownerDocument;
128
+ this.#root = doc.createElement("div");
129
+ this.#root.className = "pref-viewer-view-cube";
130
+ this.#root.dataset.renderer = "svg";
131
+ const style = doc.createElement("style");
132
+ style.textContent = PrefViewerViewCubeStyles;
133
+ this.#root.append(style);
134
+ this.#svg = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "svg");
135
+ this.#svg.setAttribute("viewBox", "0 0 120 120");
136
+ this.#svg.setAttribute("role", "group");
137
+ this.#root.append(this.#svg);
138
+ const labels = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "g");
139
+ labels.setAttribute("class", "cube-labels");
140
+ for (const region of this.#regions.filter((r) => r.kind === "face")) {
141
+ const polygon = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "polygon");
142
+ polygon.setAttribute("class", "cube-shell");
143
+ this.#svg.append(polygon);
144
+ this.#shell.push({ ...region.outline, polygon });
145
+ }
146
+ for (const region of this.#regions) {
147
+ const group = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "g");
148
+ const surfaces = region.surfaces.map((surface) => {
149
+ const polygon = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "polygon");
150
+ polygon.dataset.region = region.id;
151
+ polygon.dataset.kind = region.kind;
152
+ group.append(polygon);
153
+ return { ...surface, polygon };
154
+ });
155
+ const label = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "text");
156
+ if (region.kind === "face") {
157
+ label.dataset.face = region.id;
158
+ labels.append(label);
159
+ }
160
+ this.#svg.append(group);
161
+ const outline = doc.createElementNS(BabylonJSViewCubeSVGController.#SVG_NS, "path");
162
+ outline.setAttribute("class", "hover-outline");
163
+ this.#nodes.set(region.id, { group, surfaces, label, outline });
164
+ }
165
+ // Stroke every clickable boundary above all filled regions, including interior face boundaries.
166
+ for (const node of this.#nodes.values()) {
167
+ this.#svg.append(node.outline);
168
+ }
169
+ this.#svg.append(labels);
170
+ host.canvas.parentElement.append(this.#root);
171
+ this.#addListener(this.#svg, "pointermove", this.#onPointerMove);
172
+ this.#addListener(this.#svg, "pointerleave", () => {
173
+ if (!this.#drag) {
174
+ this.#pointer = null;
175
+ this.#setHover(null);
176
+ }
177
+ });
178
+ this.#addListener(this.#svg, "pointerdown", this.#onPointerDown);
179
+ this.#addListener(this.#svg, "pointerup", this.#onPointerUp);
180
+ this.#addListener(this.#svg, "pointercancel", this.#onPointerUp);
181
+ this.#addListener(this.#svg, "lostpointercapture", this.#onPointerUp);
182
+ this.#addListener(this.#svg, "click", this.#onClick);
183
+ if (doc.defaultView) {
184
+ this.#addListener(doc.defaultView, "blur", () => this.#finishDrag());
185
+ this.#addListener(doc.defaultView, "keydown", this.#onDragKeyDown);
186
+ }
187
+ for (const event of ["pointerdown", "wheel", "keydown"]) {
188
+ this.#addListener(host.canvas, event, () => this.cancel());
189
+ }
190
+ this.#resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.resize());
191
+ this.#resizeObserver?.observe(host.canvas);
192
+ this.#viewportResizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.#publishLayout());
193
+ this.#viewportResizeObserver?.observe(this.#svg);
194
+ this.setEnabled(true);
195
+ }
196
+
197
+ /**
198
+ * Captures a primary mouse or single-touch gesture on an unlocked orbital region independently of canvas inputs.
199
+ * Secondary touches cancel the pending gesture; touch uses temporary hover styling without a hold delay.
200
+ * Supported non-orbital cameras receive the same press feedback while retaining their preset-only navigation.
201
+ * Pending clicks retain their original selection behavior until movement exceeds the threshold.
202
+ * @private
203
+ * @param {PointerEvent} event - Pointer press originating on the SVG.
204
+ * @returns {void}
205
+ */
206
+ #onPointerDown = event => {
207
+ if (this.#drag || this.#touchFeedback) {
208
+ const pointerId = this.#drag?.pointerId ?? this.#touchFeedback.pointerId;
209
+ if (event.pointerType === "touch" && event.pointerId !== pointerId) {
210
+ event.stopPropagation();
211
+ this.#finishDrag();
212
+ }
213
+ return;
214
+ }
215
+ this.#suppressClick = false;
216
+ this.#clickRegion = null;
217
+ const region = BabylonJSViewCubeSVGController.#getRegionAt(event);
218
+ if (!region) {
219
+ return;
220
+ }
221
+ event.stopPropagation();
222
+ const camera = this.#host.getCamera();
223
+ if (event.button !== 0 || !["mouse", "touch"].includes(event.pointerType) || event.isPrimary === false
224
+ || this.#disposed || !this.#enabled || !BabylonJSCameraFraming.isCameraSupported(camera) || this.#host.isLocked(camera)) {
225
+ return;
226
+ }
227
+ if (!this.#isOrbit(camera) && event.pointerType !== "touch") {
228
+ return;
229
+ }
230
+ this.cancel();
231
+ if (event.pointerType === "touch") {
232
+ this.#pointer = null;
233
+ this.#touchFeedback = { pointerId: event.pointerId, region, camera, mode: camera.mode };
234
+ this.#setHover(region);
235
+ }
236
+ if (!this.#isOrbit(camera)) {
237
+ this.#svg.setPointerCapture?.(event.pointerId);
238
+ return;
239
+ }
240
+ this.#drag = { camera, region, mode: camera.mode, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
241
+ alpha: camera.alpha, beta: camera.beta, size: Math.max(1, this.#svg.getBoundingClientRect().width), moved: false };
242
+ this.#svg.setPointerCapture?.(event.pointerId);
243
+ };
244
+
245
+ /**
246
+ * Rotates the captured orbital camera directly and requests continuous host interaction rendering after accepted movement.
247
+ * Preserves target/radius/projection and limits; capture allows mouse or touch movement outside the widget.
248
+ * Touch press feedback clears when movement crosses the drag threshold; uncaptured touch cannot create sticky hover.
249
+ * @private
250
+ * @param {PointerEvent} event - Pointer update, or ordinary hover when no drag is pending.
251
+ * @returns {void}
252
+ */
253
+ #onPointerMove = event => {
254
+ const drag = this.#drag;
255
+ if (!drag) {
256
+ if (event.pointerType === "touch") {
257
+ return;
258
+ }
259
+ this.#pointer = { x: event.clientX, y: event.clientY };
260
+ this.#setHover(BabylonJSViewCubeSVGController.#getRegionAt(event));
261
+ return;
262
+ }
263
+ if (event.pointerId !== drag.pointerId) {
264
+ return;
265
+ }
266
+ const camera = drag.camera;
267
+ if (this.#host.getCamera() !== camera || this.#host.isLocked(camera) || camera.mode !== drag.mode || !(event.buttons & 1)) {
268
+ this.#finishDrag();
269
+ return;
270
+ }
271
+ const dx = event.clientX - drag.x;
272
+ const dy = event.clientY - drag.y;
273
+ if (!drag.moved && Math.hypot(dx, dy) < BabylonJSViewCubeSVGController.#DRAG_THRESHOLD) {
274
+ return;
275
+ }
276
+ drag.moved = true;
277
+ this.#suppressClick = true;
278
+ this.#root.dataset.dragging = "true";
279
+ this.#pointer = null;
280
+ this.#setHover(null);
281
+ event.preventDefault();
282
+ event.stopPropagation();
283
+ const scale = BabylonJSViewCubeSVGController.#DRAG_ANGLE / drag.size;
284
+ const direction = camera.getScene().useRightHandedSystem ? -1 : 1;
285
+ this.#clearInertia(camera);
286
+ camera.alpha = Math.max(camera.lowerAlphaLimit ?? -Infinity,
287
+ Math.min(camera.upperAlphaLimit ?? Infinity, drag.alpha - dx * scale * direction));
288
+ camera.beta = Math.max(camera.lowerBetaLimit ?? 0.001,
289
+ Math.min(camera.upperBetaLimit ?? Math.PI - 0.001, drag.beta - dy * scale));
290
+ camera.getViewMatrix(true);
291
+ this.#host.requestRender({ interaction: true });
292
+ this.sync();
293
+ };
294
+
295
+ /**
296
+ * Releases the matching captured press/drag, clears temporary touch hover and suppresses a consumed drag's click.
297
+ * @private
298
+ * @param {PointerEvent} event - Release, cancellation or lost-capture event.
299
+ * @returns {void}
300
+ */
301
+ #onPointerUp = event => {
302
+ const gesture = this.#drag ?? this.#touchFeedback;
303
+ if (gesture?.pointerId === event.pointerId) {
304
+ if (event.type === "pointerup" && !this.#drag?.moved) {
305
+ this.#clickRegion = gesture.region;
306
+ }
307
+ this.#finishDrag(event.type === "pointerup");
308
+ }
309
+ };
310
+
311
+ /**
312
+ * Retains preset selection for clicks/taps and consumes compatibility clicks emitted after mouse or touch orbit.
313
+ * @private
314
+ * @param {MouseEvent} event - SVG click, including the browser's post-drag click.
315
+ * @returns {void}
316
+ */
317
+ #onClick = event => {
318
+ const capturedRegion = this.#clickRegion;
319
+ this.#clickRegion = null;
320
+ if (this.#suppressClick) {
321
+ this.#suppressClick = false;
322
+ event.preventDefault();
323
+ event.stopPropagation();
324
+ return;
325
+ }
326
+ const id = BabylonJSViewCubeSVGController.#getRegionAt(event) ?? capturedRegion;
327
+ if (!id) {
328
+ return;
329
+ }
330
+ event.stopPropagation();
331
+ if (event.button === 0) {
332
+ this.select(id);
333
+ }
334
+ };
335
+
336
+ /**
337
+ * Cancels an in-progress cube gesture on Escape without modifying its last accepted camera orientation.
338
+ * @private
339
+ * @param {KeyboardEvent} event - Owning window key event.
340
+ * @returns {void}
341
+ */
342
+ #onDragKeyDown = event => {
343
+ if (event.key === "Escape") {
344
+ this.#finishDrag();
345
+ }
346
+ };
347
+
348
+ /**
349
+ * Drops gesture ownership before releasing pointer capture so lost-capture dispatch cannot recurse.
350
+ * Suppresses trailing clicks for cancelled gestures, including those below the movement threshold.
351
+ * Clears touch hover for both orbital drags and non-orbital press feedback.
352
+ * No camera inputs or scene resources are changed.
353
+ * @private
354
+ * @param {boolean} [allowClick=false] - A normal pointer release permits a short click; cancellation does not.
355
+ * @returns {void}
356
+ */
357
+ #finishDrag(allowClick = false) {
358
+ const drag = this.#drag;
359
+ const touch = this.#touchFeedback;
360
+ const pointerId = drag?.pointerId ?? touch?.pointerId;
361
+ if ((drag && (!allowClick || drag.moved)) || (touch && !allowClick)) {
362
+ this.#suppressClick = true;
363
+ this.#clickRegion = null;
364
+ }
365
+ this.#drag = null;
366
+ this.#touchFeedback = null;
367
+ if (touch) {
368
+ this.#pointer = null;
369
+ this.#setHover(null);
370
+ }
371
+ delete this.#root.dataset.dragging;
372
+ if (pointerId !== undefined && this.#svg.hasPointerCapture?.(pointerId)) {
373
+ this.#svg.releasePointerCapture(pointerId);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Creates the 26 signed-axis presets and their sharp-cube interaction surfaces.
379
+ * Edges share two prism surfaces; corners share three surfaces. Face outlines form the shell.
380
+ * @public
381
+ * @static
382
+ * @returns {ViewCubeRegion[]} Six face, twelve edge and eight corner regions with normalized directions.
383
+ */
384
+ static createViewCubeRegions() {
385
+ const regions = [];
386
+ const inset = 0.78;
387
+ for (let i = 0; i < 3; i++) {
388
+ for (const s of [-1, 1]) {
389
+ const axis = [0, 0, 0];
390
+ axis[i] = s;
391
+ BabylonJSViewCubeSVGController.#addRegion(regions, axis, [
392
+ BabylonJSViewCubeSVGController.#createSurface(i, s, [
393
+ [-inset, inset],
394
+ [-inset, inset],
395
+ [-inset, inset],
396
+ ]),
397
+ ]);
398
+ regions.at(-1).outline = BabylonJSViewCubeSVGController.#createSurface(i, s, [
399
+ [-1, 1],
400
+ [-1, 1],
401
+ [-1, 1],
402
+ ]);
403
+ }
404
+ }
405
+ for (let i = 0; i < 3; i++) {
406
+ for (let j = i + 1; j < 3; j++) {
407
+ for (const s of [-1, 1]) {
408
+ for (const t of [-1, 1]) {
409
+ const axis = [0, 0, 0];
410
+ axis[i] = s;
411
+ axis[j] = t;
412
+ const ranges = [
413
+ [-inset, inset],
414
+ [-inset, inset],
415
+ [-inset, inset],
416
+ ];
417
+ ranges[i] = [s * inset, s];
418
+ ranges[j] = [t * inset, t];
419
+ BabylonJSViewCubeSVGController.#addRegion(regions, axis, [BabylonJSViewCubeSVGController.#createSurface(i, s, ranges), BabylonJSViewCubeSVGController.#createSurface(j, t, ranges)]);
420
+ }
421
+ }
422
+ }
423
+ }
424
+ for (const x of [-1, 1]) {
425
+ for (const y of [-1, 1]) {
426
+ for (const z of [-1, 1]) {
427
+ const signs = [x, y, z];
428
+ const ranges = signs.map((s) => [s * inset, s]);
429
+ BabylonJSViewCubeSVGController.#addRegion(regions,
430
+ signs,
431
+ signs.map((s, i) => BabylonJSViewCubeSVGController.#createSurface(i, s, ranges)),
432
+ );
433
+ }
434
+ }
435
+ }
436
+ return regions;
437
+ }
438
+
439
+ /**
440
+ * Creates a signed-axis quad with its normal and four cube-space vertices.
441
+ * @private
442
+ * @static
443
+ * @param {number} axis - Fixed coordinate axis index.
444
+ * @param {number} sign - Fixed axis direction, -1 or 1.
445
+ * @param {Array<number[]>} ranges - Lower and upper coordinates for each axis.
446
+ * @returns {{normal:Vector3,vertices:Vector3[]}}
447
+ */
448
+ static #createSurface(axis, sign, ranges) {
449
+ const other = [0, 1, 2].filter((i) => i !== axis);
450
+ const normalComponents = [0, 0, 0];
451
+ normalComponents[axis] = sign;
452
+ const normal = new Vector3(...normalComponents);
453
+ const vertices = [
454
+ [0, 0],
455
+ [1, 0],
456
+ [1, 1],
457
+ [0, 1],
458
+ ].map(([u, v]) => {
459
+ const p = [0, 0, 0];
460
+ p[axis] = sign;
461
+ p[other[0]] = ranges[other[0]][u];
462
+ p[other[1]] = ranges[other[1]][v];
463
+ return new Vector3(...p);
464
+ });
465
+ return { normal, vertices };
466
+ }
467
+
468
+ /**
469
+ * Adds a face, edge or corner preset from its signed-axis vector and clickable surfaces.
470
+ * @private
471
+ * @static
472
+ * @param {ViewCubeRegion[]} regions - Destination region collection.
473
+ * @param {number[]} axis - Signed direction components.
474
+ * @param {Array<{normal:Vector3,vertices:Vector3[]}>} surfaces - Surfaces sharing that direction.
475
+ * @returns {void}
476
+ */
477
+ static #addRegion(regions, axis, surfaces) {
478
+ const direction = new Vector3(...axis).normalize();
479
+ regions.push({ id: axis.join(","), direction, surfaces, kind: ["", "face", "edge", "corner"][axis.filter(Boolean).length] });
480
+ }
481
+
482
+ /**
483
+ * Finds the semantic cube-region id under the event target.
484
+ * @private
485
+ * @static
486
+ * @param {PointerEvent|MouseEvent} event - Overlay input event.
487
+ * @returns {string|null}
488
+ */
489
+ static #getRegionAt(event) {
490
+ return event.target?.closest?.("polygon[data-region]")?.dataset.region ?? null;
491
+ }
492
+
493
+ /**
494
+ * Interpolates an orthographic extent from its start or fallback using the current eased transition.
495
+ * @private
496
+ * @static
497
+ * @param {number|null} start - Initial extent.
498
+ * @param {number} end - Destination extent.
499
+ * @param {number} fallback - Initial extent when absent.
500
+ * @param {number} eased - Eased transition fraction from 0 to 1.
501
+ * @returns {number}
502
+ */
503
+ static #interpolateExtent(start, end, fallback, eased) {
504
+ return (start ?? fallback) + (end - (start ?? fallback)) * eased;
505
+ }
506
+
507
+ /**
508
+ * Projects a cube-space vertex into the overlay SVG using the effective camera projection.
509
+ * @private
510
+ * @static
511
+ * @param {Vector3} vertex - Cube-space vertex.
512
+ * @param {ViewCubeProjection} projection - Current virtual cube projection.
513
+ * @returns {{x:number,y:number}}
514
+ */
515
+ static #projectVertex(vertex, projection) {
516
+ const { view, perspective, depthSign, eyeDistance } = projection;
517
+ const point = Vector3.TransformNormal(vertex, view);
518
+ const scale = perspective ? eyeDistance / (eyeDistance + point.z * depthSign) : 1;
519
+ return { x: 60 + point.x * 31 * scale, y: 60 - point.y * 31 * scale };
520
+ }
521
+
522
+ /**
523
+ * Updates surface visibility and projected polygon points for the current camera orientation.
524
+ * @private
525
+ * @static
526
+ * @param {object} surface - Surface containing its normal, vertices and SVG polygon.
527
+ * @param {ViewCubeProjection} projection - Current virtual cube projection.
528
+ * @returns {{shown:boolean,points:Array<{x:number,y:number}>}}
529
+ */
530
+ static #drawSurface(surface, projection) {
531
+ const { view, perspective, depthSign, eyeDistance } = projection;
532
+ const normal = Vector3.TransformNormal(surface.normal, view);
533
+ const shown = perspective ? -normal.z * depthSign * eyeDistance > 1.001 : normal.z * depthSign < -0.001;
534
+ surface.polygon.style.display = shown ? "" : "none";
535
+ const points = surface.vertices.map((vertex) => BabylonJSViewCubeSVGController.#projectVertex(vertex, projection));
536
+ surface.polygon.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" "));
537
+ return { shown, points };
538
+ }
539
+
540
+ /**
541
+ * Converts a quaternion into a new rotation matrix without changing the quaternion.
542
+ * @private
543
+ * @param {Quaternion} q - Camera orientation.
544
+ * @returns {Matrix} Rotation matrix.
545
+ */
546
+ #rotationMatrix(q) {
547
+ const matrix = Matrix.Identity();
548
+ q.toRotationMatrix(matrix);
549
+ return matrix;
550
+ }
551
+
552
+ /**
553
+ * Identifies the ArcRotateCamera adapter by its Babylon class name.
554
+ * @private
555
+ * @param {ViewCubeCamera|null|undefined} camera - Camera to inspect.
556
+ * @returns {boolean} Whether the camera uses orbital controls.
557
+ */
558
+ #isOrbit(camera) {
559
+ return camera?.getClassName?.() === "ArcRotateCamera";
560
+ }
561
+
562
+ /**
563
+ * Reads the effective world-space camera pose and current orthographic extents.
564
+ * Transforms orbit targets through the parent; other cameras use the remembered distance or one unit.
565
+ * @private
566
+ * @param {ViewCubeCamera} camera - Camera whose view matrix will be refreshed.
567
+ * @returns {ViewCubePose} Snapshot used for synchronization and transition interpolation.
568
+ */
569
+ #readPose(camera) {
570
+ const world = camera.getViewMatrix(true).clone().invert();
571
+ const position = Vector3.Zero();
572
+ const rotation = Quaternion.Identity();
573
+ world.decompose(undefined, rotation, position);
574
+ const orbitTarget = this.#isOrbit(camera) ? camera.getTarget().clone() : null;
575
+ const target = orbitTarget ? (camera.parent ? Vector3.TransformCoordinates(orbitTarget, camera.parent.getWorldMatrix()) : orbitTarget) : position.add(camera.getForwardRay().direction.scale(this.#cameraDistances.get(camera) ?? 1));
576
+ return { position, rotation, target, distance: Math.max(Vector3.Distance(position, target), 0.0001), left: camera.orthoLeft, right: camera.orthoRight, bottom: camera.orthoBottom, top: camera.orthoTop };
577
+ }
578
+
579
+ /**
580
+ * Clears orbital, translational and rotational inertia plus pending surface-focused wheel motion before explicit cube navigation.
581
+ * @private
582
+ * @param {ViewCubeCamera} camera - Camera whose pending movement is cleared.
583
+ * @returns {void}
584
+ */
585
+ #clearInertia(camera) {
586
+ BabylonJSCameraWheelMotion.cancel(camera);
587
+ for (const key of ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"]) {
588
+ if (key in camera) {
589
+ camera[key] = 0;
590
+ }
591
+ }
592
+ camera.cameraDirection?.setAll(0);
593
+ camera.cameraRotation?.setAll(0);
594
+ }
595
+
596
+ /**
597
+ * Applies a world-space position and target while retaining world +Y as camera up.
598
+ * Converts parented poses to local coordinates, clears inertia and adjusts clipping/navigation limits.
599
+ * Reads ViewCube radius factors from BabylonJSController.config.camera.
600
+ * Free/Universal cameras use target/Euler rotation to prevent quaternion-driven up-vector changes.
601
+ * The transition caller checks camera identity and lock before invoking this method.
602
+ * @private
603
+ * @param {ViewCubeCamera} camera - Camera receiving the accepted navigation pose.
604
+ * @param {Vector3} position - Interpolated world-space position.
605
+ * @param {Vector3} target - Interpolated world-space model target.
606
+ * @param {number} distance - Interpolated camera-to-target distance.
607
+ * @param {ViewCubeFit} fit - Destination bounds/framing used to adjust clipping.
608
+ * @returns {void}
609
+ */
610
+ #writePose(camera, position, target, distance, fit) {
611
+ const parentWorld = camera.parent?.getWorldMatrix?.();
612
+ const parentInverse = parentWorld?.clone().invert();
613
+ const localPosition = parentInverse ? Vector3.TransformCoordinates(position, parentInverse) : position;
614
+ const upWorld = Vector3.Up();
615
+ camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
616
+ this.#clearInertia(camera);
617
+ if (this.#isOrbit(camera)) {
618
+ // Cube selection explicitly overrides incompatible orbit constraints on this camera only.
619
+ camera.lowerAlphaLimit = null;
620
+ camera.upperAlphaLimit = null;
621
+ camera.lowerBetaLimit = null;
622
+ camera.upperBetaLimit = null;
623
+ camera.allowUpsideDown = false;
624
+ camera.lowerRadiusLimit = Math.min(distance * BabylonJSController.config.camera.minRadiusFactor, camera.lowerRadiusLimit ?? Infinity);
625
+ camera.upperRadiusLimit = Math.max(distance * BabylonJSController.config.camera.maxRadiusFactor, camera.upperRadiusLimit ?? 0);
626
+ camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
627
+ camera.setPosition(localPosition);
628
+ } else {
629
+ camera.position.copyFrom(localPosition);
630
+ // Quaternion changes make TargetCamera rotate its upVector. Its target/Euler API keeps roll zero.
631
+ camera.rotationQuaternion = null;
632
+ camera.updateUpVectorFromRotation = false;
633
+ camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
634
+ // Flush Babylon's initial roll cache before restoring the fixed world-up vector.
635
+ camera.getViewMatrix(true);
636
+ camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
637
+ this.#cameraDistances.set(camera, distance);
638
+ }
639
+ camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
640
+ camera.maxZ = Math.max(camera.maxZ, distance + fit.radius * 2);
641
+ }
642
+
643
+ /**
644
+ * Publishes measured cube viewport height and scene height when either changes.
645
+ * Disabled cubes report zero height; resolved CSS height is used when the SVG has no layout measurement.
646
+ * @private
647
+ * @returns {void}
648
+ */
649
+ #publishLayout() {
650
+ if (this.#disposed) {
651
+ return;
652
+ }
653
+ const viewportHeight = this.#svg.getBoundingClientRect().height
654
+ || parseFloat(this.#root.ownerDocument.defaultView?.getComputedStyle(this.#root).height) || 0;
655
+ const cubeHeight = this.#enabled ? viewportHeight : 0;
656
+ const sceneHeight = this.#host.getViewport().height;
657
+ const key = `${cubeHeight},${sceneHeight}`;
658
+ if (key === this.#layoutKey) {
659
+ return;
660
+ }
661
+ this.#layoutKey = key;
662
+ this.#host.onLayout?.(cubeHeight, sceneHeight);
663
+ }
664
+
665
+ /**
666
+ * Registers an overlay or canvas listener and retains its removal callback for disposal.
667
+ * @private
668
+ * @param {EventTarget} element - Event source.
669
+ * @param {string} name - DOM event name.
670
+ * @param {EventListener} handler - Handler to register.
671
+ * @returns {void}
672
+ */
673
+ #addListener(element, name, handler) {
674
+ element.addEventListener(name, handler);
675
+ this.#listeners.push(() => element.removeEventListener(name, handler));
676
+ }
677
+
678
+ /**
679
+ * Updates hover feedback for every surface and the outline of one region.
680
+ * @private
681
+ * @param {string|null} key - Cube region id whose hover feedback is updated.
682
+ * @param {boolean} active - Whether hover feedback is active.
683
+ * @returns {void}
684
+ */
685
+ #setRegionHover(key, active) {
686
+ const node = this.#nodes.get(key);
687
+ if (!node) {
688
+ return;
689
+ }
690
+ for (const surface of node.surfaces) {
691
+ surface.polygon.toggleAttribute("data-hover", active);
692
+ }
693
+ node.outline.toggleAttribute("data-hover", active);
694
+ }
695
+
696
+ /**
697
+ * Updates only the old/new region feedback attributes without rebuilding geometry.
698
+ * Clears hover when disabled or locked; all surfaces of an edge or corner share the same state.
699
+ * @private
700
+ * @param {string|null} id - Signed-axis region identifier, or null to remove hover.
701
+ * @returns {void}
702
+ */
703
+ #setHover(id) {
704
+ if (!this.#enabled || this.#host.isLocked(this.#host.getCamera())) {
705
+ id = null;
706
+ }
707
+ if (id === this.#hover) {
708
+ return;
709
+ }
710
+
711
+ this.#setRegionHover(this.#hover, false);
712
+ this.#hover = id;
713
+ this.#setRegionHover(id, true);
714
+ }
715
+
716
+ /**
717
+ * Reports whether an accepted navigation transition is pending.
718
+ * @public
719
+ * @returns {boolean} Whether the owning renderer should continue advancing the transition.
720
+ */
721
+ get isAnimating() {
722
+ return this.#transition !== null;
723
+ }
724
+
725
+ /**
726
+ * Returns the overlay root for host integration or inspection.
727
+ * @public
728
+ * @returns {HTMLDivElement} Root element containing styles and the SVG.
729
+ */
730
+ get element() {
731
+ return this.#root;
732
+ }
733
+
734
+ /**
735
+ * Changes cube visibility without reloading assets or replacing the camera.
736
+ * Disabling cancels navigation and clears hover; either state refreshes dimensions and synchronization.
737
+ * @public
738
+ * @param {boolean} enabled - Whether the cube should be visible and interactive when unlocked.
739
+ * @returns {void}
740
+ */
741
+ setEnabled(enabled) {
742
+ if (this.#disposed) {
743
+ return;
744
+ }
745
+ this.#enabled = Boolean(enabled);
746
+ this.#root.hidden = !this.#enabled;
747
+ if (!this.#enabled) {
748
+ this.cancel();
749
+ this.#pointer = null;
750
+ this.#setHover(null);
751
+ }
752
+ this.resize();
753
+ this.sync();
754
+ }
755
+
756
+ /**
757
+ * Constrains the CSS-sized square widget to the viewport without overriding its stylesheet dimensions.
758
+ * CSS owns the requested size and density defaults; only the shared maximum dimension is written inline.
759
+ * Cancels obsolete navigation and synchronizes the indicator without changing the camera pose.
760
+ * @public
761
+ * @returns {void}
762
+ */
763
+ resize() {
764
+ if (this.#disposed) {
765
+ return;
766
+ }
767
+ this.cancel();
768
+ const viewport = this.#host.getViewport();
769
+ const size = Math.max(0, Math.min(viewport.width, viewport.height));
770
+ this.#root.style.maxWidth = `${size}px`;
771
+ this.#root.style.maxHeight = `${size}px`;
772
+ this.#publishLayout();
773
+ this.sync();
774
+ }
775
+
776
+ /**
777
+ * Stops the pending transition, captured drag and temporary touch feedback without reverting camera changes.
778
+ * @public
779
+ * @returns {void}
780
+ */
781
+ cancel() {
782
+ this.#transition = null;
783
+ this.#finishDrag();
784
+ }
785
+
786
+ /**
787
+ * Ends any captured drag and starts or retargets smooth navigation using fresh model-only framing.
788
+ * Rejects disabled/disposed, locked, unsupported or unusable inputs without starting navigation.
789
+ * An accepted action clears inertia and asks the owning renderer to advance the transition.
790
+ * @public
791
+ * @param {string} id - Signed-axis preset identifier, for example "0,0,1" for Interior.
792
+ * @param {number} [now=performance.now()] - Start timestamp in milliseconds on the performance clock.
793
+ * @returns {boolean} Whether navigation was accepted.
794
+ */
795
+ select(id, now = performance.now()) {
796
+ const camera = this.#host.getCamera();
797
+ const region = this.#regions.find((r) => r.id === id);
798
+ if (this.#disposed || !this.#enabled || !region || this.#host.isLocked(camera) || !BabylonJSCameraFraming.isCameraSupported(camera)) {
799
+ return false;
800
+ }
801
+ const fit = BabylonJSCameraFraming.calculateCameraFit(camera, this.#host.getBounds(), region.direction, this.#host.getViewport());
802
+ if (!fit) {
803
+ return false;
804
+ }
805
+ this.#finishDrag();
806
+ this.#clearInertia(camera);
807
+ this.#transition = { camera, mode: camera.mode, start: this.#readPose(camera), fit, at: now };
808
+ this.#host.requestRender();
809
+ return true;
810
+ }
811
+
812
+ /**
813
+ * Advances an accepted transition with smooth easing and preserved projection mode.
814
+ * Uses BabylonJSController.config.camera.transitionDuration as the duration in milliseconds.
815
+ * Rechecks active-camera identity, lock and projection before each write; invalidated transitions stop.
816
+ * The owning render loop calls this method; it does not start an independent animation loop.
817
+ * @public
818
+ * @param {number} [now=performance.now()] - Current timestamp in milliseconds on the performance clock.
819
+ * @returns {void}
820
+ */
821
+ tick(now = performance.now()) {
822
+ const t = this.#transition;
823
+ if (!t) {
824
+ return;
825
+ }
826
+ if (this.#host.getCamera() !== t.camera || this.#host.isLocked(t.camera) || t.camera.mode !== t.mode) {
827
+ this.cancel();
828
+ return;
829
+ }
830
+ const p = Math.max(0, Math.min(1, (now - t.at) / BabylonJSController.config.camera.transitionDuration));
831
+ const eased = p * p * (3 - 2 * p);
832
+ const rotation = Quaternion.Slerp(t.start.rotation, t.fit.rotation, eased);
833
+ const target = Vector3.Lerp(t.start.target, t.fit.target, eased);
834
+ const distance = t.start.distance + (t.fit.distance - t.start.distance) * eased;
835
+ const forward = Vector3.TransformNormal(new Vector3(0, 0, t.camera.getScene().useRightHandedSystem ? -1 : 1), this.#rotationMatrix(rotation));
836
+ const position = target.subtract(forward.scale(distance));
837
+ this.#writePose(t.camera, position, target, distance, t.fit);
838
+ if (t.mode === 1) {
839
+
840
+ const viewport = this.#host.getViewport();
841
+ t.camera.orthoLeft = BabylonJSViewCubeSVGController.#interpolateExtent(t.start.left, -t.fit.halfWidth, -viewport.width / 2, eased);
842
+ t.camera.orthoRight = BabylonJSViewCubeSVGController.#interpolateExtent(t.start.right, t.fit.halfWidth, viewport.width / 2, eased);
843
+ t.camera.orthoBottom = BabylonJSViewCubeSVGController.#interpolateExtent(t.start.bottom, -t.fit.halfHeight, -viewport.height / 2, eased);
844
+ t.camera.orthoTop = BabylonJSViewCubeSVGController.#interpolateExtent(t.start.top, t.fit.halfHeight, viewport.height / 2, eased);
845
+ }
846
+ if (p === 1) {
847
+ this.cancel();
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Synchronizes projection, nearest-orientation feedback, labels and lock state from the active camera.
853
+ * Rebinds the view-matrix observer on replacement and guards against recursive notifications.
854
+ * Releases a pending drag/touch highlight when camera identity, projection or full-lock state changes.
855
+ * Reprojects SVG geometry only for camera pose/projection changes; hover and localization reuse it.
856
+ * Keeps top-layer label visibility aligned with its face instead of inheriting an interaction group's visibility.
857
+ * The virtual cube camera follows orientation/FOV independently of the model zoom.
858
+ * @public
859
+ * @returns {void}
860
+ */
861
+ sync() {
862
+ if (this.#disposed || !this.#enabled || this.#syncing) {
863
+ return;
864
+ }
865
+ this.#syncing = true;
866
+ try {
867
+ const camera = this.#host.getCamera();
868
+ if (camera !== this.#camera) {
869
+ this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
870
+ this.#camera = camera;
871
+ this.#geometryKey = null;
872
+ this.#observer = camera?.onViewMatrixChangedObservable?.add(() => this.sync());
873
+ }
874
+ const locked = !BabylonJSCameraFraming.isCameraSupported(camera) || this.#host.isLocked(camera);
875
+ const gesture = this.#drag ?? this.#touchFeedback;
876
+ if (gesture && (gesture.camera !== camera || gesture.mode !== camera?.mode || locked)) {
877
+ this.#finishDrag();
878
+ }
879
+ const lockChanged = locked !== this.#locked;
880
+ this.#locked = locked;
881
+ if (lockChanged) {
882
+ this.#root.dataset.locked = String(locked);
883
+ }
884
+ if (locked) {
885
+ this.#setHover(null);
886
+ }
887
+ if (!camera?.getViewMatrix) {
888
+ this.#svg.style.visibility = "hidden";
889
+ return;
890
+ }
891
+ if (this.#svg.style.visibility !== "visible") {
892
+ this.#svg.style.visibility = "visible";
893
+ }
894
+ const locale = this.#host.getCulture?.();
895
+ const localeChanged = locale !== this.#locale || this.#geometryKey === null;
896
+ const geometryKey = `${camera.getViewMatrix().m.join(",")}|${camera.mode}|${camera.fov}|${camera.getScene().useRightHandedSystem}`;
897
+ const geometryChanged = geometryKey !== this.#geometryKey;
898
+ if (!geometryChanged && !localeChanged && !lockChanged) {
899
+ return;
900
+ }
901
+ this.#geometryKey = geometryKey;
902
+ this.#locale = locale;
903
+ const pose = this.#readPose(camera);
904
+ const view = this.#rotationMatrix(pose.rotation).invert();
905
+ const rightHanded = camera.getScene().useRightHandedSystem;
906
+ const depthSign = rightHanded ? -1 : 1;
907
+ const perspective = camera.mode !== 1;
908
+ const fov = Number.isFinite(camera.fov) && camera.fov > 0 && camera.fov < Math.PI ? camera.fov : 0.8;
909
+ // An isolated virtual eye follows the scene camera's orientation/FOV, not its model zoom.
910
+ const eyeDistance = 1.8 + 1.8 / Math.tan(fov / 2);
911
+ const projection = { view, perspective, depthSign, eyeDistance };
912
+ if (geometryChanged) {
913
+ for (const surface of this.#shell) {
914
+ BabylonJSViewCubeSVGController.#drawSurface(surface, projection);
915
+ }
916
+ }
917
+ const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, -depthSign), this.#rotationMatrix(pose.rotation)).normalize();
918
+ const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
919
+ if (geometryChanged) {
920
+ this.#root.dataset.projection = perspective ? "perspective" : "orthographic";
921
+ this.#root.dataset.currentRegion = nearest.id;
922
+ }
923
+ // Draw full faces first, then the coplanar edge prisms and corner cubes.
924
+ // Visibility belongs to each physical face, not the semantic preset direction.
925
+ if (localeChanged) {
926
+ this.#svg.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
927
+ }
928
+ for (const r of this.#regions) {
929
+ const node = this.#nodes.get(r.id);
930
+ const parts = r.id.split(",").map(Number);
931
+ const names = parts
932
+ .map((s, i) =>
933
+ s
934
+ ? I18n.translate(
935
+ `prefViewer.viewCube.${
936
+ [
937
+ ["right", "left"],
938
+ ["bottom", "top"],
939
+ ["back", "front"],
940
+ ][i][s > 0 ? 1 : 0]
941
+ }`,
942
+ { locale },
943
+ )
944
+ : null,
945
+ )
946
+ .filter(Boolean);
947
+ let visible = false;
948
+ const outlineSegments = [];
949
+ for (const surface of node.surfaces) {
950
+ const { shown, points } = geometryChanged ? BabylonJSViewCubeSVGController.#drawSurface(surface, projection) : { shown: surface.polygon.style.display !== "none", points: [] };
951
+ visible ||= shown;
952
+ if (lockChanged || geometryChanged) {
953
+ surface.polygon.style.pointerEvents = locked ? "none" : "visiblePainted";
954
+ }
955
+ if (geometryChanged) {
956
+ surface.polygon.toggleAttribute("data-current", r.id === nearest.id);
957
+ }
958
+ if (localeChanged) {
959
+ surface.polygon.setAttribute("aria-label", names.join(" / "));
960
+ }
961
+ if (geometryChanged && shown) {
962
+ outlineSegments.push(`M${points.map((p) => `${p.x},${p.y}`).join(" L")} Z`);
963
+ }
964
+ if (geometryChanged && r.kind === "face") {
965
+ node.label.setAttribute("x", String(points.reduce((sum, v) => sum + v.x, 0) / points.length));
966
+ node.label.setAttribute("y", String(points.reduce((sum, v) => sum + v.y, 0) / points.length));
967
+ }
968
+ }
969
+ if (geometryChanged) {
970
+ node.group.style.display = visible ? "" : "none";
971
+ if (r.kind === "face") {
972
+ node.label.style.display = visible ? "" : "none";
973
+ }
974
+ node.outline.setAttribute("d", outlineSegments.join(" "));
975
+ }
976
+ if (localeChanged) {
977
+ node.label.textContent = r.kind === "face" ? names[0] : "";
978
+ }
979
+ }
980
+ // Camera motion can move hit regions under a stationary mouse.
981
+ if (geometryChanged && this.#pointer && Number.isFinite(this.#pointer.x + this.#pointer.y)) {
982
+ const hit = this.#root.getRootNode().elementFromPoint?.(this.#pointer.x, this.#pointer.y);
983
+ if (hit) {
984
+ this.#setHover(this.#root.contains(hit) ? (hit.closest?.("polygon[data-region]")?.dataset.region ?? null) : null);
985
+ }
986
+ }
987
+ } finally {
988
+ this.#syncing = false;
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Idempotently cancels navigation and releases listeners, camera/resize observers and overlay DOM.
994
+ * Reports zero layout footprint without disposing the host camera, scene or model assets.
995
+ * @public
996
+ * @returns {void}
997
+ */
998
+ dispose() {
999
+ if (this.#disposed) {
1000
+ return;
1001
+ }
1002
+ this.cancel();
1003
+ this.#disposed = true;
1004
+ this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
1005
+ this.#resizeObserver?.disconnect();
1006
+ this.#viewportResizeObserver?.disconnect();
1007
+ this.#listeners.forEach((remove) => remove());
1008
+ this.#listeners.length = 0;
1009
+ this.#root.remove();
1010
+ this.#host.onLayout?.(0, 0);
1011
+ }
1012
+ }