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