@preference-sl/pref-viewer 2.18.0 → 3.0.0-beta.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 (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 +507 -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 +7 -4
  22. package/src/gltf-resolver.js +13 -8
  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 +280 -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,1287 @@
1
+ import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
2
+ import { PrefViewerStyleVariables, PrefViewerViewCubePalette, PrefViewerViewCubeTypography, PrefViewerViewCubeSizingStyles, PrefViewerViewCubeCursorStyles } from "./styles.js";
3
+ import I18n from "./localization/i18n.js";
4
+ import BabylonJSController from "./babylonjs-controller.js";
5
+ import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
6
+ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
7
+ import BabylonJSViewCubeCanvasRenderer from "./babylonjs-view-cube-canvas-renderer.js";
8
+ import BabylonJSViewCubeCanvasOrbitMath from "./babylonjs-view-cube-canvas-orbit-math.js";
9
+
10
+ /**
11
+ * @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
12
+ */
13
+
14
+ /**
15
+ * @typedef {import("./babylonjs-camera-framing.js").CameraFramingBounds} ViewCubeBounds
16
+ */
17
+
18
+ /**
19
+ * One planar surface of a sharp-cube interaction region.
20
+ * @typedef {Object} ViewCubeSurface
21
+ * @property {Vector3} normal - Outward cube-local normal.
22
+ * @property {Vector3[]} vertices - Four cube-local polygon vertices.
23
+ */
24
+
25
+ /**
26
+ * Face, edge or corner orientation preset and its clickable geometry.
27
+ * @typedef {Object} ViewCubeRegion
28
+ * @property {string} id - Comma-separated signed axes identifying the preset.
29
+ * @property {Vector3} direction - Normalized direction from target toward observer.
30
+ * @property {"face"|"edge"|"corner"} kind - Interaction region category.
31
+ * @property {ViewCubeSurface[]} surfaces - One, two or three clickable planar surfaces.
32
+ * @property {ViewCubeSurface} [outline] - Full shell face, supplied only for face presets.
33
+ */
34
+
35
+ /**
36
+ * @typedef {import("./babylonjs-camera-framing.js").CameraFramingFit} ViewCubeFit
37
+ */
38
+
39
+ /**
40
+ * World-space pose snapshot for transition interpolation.
41
+ * @typedef {Object} ViewCubePose
42
+ * @property {Vector3} position - World-space camera position.
43
+ * @property {Quaternion} rotation - World-space camera orientation.
44
+ * @property {Vector3} target - Effective world-space target.
45
+ * @property {number} distance - Positive camera-to-target world distance.
46
+ * @property {number|null} left - Current orthographic left extent.
47
+ * @property {number|null} right - Current orthographic right extent.
48
+ * @property {number|null} bottom - Current orthographic bottom extent.
49
+ * @property {number|null} top - Current orthographic top extent.
50
+ */
51
+
52
+ /**
53
+ * Options handed to the renderer factory.
54
+ * @typedef {Object} ViewCubeRendererOptions
55
+ * @property {HTMLCanvasElement} canvas - Cube canvas inside the overlay root.
56
+ * @property {Object<string, string>} labels - Translated face labels keyed right, left, bottom, top, back, front.
57
+ * @property {object} colors - Shared resolved hex palette and numeric hover/current opacities.
58
+ * @property {number} devicePixelRatio - Pixel ratio of the owning window.
59
+ * @property {function(): void} [onReady] - Requests a repaint after initial shader/texture readiness.
60
+ * @property {function(): void} onContextRestored - Called when the renderer's own WebGL context was restored; the controller then repaints.
61
+ */
62
+
63
+ /**
64
+ * BabylonJSViewCubeCanvasController - Owns a 3D orientation indicator and model-framing camera navigation.
65
+ *
66
+ * Displays 26 face/edge/corner presets with the shared SVG palette, primary-color hover and nearest-view feedback.
67
+ * Owns shared palette, typography and cursor stylesheets inside its overlay root; disposal releases them with the root.
68
+ * The visual layer is a BabylonJSViewCubeCanvasRenderer drawing on a canvas inside the overlay root; this class
69
+ * keeps the interaction (hover, click), the camera pose reading and the camera transitions.
70
+ * Touch hover lasts only during a stationary press; dragging and release retain only nearest-view feedback.
71
+ * Synchronizes camera orientation while locked, unsupported and XR cameras remain display-only.
72
+ * Accepted clicks preserve projection, fit only model bounds and retain world +Y as camera up.
73
+ *
74
+ * The host owns the camera, scene, assets and render loop. It calls tick() to advance transitions
75
+ * and sync() to refresh the indicator; sync() renders the cube, so the cube has no render loop of its own.
76
+ * The renderer owns a separate Engine and Scene, created lazily once a camera exists and disposed when the cube
77
+ * is disabled or disposed. dispose() releases the overlay, its listeners/observers and the renderer.
78
+ *
79
+ * Interaction thresholds, theme tokens and navigation constants are private static class members.
80
+ * Members are ordered as properties, constructor, static methods, private methods and public methods.
81
+ * @class
82
+ */
83
+ export default class BabylonJSViewCubeCanvasController {
84
+ // Horizontal component of the unit view direction below which the camera counts as being on a pole.
85
+ static #POLE_SNAP = 1e-6;
86
+ static #PRIMARY_COLOR_VARIABLE = "--pref-viewer-color-primary";
87
+ // The app can change the primary colour at runtime without notifying the cube, so it is polled; reading the computed
88
+ // style forces a style recalculation, hence at most one read per interval (creation, culture change, resize and
89
+ // setEnabled(true) always read).
90
+ static #COLOR_REFRESH_MS = 250;
91
+ // CSS-wide keywords and currentcolor resolve against the probe's parent, not to a colour of their own.
92
+ static #CSS_COLOR_KEYWORDS = /^(?:inherit|initial|unset|revert|revert-layer|currentcolor)$/i;
93
+ // Host-settable CSS custom property (set on any ancestor of the viewer; it inherits through the shadow boundary)
94
+ // that insets the cube from the right edge of its container. Unset, the cube stays pinned at right:0px.
95
+ static #INSET_RIGHT_VARIABLE = "--pref-viewer-view-cube-inset-right";
96
+ // Pointer travel, in CSS pixels, beyond which a press on the cube stops being a click and becomes a drag.
97
+ static #DRAG_THRESHOLD_PX = 3;
98
+ // Orbit angle, in radians, applied per CSS pixel of drag; the PrefSketch view cube uses the same value.
99
+ static #DRAG_RADIANS_PER_PX = 0.012;
100
+ static #INERTIA_KEYS = ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"];
101
+
102
+ #cameraDistances = new WeakMap();
103
+ #host;
104
+ #root;
105
+ #canvas;
106
+ #renderer = null;
107
+ #regions = BabylonJSViewCubeCanvasController.createViewCubeRegions();
108
+ // View matrix, projection mode and handedness the cube was last posed for; null forces a new pose.
109
+ #geometryKey = null;
110
+ #colorsReadAt = null;
111
+ #locale;
112
+ #locked;
113
+ #enabled = false;
114
+ #transition = null;
115
+ #camera = null;
116
+ #observer = null;
117
+ #syncing = false;
118
+ #disposed = false;
119
+ #hover = null;
120
+ #pointer = null;
121
+ #press = null;
122
+ #alpha = 0;
123
+ #dirty = false;
124
+ #paletteRaw = null;
125
+ #listeners = [];
126
+ #paletteStyleObserver;
127
+ #resizeObserver;
128
+ #viewportResizeObserver;
129
+ #resizeFrame = null;
130
+ #layoutKey = null;
131
+ #stopPixelRatioWatch = null;
132
+
133
+ /**
134
+ * Creates the overlay root, shared palette/typography/cursor stylesheet and canvas, attaches input/resize and inherited-style observers and enables the cube.
135
+ * Mouse hover follows the pointer; touch highlights the pressed region until drag, release or cancellation.
136
+ * The host retains ownership of the camera, scene, assets and render loop. Callbacks resolve
137
+ * current state so camera/model replacement does not leave the cube with stale references.
138
+ *
139
+ * @public
140
+ * @param {Object} host - Internal integration contract supplied by the owning viewer.
141
+ * @param {HTMLCanvasElement} host.canvas - Connected scene canvas; its parent receives the cube overlay.
142
+ * @param {function(): (import("@babylonjs/core").Camera|null|undefined)} host.getCamera - Returns the effective active camera, or no camera while unavailable.
143
+ * @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.
144
+ * @param {function(): (ViewCubeBounds|null)} host.getBounds - Returns current model-only world bounds and their eight corners; null when geometry is unusable. Excludes the environment.
145
+ * @param {function(): {width: number, height: number}} host.getViewport - Returns the current scene viewport dimensions in CSS pixels.
146
+ * @param {function(Object=): void} host.requestRender - Requests rendering through the host scheduler after accepted navigation; `{ interaction: true }` marks a pointer drag step, for which the host keeps rendering continuously as it does for its own canvas drags.
147
+ * @param {function(): string} [host.getCulture] - Returns the current localization culture; omitted callbacks use the translator's default culture.
148
+ * @param {function(number, number): void} [host.onLayout] - Receives the measured cube viewport height and scene viewport height, in CSS pixels; zero cube height clears the menu offset, and disposal reports (0, 0).
149
+ * @param {function(ViewCubeRendererOptions): BabylonJSViewCubeCanvasRenderer} [host.createRenderer] - Renderer factory, mainly for tests; defaults to a BabylonJSViewCubeCanvasRenderer with its own Engine.
150
+ */
151
+ constructor(host) {
152
+ this.#host = host;
153
+ const doc = host.canvas.ownerDocument;
154
+ this.#root = doc.createElement("div");
155
+ this.#root.className = "pref-viewer-view-cube";
156
+ this.#root.dataset.renderer = "canvas";
157
+ this.#root.style.cssText = `position:absolute;right:var(${BabylonJSViewCubeCanvasController.#INSET_RIGHT_VARIABLE}, 0px);top:0px;z-index:900;pointer-events:none`;
158
+ this.#canvas = doc.createElement("canvas");
159
+ this.#canvas.setAttribute("role", "img");
160
+ // The canvas is the only pointer-active part; the layout wrapper never intercepts scene input.
161
+ // The renderer keeps Babylon's input manager detached from this canvas, so nothing else focuses it or moves the
162
+ // cursor. The ring, selection and tap highlight are still suppressed as a harmless extra guard: the cube is pointer-driven.
163
+ this.#canvas.style.cssText = "display:block;width:100%;height:100%;touch-action:none;pointer-events:auto;outline:none;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none";
164
+ const paletteStyle = doc.createElement("style");
165
+ paletteStyle.textContent = PrefViewerViewCubePalette.styles + PrefViewerViewCubeTypography.styles + PrefViewerViewCubeSizingStyles + PrefViewerViewCubeCursorStyles;
166
+ this.#root.append(paletteStyle, this.#canvas);
167
+ host.canvas.parentElement.append(this.#root);
168
+ if (doc.defaultView?.MutationObserver) {
169
+ this.#paletteStyleObserver = new doc.defaultView.MutationObserver(this.#onPaletteStyleChange);
170
+ let ancestor = this.#root.parentElement;
171
+ while (ancestor) {
172
+ this.#paletteStyleObserver.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] });
173
+ ancestor = ancestor.parentElement ?? ancestor.getRootNode()?.host;
174
+ }
175
+ }
176
+ this.#addListener(this.#canvas, "pointermove", (event) => {
177
+ const touch = event.pointerType === "touch";
178
+ this.#pointer = touch && !this.#press ? null : this.#toLocal(event);
179
+ if (this.#press) {
180
+ this.#dragTo(event);
181
+ return;
182
+ }
183
+ this.#setHover(touch ? null : this.#pick(this.#pointer));
184
+ });
185
+ this.#addListener(this.#canvas, "pointerleave", () => {
186
+ this.#pointer = null;
187
+ this.#endPress();
188
+ this.#setHover(null);
189
+ });
190
+ this.#addListener(this.#canvas, "pointerdown", (event) => {
191
+ if (!this.#isInteractive()) {
192
+ return;
193
+ }
194
+ // The whole canvas is a drag surface, transparent corners included: the main camera never starts from it.
195
+ event.stopPropagation();
196
+ if (event.button !== 0 || event.isPrimary === false || (this.#press && this.#press.pointerId !== event.pointerId)) {
197
+ return;
198
+ }
199
+ this.#endPress();
200
+ this.#pointer = this.#toLocal(event);
201
+ this.#press = { pointerType: event.pointerType, pointerId: event.pointerId, id: this.#pick(this.#pointer), startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, dragging: false };
202
+ this.#setHover(this.#press.id);
203
+ try {
204
+ this.#canvas.setPointerCapture?.(event.pointerId);
205
+ } catch (_error) {}
206
+ });
207
+ this.#addListener(this.#canvas, "pointerup", (event) => {
208
+ const press = this.#press;
209
+ if (!press || press.pointerId !== event.pointerId) {
210
+ return;
211
+ }
212
+ this.#endPress();
213
+ if (!press.dragging && press.id && event.button === 0 && this.#pick(this.#toLocal(event)) === press.id) {
214
+ this.select(press.id);
215
+ }
216
+ });
217
+ for (const name of ["pointercancel", "lostpointercapture"]) {
218
+ this.#addListener(this.#canvas, name, (event) => {
219
+ if (this.#press?.pointerId === event.pointerId) {
220
+ this.#endPress();
221
+ }
222
+ });
223
+ }
224
+ for (const event of ["pointerdown", "wheel", "keydown"]) {
225
+ this.#addListener(host.canvas, event, () => this.cancel());
226
+ }
227
+ this.#resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.requestResize());
228
+ this.#resizeObserver?.observe(host.canvas);
229
+ this.#viewportResizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.requestResize());
230
+ this.#viewportResizeObserver?.observe(this.#canvas);
231
+ this.#watchPixelRatio();
232
+ this.setEnabled(true);
233
+ }
234
+
235
+ /**
236
+ * Creates the 26 signed-axis presets and their sharp-cube interaction surfaces.
237
+ * Edges share two prism surfaces; corners share three surfaces. Face outlines form the shell.
238
+ * @public
239
+ * @static
240
+ * @returns {ViewCubeRegion[]} Six face, twelve edge and eight corner regions with normalized directions.
241
+ */
242
+ static createViewCubeRegions() {
243
+ const regions = [];
244
+ const inset = 0.78;
245
+ for (let i = 0; i < 3; i++) {
246
+ for (const s of [-1, 1]) {
247
+ const axis = [0, 0, 0];
248
+ axis[i] = s;
249
+ BabylonJSViewCubeCanvasController.#addRegion(regions, axis, [
250
+ BabylonJSViewCubeCanvasController.#createSurface(i, s, [
251
+ [-inset, inset],
252
+ [-inset, inset],
253
+ [-inset, inset],
254
+ ]),
255
+ ]);
256
+ regions.at(-1).outline = BabylonJSViewCubeCanvasController.#createSurface(i, s, [
257
+ [-1, 1],
258
+ [-1, 1],
259
+ [-1, 1],
260
+ ]);
261
+ }
262
+ }
263
+ for (let i = 0; i < 3; i++) {
264
+ for (let j = i + 1; j < 3; j++) {
265
+ for (const s of [-1, 1]) {
266
+ for (const t of [-1, 1]) {
267
+ const axis = [0, 0, 0];
268
+ axis[i] = s;
269
+ axis[j] = t;
270
+ const ranges = [
271
+ [-inset, inset],
272
+ [-inset, inset],
273
+ [-inset, inset],
274
+ ];
275
+ ranges[i] = [s * inset, s];
276
+ ranges[j] = [t * inset, t];
277
+ BabylonJSViewCubeCanvasController.#addRegion(regions, axis, [BabylonJSViewCubeCanvasController.#createSurface(i, s, ranges), BabylonJSViewCubeCanvasController.#createSurface(j, t, ranges)]);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ for (const x of [-1, 1]) {
283
+ for (const y of [-1, 1]) {
284
+ for (const z of [-1, 1]) {
285
+ const signs = [x, y, z];
286
+ const ranges = signs.map((s) => [s * inset, s]);
287
+ BabylonJSViewCubeCanvasController.#addRegion(regions,
288
+ signs,
289
+ signs.map((s, i) => BabylonJSViewCubeCanvasController.#createSurface(i, s, ranges)),
290
+ );
291
+ }
292
+ }
293
+ }
294
+ return regions;
295
+ }
296
+
297
+ /**
298
+ * Creates a signed-axis quad with its normal and four cube-space vertices.
299
+ * @private
300
+ * @static
301
+ * @param {number} axis - Fixed coordinate axis index.
302
+ * @param {number} sign - Fixed axis direction, -1 or 1.
303
+ * @param {Array<number[]>} ranges - Lower and upper coordinates for each axis.
304
+ * @returns {{normal:Vector3,vertices:Vector3[]}}
305
+ */
306
+ static #createSurface(axis, sign, ranges) {
307
+ const other = [0, 1, 2].filter((i) => i !== axis);
308
+ const normalComponents = [0, 0, 0];
309
+ normalComponents[axis] = sign;
310
+ const normal = new Vector3(...normalComponents);
311
+ const vertices = [
312
+ [0, 0],
313
+ [1, 0],
314
+ [1, 1],
315
+ [0, 1],
316
+ ].map(([u, v]) => {
317
+ const p = [0, 0, 0];
318
+ p[axis] = sign;
319
+ p[other[0]] = ranges[other[0]][u];
320
+ p[other[1]] = ranges[other[1]][v];
321
+ return new Vector3(...p);
322
+ });
323
+ return { normal, vertices };
324
+ }
325
+
326
+ /**
327
+ * Adds a face, edge or corner preset from its signed-axis vector and clickable surfaces.
328
+ * @private
329
+ * @static
330
+ * @param {ViewCubeRegion[]} regions - Destination region collection.
331
+ * @param {number[]} axis - Signed direction components.
332
+ * @param {Array<{normal:Vector3,vertices:Vector3[]}>} surfaces - Surfaces sharing that direction.
333
+ * @returns {void}
334
+ */
335
+ static #addRegion(regions, axis, surfaces) {
336
+ const direction = new Vector3(...axis).normalize();
337
+ regions.push({ id: axis.join(","), direction, surfaces, kind: ["", "face", "edge", "corner"][axis.filter(Boolean).length] });
338
+ }
339
+
340
+ /**
341
+ * Interpolates an orthographic extent from its start or fallback using the current eased transition.
342
+ * @private
343
+ * @static
344
+ * @param {number|null} start - Initial extent.
345
+ * @param {number} end - Destination extent.
346
+ * @param {number} fallback - Initial extent when absent.
347
+ * @param {number} eased - Eased transition fraction from 0 to 1.
348
+ * @returns {number}
349
+ */
350
+ static #interpolateExtent(start, end, fallback, eased) {
351
+ return (start ?? fallback) + (end - (start ?? fallback)) * eased;
352
+ }
353
+
354
+ /**
355
+ * Converts any CSS colour into lowercase "#rrggbb". The browser's own parser does the work when available: the value
356
+ * is assigned to a probe element's style (an invalid value leaves it empty) and the computed `color`, always
357
+ * "rgb()" or "rgba()", is converted. Environments without usable computed styles use a small pure-JS parser
358
+ * (hex 3/4/6/8 digits, rgb()/rgba() with numbers or percentages, hsl()/hsla()); named colours need the browser.
359
+ * Alpha is ignored and nothing is premultiplied: the colour is used as an opaque tint.
360
+ * @private
361
+ * @param {string} value - CSS colour text.
362
+ * @param {Element} [probeParent] - Element the probe is attached to while its computed colour is read.
363
+ * @returns {string|null} Hex colour, or null when the text is not a colour.
364
+ */
365
+ static #toHex(value, probeParent) {
366
+ const text = String(value ?? "").trim();
367
+ if (!text || BabylonJSViewCubeCanvasController.#CSS_COLOR_KEYWORDS.test(text)) {
368
+ return null;
369
+ }
370
+ return BabylonJSViewCubeCanvasController.#toHexWithBrowser(text, probeParent) ?? BabylonJSViewCubeCanvasController.#toHexWithoutBrowser(text);
371
+ }
372
+
373
+ /**
374
+ * Normalises a colour through the browser. Returns null when the browser is unavailable or cannot resolve it,
375
+ * so the caller can try the pure-JS parser.
376
+ * @private
377
+ * @static
378
+ * @param {string} text - Trimmed CSS colour text.
379
+ * @param {Element} [parent] - Element that hosts the probe while its computed colour is read.
380
+ * @returns {string|null}
381
+ */
382
+ static #toHexWithBrowser(text, parent) {
383
+ const view = parent?.ownerDocument?.defaultView;
384
+ if (!view?.getComputedStyle) {
385
+ return null;
386
+ }
387
+ const probe = parent.ownerDocument.createElement("span");
388
+ try {
389
+ probe.style.color = text;
390
+ if (!probe.style.color) {
391
+ return null;
392
+ }
393
+ parent.appendChild(probe);
394
+ return BabylonJSViewCubeCanvasController.#toHexWithoutBrowser(String(view.getComputedStyle(probe).color ?? "").trim());
395
+ } catch (_error) {
396
+ return null;
397
+ } finally {
398
+ probe.remove?.();
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Pure-JS colour parser used when the browser cannot normalise a value.
404
+ * @private
405
+ * @static
406
+ * @param {string} value - Trimmed CSS colour text.
407
+ * @returns {string|null}
408
+ */
409
+ static #toHexWithoutBrowser(value) {
410
+ const text = value.toLowerCase();
411
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1];
412
+ if (hex) {
413
+ const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
414
+ return `#${full.slice(0, 6)}`;
415
+ }
416
+ const channel = "(\\d*\\.?\\d+)(%?)";
417
+ const rgb = new RegExp(`^rgba?\\(\\s*${channel}[\\s,]+${channel}[\\s,]+${channel}\\s*(?:[,/][^)]*)?\\)$`).exec(text);
418
+ if (rgb) {
419
+ const channels = [1, 3, 5].map((i) => (rgb[i + 1] ? Number(rgb[i]) * 2.55 : Number(rgb[i])));
420
+ return BabylonJSViewCubeCanvasController.#formatHex(channels);
421
+ }
422
+ const hsl = /^hsla?\(\s*(-?\d*\.?\d+)(?:deg)?[\s,]+(\d*\.?\d+)%[\s,]+(\d*\.?\d+)%\s*(?:[,/][^)]*)?\)$/.exec(text);
423
+ if (hsl) {
424
+ const hue = ((Number(hsl[1]) % 360) + 360) % 360;
425
+ const s = Math.min(1, Number(hsl[2]) / 100);
426
+ const l = Math.min(1, Number(hsl[3]) / 100);
427
+ const a = s * Math.min(l, 1 - l);
428
+ const f = (n) => l - a * Math.max(-1, Math.min((n + hue / 30) % 12 - 3, 9 - ((n + hue / 30) % 12), 1));
429
+ return BabylonJSViewCubeCanvasController.#formatHex([f(0) * 255, f(8) * 255, f(4) * 255]);
430
+ }
431
+ return null;
432
+ }
433
+
434
+ /**
435
+ * Formats 0-255 channels as "#rrggbb", rounding and clamping each one.
436
+ * @private
437
+ * @static
438
+ * @param {number[]} channels - Red, green and blue.
439
+ * @returns {string}
440
+ */
441
+ static #formatHex(channels) {
442
+ return `#${channels.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("")}`;
443
+ }
444
+
445
+ /**
446
+ * Converts a quaternion into a new rotation matrix without changing the quaternion.
447
+ * @private
448
+ * @param {Quaternion} q - Camera orientation.
449
+ * @returns {Matrix} Rotation matrix.
450
+ */
451
+ #rotationMatrix(q) {
452
+ const matrix = Matrix.Identity();
453
+ q.toRotationMatrix(matrix);
454
+ return matrix;
455
+ }
456
+
457
+ /**
458
+ * Identifies the ArcRotateCamera adapter by its Babylon class name.
459
+ * @private
460
+ * @param {ViewCubeCamera|null|undefined} camera - Camera to inspect.
461
+ * @returns {boolean} Whether the camera uses orbital controls.
462
+ */
463
+ #isOrbit(camera) {
464
+ return camera?.getClassName?.() === "ArcRotateCamera";
465
+ }
466
+
467
+ /**
468
+ * Reads the effective world-space camera pose and current orthographic extents.
469
+ * Transforms orbit targets through the parent; other cameras use the remembered distance or one unit.
470
+ * @private
471
+ * @param {ViewCubeCamera} camera - Camera whose view matrix will be refreshed.
472
+ * @returns {ViewCubePose} Snapshot used for synchronization and transition interpolation.
473
+ */
474
+ #readPose(camera) {
475
+ const world = camera.getViewMatrix(true).clone().invert();
476
+ const position = Vector3.Zero();
477
+ const rotation = Quaternion.Identity();
478
+ world.decompose(undefined, rotation, position);
479
+ const orbitTarget = this.#isOrbit(camera) ? camera.getTarget().clone() : null;
480
+ const target = orbitTarget ? (camera.parent ? Vector3.TransformCoordinates(orbitTarget, camera.parent.getWorldMatrix()) : orbitTarget) : position.add(camera.getForwardRay().direction.scale(this.#cameraDistances.get(camera) ?? 1));
481
+ 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 };
482
+ }
483
+
484
+ /**
485
+ * Clears orbital, translational and rotational inertia before explicit cube navigation.
486
+ * @private
487
+ * @param {ViewCubeCamera} camera - Camera whose pending movement is cleared.
488
+ * @returns {void}
489
+ */
490
+ #clearInertia(camera) {
491
+ BabylonJSCameraWheelMotion.cancel(camera);
492
+ for (const key of BabylonJSViewCubeCanvasController.#INERTIA_KEYS) {
493
+ if (key in camera) {
494
+ camera[key] = 0;
495
+ }
496
+ }
497
+ camera.cameraDirection?.setAll(0);
498
+ camera.cameraRotation?.setAll(0);
499
+ }
500
+
501
+ /**
502
+ * Applies a world-space position and target while retaining world +Y as camera up.
503
+ * Converts parented poses to local coordinates, clears inertia and adjusts clipping/navigation limits.
504
+ * Reads ViewCube radius factors from BabylonJSController.config.camera.
505
+ * Free/Universal cameras use target/Euler rotation to prevent quaternion-driven up-vector changes.
506
+ * The transition caller checks camera identity and lock before invoking this method.
507
+ * @private
508
+ * @param {ViewCubeCamera} camera - Camera receiving the accepted navigation pose.
509
+ * @param {Vector3} position - Interpolated world-space position.
510
+ * @param {Vector3} target - Interpolated world-space model target.
511
+ * @param {number} distance - Interpolated camera-to-target distance.
512
+ * @param {ViewCubeFit} [fit] - Destination bounds/framing used to adjust clipping; drag rotation omits it and keeps the clipping planes.
513
+ * @returns {void}
514
+ */
515
+ #writePose(camera, position, target, distance, fit) {
516
+ const parentWorld = camera.parent?.getWorldMatrix?.();
517
+ const parentInverse = parentWorld?.clone().invert();
518
+ const localPosition = parentInverse ? Vector3.TransformCoordinates(position, parentInverse) : position;
519
+ const upWorld = Vector3.Up();
520
+ camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
521
+ this.#clearInertia(camera);
522
+ if (this.#isOrbit(camera)) {
523
+ // Cube selection explicitly overrides incompatible orbit constraints on this camera only.
524
+ camera.lowerAlphaLimit = null;
525
+ camera.upperAlphaLimit = null;
526
+ camera.lowerBetaLimit = null;
527
+ camera.upperBetaLimit = null;
528
+ camera.allowUpsideDown = false;
529
+ camera.lowerRadiusLimit = Math.min(distance * BabylonJSController.config.camera.minRadiusFactor, camera.lowerRadiusLimit ?? Infinity);
530
+ camera.upperRadiusLimit = Math.max(distance * BabylonJSController.config.camera.maxRadiusFactor, camera.upperRadiusLimit ?? 0);
531
+ camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
532
+ camera.setPosition(localPosition);
533
+ } else {
534
+ camera.position.copyFrom(localPosition);
535
+ // Quaternion changes make TargetCamera rotate its upVector. Its target/Euler API keeps roll zero.
536
+ camera.rotationQuaternion = null;
537
+ camera.updateUpVectorFromRotation = false;
538
+ camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
539
+ // Flush Babylon's initial roll cache before restoring the fixed world-up vector.
540
+ camera.getViewMatrix(true);
541
+ camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
542
+ this.#cameraDistances.set(camera, distance);
543
+ }
544
+ if (fit) {
545
+ camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
546
+ camera.maxZ = Math.max(camera.maxZ, distance + fit.radius * 2);
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Applies a drag increment to the host camera so the model follows the pointer.
552
+ *
553
+ * Sign convention, verified against the main-canvas input of Babylon: ArcRotateCameraPointersInput accumulates
554
+ * -offsetX and -offsetY, and ArcRotateCamera adds them to alpha (times -1 in right-handed scenes or mirrored
555
+ * parents, and times -1 with invertRotation) and to beta (times -1 with invertRotation). The same factors are
556
+ * applied here, so dragging the cube and dragging the main canvas rotate the model identically.
557
+ * ArcRotateCameras write alpha/beta directly with beta kept away from the poles; other cameras orbit their
558
+ * current target at the same distance, with the same angles, through the shared pose writer.
559
+ * @private
560
+ * @param {ViewCubeCamera} camera - Active host camera.
561
+ * @param {number} dx - Horizontal pointer travel in CSS pixels, positive to the right.
562
+ * @param {number} dy - Vertical pointer travel in CSS pixels, positive downwards.
563
+ * @returns {void}
564
+ */
565
+ #rotateCamera(camera, dx, dy) {
566
+ const invert = camera.invertRotation ? -1 : 1;
567
+ let handedness = camera.getScene().useRightHandedSystem ? -1 : 1;
568
+ if (camera.parent?.getWorldMatrix?.().determinant() < 0) {
569
+ handedness *= -1;
570
+ }
571
+ const dAlpha = -dx * BabylonJSViewCubeCanvasController.#DRAG_RADIANS_PER_PX * handedness * invert;
572
+ const dBeta = -dy * BabylonJSViewCubeCanvasController.#DRAG_RADIANS_PER_PX * invert;
573
+ if (this.#isOrbit(camera)) {
574
+ const next = BabylonJSViewCubeCanvasOrbitMath.applyDragDelta({ alpha: camera.alpha, beta: camera.beta }, dAlpha, dBeta);
575
+ this.#clearInertia(camera);
576
+ camera.alpha = next.alpha;
577
+ camera.beta = next.beta;
578
+ } else {
579
+ const pose = this.#readPose(camera);
580
+ const angles = BabylonJSViewCubeCanvasOrbitMath.anglesFromDirection(pose.position.subtract(pose.target), this.#alpha);
581
+ if (!angles) {
582
+ return;
583
+ }
584
+ const direction = BabylonJSViewCubeCanvasOrbitMath.directionFromAngles(BabylonJSViewCubeCanvasOrbitMath.applyDragDelta(angles, dAlpha, dBeta));
585
+ this.#writePose(camera, pose.target.add(new Vector3(direction.x, direction.y, direction.z).scale(pose.distance)), pose.target, pose.distance);
586
+ }
587
+ // A drag is pointer interaction: the host keeps its loop continuous for its interaction window, as it does for a
588
+ // main-canvas drag. A plain one-frame request would let its idle throttle pace the drag (seen at ~8 fps).
589
+ this.#host.requestRender({ interaction: true });
590
+ }
591
+
592
+ /**
593
+ * Follows a move of the pressed pointer: crossing the threshold turns the press into a drag, which cancels any
594
+ * running click transition and clears hover; every later move rotates by the delta since the previous one.
595
+ * @private
596
+ * @param {PointerEvent} event - Move event on the cube canvas.
597
+ * @returns {void}
598
+ */
599
+ #dragTo(event) {
600
+ const press = this.#press;
601
+ if (event.pointerId !== press.pointerId) {
602
+ return;
603
+ }
604
+ const camera = this.#host.getCamera();
605
+ if (!this.#isInteractive()) {
606
+ this.#endPress();
607
+ return;
608
+ }
609
+ if (!press.dragging) {
610
+ if (Math.hypot(event.clientX - press.startX, event.clientY - press.startY) <= BabylonJSViewCubeCanvasController.#DRAG_THRESHOLD_PX) {
611
+ return;
612
+ }
613
+ press.dragging = true;
614
+ this.cancel();
615
+ this.#setHover(null);
616
+ }
617
+ const dx = event.clientX - press.lastX;
618
+ const dy = event.clientY - press.lastY;
619
+ press.lastX = event.clientX;
620
+ press.lastY = event.clientY;
621
+ if (dx !== 0 || dy !== 0) {
622
+ this.#rotateCamera(camera, dx, dy);
623
+ }
624
+ this.#updateCursor();
625
+ }
626
+
627
+ /**
628
+ * Releases the current pointer capture after dropping press ownership to prevent lost-capture recursion.
629
+ * Touch clears hover and its cached point so camera synchronization cannot recreate sticky hover.
630
+ * Mouse/pen drag completion restores hover under the last pointer position; active-view feedback is independent.
631
+ * @private
632
+ * @returns {void}
633
+ */
634
+ #endPress() {
635
+ const press = this.#press;
636
+ if (!press) {
637
+ return;
638
+ }
639
+ this.#press = null;
640
+ try {
641
+ this.#canvas.releasePointerCapture?.(press.pointerId);
642
+ } catch (_error) {}
643
+ if (press.pointerType === "touch") {
644
+ this.#pointer = null;
645
+ this.#setHover(null);
646
+ } else if (press.dragging && this.#pointer) {
647
+ this.#setHover(this.#pick(this.#pointer));
648
+ }
649
+ this.#updateCursor();
650
+ }
651
+
652
+ /**
653
+ * Sets the canvas cursor: inherited while display-only, the shared rotation cursor while dragging, pointer over a region, default otherwise.
654
+ * @private
655
+ * @returns {void}
656
+ */
657
+ #updateCursor() {
658
+ const cursor = !this.#isInteractive() ? "" : this.#press?.dragging ? "var(--cube-rotation-cursor)" : this.#hover ? "pointer" : "default";
659
+ if (this.#canvas.style.cursor !== cursor) {
660
+ this.#canvas.style.cursor = cursor;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Publishes measured cube viewport height and scene height when either changes.
666
+ * Disabled cubes report zero height; computed CSS height is used if measurable canvas height is unavailable.
667
+ * Environments without layout or computed styles report zero until a real measurement becomes available.
668
+ * @private
669
+ * @returns {void}
670
+ */
671
+ #publishLayout() {
672
+ if (this.#disposed) {
673
+ return;
674
+ }
675
+ let viewportHeight = this.#canvas.getBoundingClientRect().height;
676
+ if (!viewportHeight) {
677
+ try {
678
+ viewportHeight = parseFloat(this.#root.ownerDocument.defaultView?.getComputedStyle(this.#root).height) || 0;
679
+ } catch (_error) {
680
+ viewportHeight = 0;
681
+ }
682
+ }
683
+ const cubeHeight = this.#enabled ? viewportHeight : 0;
684
+ const sceneHeight = this.#host.getViewport().height;
685
+ const key = `${cubeHeight},${sceneHeight}`;
686
+ if (key === this.#layoutKey) {
687
+ return;
688
+ }
689
+ this.#layoutKey = key;
690
+ this.#host.onLayout?.(cubeHeight, sceneHeight);
691
+ }
692
+
693
+ /**
694
+ * Registers an overlay or canvas listener and retains its removal callback for disposal.
695
+ * @private
696
+ * @param {EventTarget} element - Event source.
697
+ * @param {string} name - DOM event name.
698
+ * @param {EventListener} handler - Handler to register.
699
+ * @returns {void}
700
+ */
701
+ #addListener(element, name, handler) {
702
+ element.addEventListener(name, handler);
703
+ this.#listeners.push(() => element.removeEventListener(name, handler));
704
+ }
705
+
706
+ /**
707
+ * Reports whether the cube accepts pointer interaction: enabled, supported camera and not locked.
708
+ * @private
709
+ * @returns {boolean}
710
+ */
711
+ #isInteractive() {
712
+ const camera = this.#host.getCamera();
713
+ return this.#enabled && !this.#disposed && Boolean(this.#renderer) && BabylonJSCameraFraming.isCameraSupported(camera) && !this.#host.isLocked(camera);
714
+ }
715
+
716
+ /**
717
+ * Reads the current device pixel ratio of the owning window; browser zoom or a move to another monitor changes it.
718
+ * @private
719
+ * @returns {number} Positive ratio, 1 when unknown.
720
+ */
721
+ #readPixelRatio() {
722
+ const ratio = this.#root.ownerDocument.defaultView?.devicePixelRatio;
723
+ return ratio > 0 ? ratio : 1;
724
+ }
725
+
726
+ /**
727
+ * Listens for the next change of the device pixel ratio with a `(resolution: <ratio>dppx)` media query. The query
728
+ * only matches the ratio it was built with, so each change re-reads the ratio, resizes and arms a fresh query.
729
+ * Does nothing without matchMedia. Replaces any listener armed before.
730
+ * @private
731
+ * @returns {void}
732
+ */
733
+ #watchPixelRatio() {
734
+ this.#stopPixelRatioWatch?.();
735
+ this.#stopPixelRatioWatch = null;
736
+ const view = this.#root.ownerDocument.defaultView;
737
+ if (this.#disposed || typeof view?.matchMedia !== "function") {
738
+ return;
739
+ }
740
+ const list = view.matchMedia(`(resolution: ${this.#readPixelRatio()}dppx)`);
741
+ const onChange = () => {
742
+ this.#watchPixelRatio();
743
+ this.resize();
744
+ };
745
+ if (typeof list.addEventListener === "function") {
746
+ list.addEventListener("change", onChange);
747
+ this.#stopPixelRatioWatch = () => list.removeEventListener("change", onChange);
748
+ } else if (typeof list.addListener === "function") {
749
+ list.addListener(onChange);
750
+ this.#stopPixelRatioWatch = () => list.removeListener(onChange);
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Repaints after initial GPU readiness or WebGL context restoration, waking an idle host with a dirty cube.
756
+ * @private
757
+ * @returns {void}
758
+ */
759
+ #handleRendererReady() {
760
+ if (this.#disposed || !this.#renderer) {
761
+ return;
762
+ }
763
+ this.#dirty = true;
764
+ this.#host.requestRender();
765
+ }
766
+
767
+ /**
768
+ * Converts pointer client coordinates into CSS pixels from the cube canvas top-left.
769
+ * @private
770
+ * @param {PointerEvent|MouseEvent} event - Canvas input event.
771
+ * @returns {{x:number,y:number}}
772
+ */
773
+ #toLocal(event) {
774
+ const rect = this.#canvas.getBoundingClientRect();
775
+ return { x: event.clientX - (rect.left ?? 0), y: event.clientY - (rect.top ?? 0) };
776
+ }
777
+
778
+ /**
779
+ * Resolves the region under a canvas point; null while the cube is display-only.
780
+ * @private
781
+ * @param {{x:number,y:number}} point - Canvas-local CSS pixel position.
782
+ * @returns {string|null}
783
+ */
784
+ #pick(point) {
785
+ return this.#isInteractive() && Number.isFinite(point.x + point.y) ? this.#renderer.pick(point.x, point.y) : null;
786
+ }
787
+
788
+ /**
789
+ * Forwards hover to the renderer and repaints the cube right away, since hover changes outside the host loop.
790
+ * The cube has its own engine and scene, so the host's model scene is not re-rendered (no host.requestRender()).
791
+ * During sync() the cube is rendered once at its end instead. Clears hover when disabled, locked, unsupported or dragging.
792
+ * @private
793
+ * @param {string|null} id - Signed-axis region identifier, or null to remove hover.
794
+ * @returns {void}
795
+ */
796
+ #setHover(id) {
797
+ if (!this.#isInteractive() || this.#press?.dragging) {
798
+ id = null;
799
+ }
800
+ if (id === this.#hover) {
801
+ return;
802
+ }
803
+ this.#hover = id;
804
+ this.#updateCursor();
805
+ this.#renderer?.setHover(id);
806
+ this.#dirty = true;
807
+ if (!this.#syncing) {
808
+ this.#renderDirty();
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Renders the cube when something visible changed. A renderer that skipped the frame (zero-size canvas) returns
814
+ * false: the cube stays dirty so a later size change or sync repaints.
815
+ * @private
816
+ * @returns {void}
817
+ */
818
+ #renderDirty() {
819
+ if (this.#enabled && this.#renderer && this.#dirty) {
820
+ this.#dirty = this.#renderer.render() === false;
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Builds the translated face labels in the renderer's key order.
826
+ * @private
827
+ * @param {string} [locale] - Localization culture.
828
+ * @returns {Object<string, string>}
829
+ */
830
+ #getLabels(locale) {
831
+ const labels = {};
832
+ for (const key of ["right", "left", "bottom", "top", "back", "front"]) {
833
+ labels[key] = I18n.translate(`prefViewer.viewCube.${key}`, { locale });
834
+ }
835
+ return labels;
836
+ }
837
+
838
+ /**
839
+ * Refreshes inherited palette overrides when ancestor styles change, including while the host render loop is idle.
840
+ * Observes attributes only, excludes the cube's own resize/cursor writes and releases all subscriptions on disposal.
841
+ * @private
842
+ * @returns {void} Repaints only the cube; no host model frame or timer is required.
843
+ */
844
+ #onPaletteStyleChange = () => {
845
+ if (this.#disposed) {
846
+ return;
847
+ }
848
+ this.#colorsReadAt = null;
849
+ this.sync();
850
+ };
851
+
852
+ /**
853
+ * Resolves the shared SVG label typography with an invisible temporary probe, including CSS variables and relative units.
854
+ * The probe is removed immediately; missing browser values fall back to the shared defaults.
855
+ * @private
856
+ * @returns {object} Font family, size and spacing in SVG units, weight and text transformation.
857
+ */
858
+ #readLabelStyle() {
859
+ const defaults = PrefViewerViewCubeTypography.defaults;
860
+ const probe = this.#root.ownerDocument.createElement("span");
861
+ probe.className = "pref-viewer-view-cube-label-style";
862
+ this.#root.appendChild(probe);
863
+ try {
864
+ const style = this.#root.ownerDocument.defaultView.getComputedStyle(probe);
865
+ const size = parseFloat(style.fontSize);
866
+ const spacing = parseFloat(style.letterSpacing);
867
+ return {
868
+ fontFamily: style.fontFamily || defaults.fontFamily,
869
+ fontSize: size > 0 ? size : defaults.fontSize,
870
+ fontWeight: style.fontWeight || defaults.fontWeight,
871
+ letterSpacing: Number.isFinite(spacing) ? spacing : defaults.letterSpacing,
872
+ textTransform: style.textTransform || defaults.textTransform
873
+ };
874
+ } catch (_error) {
875
+ return defaults;
876
+ } finally {
877
+ probe.remove();
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Reads the shared SVG/Canvas palette and label typography from computed CSS, with immutable style defaults as fallback.
883
+ * Polling is throttled to COLOR_REFRESH_MS and unchanged raw inputs avoid color probes and repaints.
884
+ * Missing/unresolvable CSS values retain defaults; edge/corner colors use their dedicated Canvas gray.
885
+ * @private
886
+ * @param {boolean} [force=false] - Resolves now even when raw CSS inputs are unchanged.
887
+ * @returns {object|null} Resolved hex colors and numeric highlight opacities, or null when unchanged/throttled.
888
+ */
889
+ #readColors(force = false) {
890
+ const now = this.#now();
891
+ if (!force && this.#colorsReadAt !== null && now - this.#colorsReadAt < BabylonJSViewCubeCanvasController.#COLOR_REFRESH_MS) {
892
+ return null;
893
+ }
894
+ this.#colorsReadAt = now;
895
+ const defaults = PrefViewerViewCubePalette.colors;
896
+ let style;
897
+ try {
898
+ style = this.#root.ownerDocument.defaultView.getComputedStyle(this.#root);
899
+ } catch (_error) {}
900
+ /** @param {string} name - CSS custom property. @returns {string} Trimmed value, or empty when unavailable. */
901
+ const read = name => style?.getPropertyValue(name)?.trim() ?? "";
902
+ const primary = read(BabylonJSViewCubeCanvasController.#PRIMARY_COLOR_VARIABLE) || String(PrefViewerStyleVariables.colorPrimary);
903
+ const raw = {
904
+ fill: read("--pref-viewer-view-cube-fill-color") || read("--cube-fill-color"), border: read("--pref-viewer-view-cube-border-color") || read("--cube-border-color"), text: read("--pref-viewer-view-cube-text-color") || read("--cube-text-color"),
905
+ edge: read("--pref-viewer-view-cube-edge-color") || read("--cube-edge-color"), corner: read("--pref-viewer-view-cube-corner-color") || read("--cube-corner-color"),
906
+ hover: read("--pref-viewer-view-cube-hover-color") || read("--cube-hover-color"), current: read("--pref-viewer-view-cube-active-color") || read("--cube-active-color"),
907
+ hoverOpacity: read("--pref-viewer-view-cube-hover-opacity") || read("--cube-hover-opacity"), currentOpacity: read("--pref-viewer-view-cube-active-opacity") || read("--cube-active-opacity")
908
+ };
909
+ const labelStyle = this.#readLabelStyle();
910
+ const key = JSON.stringify([primary, raw, defaults, labelStyle]);
911
+ if (key === this.#paletteRaw && !force) {
912
+ return null;
913
+ }
914
+ this.#paletteRaw = key;
915
+ this.#renderer?.setLabelStyle?.(labelStyle);
916
+ /** @param {string} value - CSS color. @param {string} fallback - Known usable hex color. @returns {string} Resolved opaque hex color. */
917
+ const color = (value, fallback) => BabylonJSViewCubeCanvasController.#toHex(value, this.#root) ?? fallback;
918
+ const border = color(raw.border, defaults.border);
919
+ const tint = color(primary, defaults.hover);
920
+ /** @param {string} value - CSS opacity. @param {number} fallback - Default opacity. @returns {number} Opacity clamped to 0..1. */
921
+ const opacity = (value, fallback) => value && Number.isFinite(Number(value)) ? Math.min(1, Math.max(0, Number(value))) : fallback;
922
+ return {
923
+ fill: color(raw.fill, defaults.fill), border, text: color(raw.text, defaults.text),
924
+ edge: color(raw.edge, defaults.edge), corner: color(raw.corner, defaults.corner),
925
+ hover: color(raw.hover, tint), current: color(raw.current, tint),
926
+ hoverOpacity: opacity(raw.hoverOpacity, defaults.hoverOpacity), currentOpacity: opacity(raw.currentOpacity, defaults.currentOpacity)
927
+ };
928
+ }
929
+
930
+ /**
931
+ * Compares the pose inputs with the last ones the cube was posed for and remembers them.
932
+ * @private
933
+ * @param {ArrayLike<number>} matrix - Camera view matrix elements.
934
+ * @param {number} mode - Camera projection mode.
935
+ * @param {boolean} rightHanded - Whether the scene is right-handed.
936
+ * @param {number} fov - Camera vertical field of view in radians.
937
+ * @returns {boolean} Whether anything differs from the previous call.
938
+ */
939
+ #updateGeometryKey(matrix, mode, rightHanded, fov) {
940
+ const key = this.#geometryKey;
941
+ if (key && key.mode === mode && key.rightHanded === rightHanded && key.fov === fov && key.matrix.every((value, i) => value === matrix[i])) {
942
+ return false;
943
+ }
944
+ this.#geometryKey = { matrix: Array.from(matrix), mode, rightHanded, fov };
945
+ return true;
946
+ }
947
+
948
+ /**
949
+ * Current time in milliseconds: the host clock when it provides one, otherwise the performance clock.
950
+ * @private
951
+ * @returns {number}
952
+ */
953
+ #now() {
954
+ return this.#host.now?.() ?? performance.now();
955
+ }
956
+
957
+ /**
958
+ * Creates the renderer with resolved CSS typography once a camera exists while enabled, subscribing to GPU readiness and context restoration.
959
+ * @private
960
+ * @param {string} [locale] - Localization culture used for the initial labels.
961
+ * @returns {void}
962
+ */
963
+ #ensureRenderer(locale) {
964
+ if (this.#renderer) {
965
+ return;
966
+ }
967
+ const options = {
968
+ canvas: this.#canvas,
969
+ labels: this.#getLabels(locale),
970
+ colors: this.#readColors(true) ?? {},
971
+ labelStyle: this.#readLabelStyle(),
972
+ devicePixelRatio: this.#readPixelRatio(),
973
+ onContextRestored: () => this.#handleRendererReady(),
974
+ onReady: () => this.#handleRendererReady()
975
+ };
976
+ this.#renderer = this.#host.createRenderer ? this.#host.createRenderer(options) : new BabylonJSViewCubeCanvasRenderer(options);
977
+ this.#renderer.resize(this.#readPixelRatio());
978
+ this.#applyLocale(locale);
979
+ this.#geometryKey = null;
980
+ this.#dirty = true;
981
+ }
982
+
983
+ /**
984
+ * Remembers the culture and localizes the canvas accessible name.
985
+ * @private
986
+ * @param {string} [locale] - Localization culture.
987
+ * @returns {void}
988
+ */
989
+ #applyLocale(locale) {
990
+ this.#locale = locale;
991
+ this.#canvas.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
992
+ }
993
+
994
+ /**
995
+ * Disposes the renderer and forgets everything derived from it.
996
+ * @private
997
+ * @returns {void}
998
+ */
999
+ #disposeRenderer() {
1000
+ this.#endPress();
1001
+ this.#renderer?.dispose();
1002
+ this.#renderer = null;
1003
+ this.#hover = null;
1004
+ this.#paletteRaw = null;
1005
+ this.#colorsReadAt = null;
1006
+ this.#geometryKey = null;
1007
+ this.#canvas.style.cursor = "";
1008
+ }
1009
+
1010
+ /**
1011
+ * Reports whether an accepted navigation transition is pending.
1012
+ * @public
1013
+ * @returns {boolean} Whether the owning renderer should continue advancing the transition.
1014
+ */
1015
+ get isAnimating() {
1016
+ return this.#transition !== null;
1017
+ }
1018
+
1019
+ /**
1020
+ * Returns the overlay root for host integration or inspection.
1021
+ * @public
1022
+ * @returns {HTMLDivElement} Root element containing the cube canvas.
1023
+ */
1024
+ get element() {
1025
+ return this.#root;
1026
+ }
1027
+
1028
+ /**
1029
+ * Changes cube visibility without reloading assets or replacing the camera.
1030
+ * Disabling cancels navigation and clears hover; either state refreshes dimensions and synchronization.
1031
+ * @public
1032
+ * @param {boolean} enabled - Whether the cube should be visible and interactive when unlocked.
1033
+ * @returns {void}
1034
+ */
1035
+ setEnabled(enabled) {
1036
+ if (this.#disposed) {
1037
+ return;
1038
+ }
1039
+ this.#enabled = Boolean(enabled);
1040
+ this.#colorsReadAt = null;
1041
+ this.#root.hidden = !this.#enabled;
1042
+ if (!this.#enabled) {
1043
+ this.cancel();
1044
+ this.#pointer = null;
1045
+ this.#setHover(null);
1046
+ this.#disposeRenderer();
1047
+ }
1048
+ this.resize();
1049
+ this.sync();
1050
+ }
1051
+
1052
+ /**
1053
+ * Constrains the shared CSS-sized square to the available viewport, resizes graphics and publishes layout.
1054
+ * SVG and Canvas use the same presentation-density dimensions; CSS owns width and height.
1055
+ * Cancels obsolete navigation and synchronizes the indicator without changing the camera pose.
1056
+ * @public
1057
+ * @returns {void}
1058
+ */
1059
+ resize() {
1060
+ if (this.#disposed) {
1061
+ return;
1062
+ }
1063
+ this.cancel();
1064
+ this.#colorsReadAt = null;
1065
+ const viewport = this.#host.getViewport();
1066
+ const size = Math.max(0, Math.min(viewport.width, viewport.height));
1067
+ this.#root.style.maxWidth = `${size}px`;
1068
+ this.#root.style.maxHeight = `${size}px`;
1069
+ if (this.#renderer) {
1070
+ this.#renderer.resize(this.#readPixelRatio());
1071
+ this.#dirty = true;
1072
+ }
1073
+ this.#publishLayout();
1074
+ this.sync();
1075
+ }
1076
+
1077
+ /**
1078
+ * Coalesces observer/presentation resize requests into the owning window's next animation frame.
1079
+ * Keeps canvas/root writes outside ResizeObserver delivery and ignores queued work after disposal.
1080
+ * @returns {void} Environments without animation frames resize synchronously.
1081
+ */
1082
+ requestResize() {
1083
+ if (this.#disposed || this.#resizeFrame !== null) {
1084
+ return;
1085
+ }
1086
+ const view = this.#canvas.ownerDocument.defaultView;
1087
+ if (!view?.requestAnimationFrame) {
1088
+ this.resize();
1089
+ return;
1090
+ }
1091
+ this.#resizeFrame = view.requestAnimationFrame(() => {
1092
+ this.#resizeFrame = null;
1093
+ if (!this.#disposed) {
1094
+ this.resize();
1095
+ }
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * Stops the pending transition at the displayed pose without reverting camera changes.
1101
+ * @public
1102
+ * @returns {void}
1103
+ */
1104
+ cancel() {
1105
+ this.#transition = null;
1106
+ }
1107
+
1108
+ /**
1109
+ * Starts or retargets smooth navigation to a region using fresh model-only framing.
1110
+ * Rejects disabled/disposed, locked, unsupported or unusable inputs without starting navigation.
1111
+ * An accepted action clears inertia and asks the owning renderer to advance the transition.
1112
+ * @public
1113
+ * @param {string} id - Signed-axis preset identifier, for example "0,0,1" for Interior.
1114
+ * @param {number} [now=performance.now()] - Start timestamp in milliseconds on the performance clock.
1115
+ * @returns {boolean} Whether navigation was accepted.
1116
+ */
1117
+ select(id, now = performance.now()) {
1118
+ const camera = this.#host.getCamera();
1119
+ const region = this.#regions.find((r) => r.id === id);
1120
+ if (this.#disposed || !this.#enabled || !region || this.#host.isLocked(camera) || !BabylonJSCameraFraming.isCameraSupported(camera)) {
1121
+ return false;
1122
+ }
1123
+ const fit = BabylonJSCameraFraming.calculateCameraFit(camera, this.#host.getBounds(), region.direction, this.#host.getViewport());
1124
+ if (!fit) {
1125
+ return false;
1126
+ }
1127
+ this.#clearInertia(camera);
1128
+ this.#transition = { camera, mode: camera.mode, start: this.#readPose(camera), fit, at: now };
1129
+ this.#host.requestRender();
1130
+ return true;
1131
+ }
1132
+
1133
+ /**
1134
+ * Advances an accepted transition with smooth easing and preserved projection mode.
1135
+ * Uses BabylonJSController.config.camera.transitionDuration as the duration in milliseconds.
1136
+ * Rechecks active-camera identity, lock and projection before each write; invalidated transitions stop.
1137
+ * The owning render loop calls this method; it does not start an independent animation loop.
1138
+ * @public
1139
+ * @param {number} [now=performance.now()] - Current timestamp in milliseconds on the performance clock.
1140
+ * @returns {void}
1141
+ */
1142
+ tick(now = performance.now()) {
1143
+ const t = this.#transition;
1144
+ if (!t) {
1145
+ return;
1146
+ }
1147
+ if (this.#host.getCamera() !== t.camera || this.#host.isLocked(t.camera) || t.camera.mode !== t.mode) {
1148
+ this.cancel();
1149
+ return;
1150
+ }
1151
+ const p = Math.max(0, Math.min(1, (now - t.at) / BabylonJSController.config.camera.transitionDuration));
1152
+ const eased = p * p * (3 - 2 * p);
1153
+ const rotation = Quaternion.Slerp(t.start.rotation, t.fit.rotation, eased);
1154
+ const target = Vector3.Lerp(t.start.target, t.fit.target, eased);
1155
+ const distance = t.start.distance + (t.fit.distance - t.start.distance) * eased;
1156
+ const forward = Vector3.TransformNormal(new Vector3(0, 0, t.camera.getScene().useRightHandedSystem ? -1 : 1), this.#rotationMatrix(rotation));
1157
+ const position = target.subtract(forward.scale(distance));
1158
+ this.#writePose(t.camera, position, target, distance, t.fit);
1159
+ if (t.mode === 1) {
1160
+ const viewport = this.#host.getViewport();
1161
+ t.camera.orthoLeft = BabylonJSViewCubeCanvasController.#interpolateExtent(t.start.left, -t.fit.halfWidth, -viewport.width / 2, eased);
1162
+ t.camera.orthoRight = BabylonJSViewCubeCanvasController.#interpolateExtent(t.start.right, t.fit.halfWidth, viewport.width / 2, eased);
1163
+ t.camera.orthoBottom = BabylonJSViewCubeCanvasController.#interpolateExtent(t.start.bottom, -t.fit.halfHeight, -viewport.height / 2, eased);
1164
+ t.camera.orthoTop = BabylonJSViewCubeCanvasController.#interpolateExtent(t.start.top, t.fit.halfHeight, viewport.height / 2, eased);
1165
+ }
1166
+ if (p === 1) {
1167
+ this.cancel();
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Synchronizes the 3D cube with the active camera: pose, nearest orientation, labels, colours and lock state.
1173
+ * Rebinds the view-matrix observer on replacement and guards against recursive notifications.
1174
+ * Creates the renderer lazily once a camera exists. The cube orientation follows the camera direction only,
1175
+ * independent of model zoom and projection. Locked, unsupported and XR cameras keep the cube following the
1176
+ * camera but make it display-only. Renders only when something visible changed; it is called by the host
1177
+ * render loop after the scene renders. Hover picking stays suspended throughout a drag, while camera
1178
+ * orientation and current-region highlighting continue to update. Hover changes repaint only the cube.
1179
+ * @public
1180
+ * @returns {void}
1181
+ */
1182
+ sync() {
1183
+ if (this.#disposed || !this.#enabled || this.#syncing) {
1184
+ return;
1185
+ }
1186
+ this.#syncing = true;
1187
+ try {
1188
+ const camera = this.#host.getCamera();
1189
+ if (camera !== this.#camera) {
1190
+ this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
1191
+ this.#camera = camera;
1192
+ this.#geometryKey = null;
1193
+ this.#observer = camera?.onViewMatrixChangedObservable?.add(() => this.sync());
1194
+ }
1195
+ const locked = !BabylonJSCameraFraming.isCameraSupported(camera) || this.#host.isLocked(camera);
1196
+ const lockChanged = locked !== this.#locked;
1197
+ this.#locked = locked;
1198
+ if (lockChanged) {
1199
+ this.#root.dataset.locked = String(locked);
1200
+ this.#canvas.style.pointerEvents = locked ? "none" : "auto";
1201
+ this.#endPress();
1202
+ this.#dirty = true;
1203
+ }
1204
+ if (locked) {
1205
+ this.#setHover(null);
1206
+ }
1207
+ this.#updateCursor();
1208
+ if (!camera?.getViewMatrix) {
1209
+ this.#canvas.style.visibility = "hidden";
1210
+ return;
1211
+ }
1212
+ if (this.#canvas.style.visibility !== "visible") {
1213
+ this.#canvas.style.visibility = "visible";
1214
+ }
1215
+ const locale = this.#host.getCulture?.();
1216
+ this.#ensureRenderer(locale);
1217
+ this.#updateCursor();
1218
+ const renderer = this.#renderer;
1219
+ if (locale !== this.#locale) {
1220
+ this.#applyLocale(locale);
1221
+ renderer.setLabels(this.#getLabels(locale));
1222
+ this.#colorsReadAt = null;
1223
+ this.#dirty = true;
1224
+ }
1225
+ const colors = this.#readColors();
1226
+ if (colors) {
1227
+ renderer.setColors(colors);
1228
+ this.#dirty = true;
1229
+ }
1230
+ const rightHanded = camera.getScene().useRightHandedSystem;
1231
+ if (this.#updateGeometryKey(camera.getViewMatrix().m, camera.mode, rightHanded, camera.fov)) {
1232
+ const pose = this.#readPose(camera);
1233
+ const rotation = this.#rotationMatrix(pose.rotation);
1234
+ const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, rightHanded ? 1 : -1), rotation).normalize();
1235
+ // At the poles alpha is undefined and the horizontal part is numerical noise: snap it to
1236
+ // zero so the previous alpha is kept instead of spinning the cube arbitrarily.
1237
+ const horizontal = Math.hypot(towardObserver.x, towardObserver.z) < BabylonJSViewCubeCanvasController.#POLE_SNAP ? { x: 0, y: towardObserver.y, z: 0 } : towardObserver;
1238
+ const angles = BabylonJSViewCubeCanvasOrbitMath.anglesFromDirection(horizontal, this.#alpha);
1239
+ if (angles) {
1240
+ this.#alpha = angles.alpha;
1241
+ renderer.setPose({ ...angles, mode: camera.mode, fov: camera.fov });
1242
+ }
1243
+ const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
1244
+ renderer.setCurrent(nearest.id);
1245
+ this.#root.dataset.projection = camera.mode === 1 ? "orthographic" : "perspective";
1246
+ this.#root.dataset.currentRegion = nearest.id;
1247
+ this.#dirty = true;
1248
+ // Camera motion can move hit regions under a stationary pointer outside a drag.
1249
+ if (this.#pointer && !this.#press?.dragging) {
1250
+ this.#setHover(this.#pick(this.#pointer));
1251
+ }
1252
+ }
1253
+ this.#renderDirty();
1254
+ } finally {
1255
+ this.#syncing = false;
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * Idempotently cancels navigation and releases listeners, camera/resize/style observers, the renderer, pending resize frame and overlay DOM.
1261
+ * Reports zero layout footprint without disposing the host camera, scene or model assets.
1262
+ * @public
1263
+ * @returns {void}
1264
+ */
1265
+ dispose() {
1266
+ if (this.#disposed) {
1267
+ return;
1268
+ }
1269
+ this.cancel();
1270
+ this.#disposed = true;
1271
+ if (this.#resizeFrame !== null) {
1272
+ this.#canvas.ownerDocument.defaultView?.cancelAnimationFrame?.(this.#resizeFrame);
1273
+ this.#resizeFrame = null;
1274
+ }
1275
+ this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
1276
+ this.#paletteStyleObserver?.disconnect();
1277
+ this.#resizeObserver?.disconnect();
1278
+ this.#viewportResizeObserver?.disconnect();
1279
+ this.#stopPixelRatioWatch?.();
1280
+ this.#stopPixelRatioWatch = null;
1281
+ this.#listeners.forEach((remove) => remove());
1282
+ this.#listeners.length = 0;
1283
+ this.#disposeRenderer();
1284
+ this.#root.remove();
1285
+ this.#host.onLayout?.(0, 0);
1286
+ }
1287
+ }