@preference-sl/pref-viewer 2.17.0 → 2.17.2-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.
@@ -1,8 +1,32 @@
1
1
  import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
2
- import { PrefViewerViewCubeStyles } from "./styles.js";
2
+ import { PrefViewerStyleVariables } from "./styles.js";
3
3
  import I18n from "./localization/i18n.js";
4
4
  import BabylonJSController from "./babylonjs-controller.js";
5
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"];
6
30
 
7
31
  /**
8
32
  * @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
@@ -47,39 +71,42 @@ import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
47
71
  */
48
72
 
49
73
  /**
50
- * Virtual camera inputs used to project cube surfaces into SVG.
51
- * @typedef {Object} ViewCubeProjection
52
- * @property {Matrix} view - Cube-local to virtual camera rotation matrix.
53
- * @property {boolean} perspective - Whether to apply perspective scaling.
54
- * @property {number} depthSign - Scene handedness depth direction.
55
- * @property {number} eyeDistance - Virtual camera distance independent of model zoom.
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.
56
81
  */
57
82
 
58
83
  /**
59
- * BabylonJSViewCubeController - Owns an SVG orientation indicator and model-framing camera navigation.
84
+ * BabylonJSViewCubeController - Owns a 3D orientation indicator and model-framing camera navigation.
60
85
  *
61
86
  * Displays 26 face/edge/corner presets with primary-color hover and nearest-view feedback.
62
- * Synchronizes camera projection and orientation while locked cameras remain display-only.
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.
63
90
  * Accepted clicks preserve projection, fit only model bounds and retain world +Y as camera up.
64
91
  *
65
92
  * The host owns the camera, scene, assets and render loop. It calls tick() to advance transitions
66
- * and sync() to refresh the indicator. The cube requests rendering but allocates no Engine,
67
- * Scene or asset meshes. dispose() releases only the overlay and its listeners/observers.
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.
68
96
  *
69
97
  * Members are ordered as properties, constructor, static methods, private methods and public methods.
70
98
  * @class
71
99
  */
72
100
  export default class BabylonJSViewCubeController {
73
- static #SVG_NS = "http://www.w3.org/2000/svg";
74
-
75
101
  #cameraDistances = new WeakMap();
76
102
  #host;
77
103
  #root;
78
- #svg;
104
+ #canvas;
105
+ #renderer = null;
79
106
  #regions = BabylonJSViewCubeController.createViewCubeRegions();
80
- #nodes = new Map();
81
- #shell = [];
107
+ // View matrix, projection mode and handedness the cube was last posed for; null forces a new pose.
82
108
  #geometryKey = null;
109
+ #colorsReadAt = null;
83
110
  #locale;
84
111
  #locked;
85
112
  #enabled = false;
@@ -90,13 +117,18 @@ export default class BabylonJSViewCubeController {
90
117
  #disposed = false;
91
118
  #hover = null;
92
119
  #pointer = null;
120
+ #press = null;
121
+ #alpha = 0;
122
+ #dirty = false;
123
+ #primaryRaw = null;
93
124
  #listeners = [];
94
125
  #resizeObserver;
95
126
  #viewportResizeObserver;
96
127
  #layoutKey = null;
128
+ #stopPixelRatioWatch = null;
97
129
 
98
130
  /**
99
- * Creates the SVG overlay, attaches input/resize observers and enables the cube.
131
+ * Creates the overlay root and canvas, attaches input/resize observers and enables the cube.
100
132
  * The host retains ownership of the camera, scene, assets and render loop. Callbacks resolve
101
133
  * current state so camera/model replacement does not leave the cube with stale references.
102
134
  *
@@ -109,80 +141,76 @@ export default class BabylonJSViewCubeController {
109
141
  * @param {function(): {width: number, height: number}} host.getViewport - Returns the current scene viewport dimensions in CSS pixels.
110
142
  * @param {function(): void} host.requestRender - Requests rendering through the host scheduler after accepted navigation.
111
143
  * @param {function(): string} [host.getCulture] - Returns the current localization culture; omitted callbacks use the translator's default culture.
112
- * @param {function(number, number): void} [host.onLayout] - Receives the measured cube SVG viewport height and scene viewport height, in CSS pixels; zero cube height clears the menu offset, and disposal reports (0, 0).
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.
113
146
  */
114
147
  constructor(host) {
115
148
  this.#host = host;
116
149
  const doc = host.canvas.ownerDocument;
117
150
  this.#root = doc.createElement("div");
118
151
  this.#root.className = "pref-viewer-view-cube";
119
- this.#root.style.cssText = "position:absolute;right:0px;top:0px;width:100px;height:100px;z-index:900;pointer-events:none";
120
- const style = doc.createElement("style");
121
- style.textContent = PrefViewerViewCubeStyles;
122
- this.#root.append(style);
123
- this.#svg = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "svg");
124
- this.#svg.setAttribute("viewBox", "0 0 120 120");
125
- this.#svg.setAttribute("role", "group");
126
- this.#root.append(this.#svg);
127
- for (const region of this.#regions.filter((r) => r.kind === "face")) {
128
- const polygon = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "polygon");
129
- polygon.setAttribute("class", "cube-shell");
130
- this.#svg.append(polygon);
131
- this.#shell.push({ ...region.outline, polygon });
132
- }
133
- for (const region of this.#regions) {
134
- const group = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "g");
135
- const surfaces = region.surfaces.map((surface) => {
136
- const polygon = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "polygon");
137
- polygon.dataset.region = region.id;
138
- polygon.dataset.kind = region.kind;
139
- group.append(polygon);
140
- return { ...surface, polygon };
141
- });
142
- const label = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "text");
143
- if (region.kind === "face") {
144
- group.append(label);
145
- }
146
- this.#svg.append(group);
147
- const outline = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "path");
148
- outline.setAttribute("class", "hover-outline");
149
- this.#nodes.set(region.id, { group, surfaces, label, outline });
150
- }
151
- // Stroke every clickable boundary above all filled regions, including interior face boundaries.
152
- for (const node of this.#nodes.values()) {
153
- this.#svg.append(node.outline);
154
- }
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);
155
160
  host.canvas.parentElement.append(this.#root);
156
- this.#addListener(this.#svg, "pointermove", (event) => {
157
- this.#pointer = { x: event.clientX, y: event.clientY };
158
- this.#setHover(BabylonJSViewCubeController.#getRegionAt(event));
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));
159
168
  });
160
- this.#addListener(this.#svg, "pointerleave", () => {
169
+ this.#addListener(this.#canvas, "pointerleave", () => {
161
170
  this.#pointer = null;
171
+ this.#endPress();
162
172
  this.#setHover(null);
163
173
  });
164
- this.#addListener(this.#svg, "pointerdown", (event) => {
165
- if (BabylonJSViewCubeController.#getRegionAt(event)) {
166
- event.stopPropagation();
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;
167
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) {}
168
188
  });
169
- this.#addListener(this.#svg, "click", (event) => {
170
- const id = BabylonJSViewCubeController.#getRegionAt(event);
171
- if (!id) {
189
+ this.#addListener(this.#canvas, "pointerup", (event) => {
190
+ const press = this.#press;
191
+ if (!press || press.pointerId !== event.pointerId) {
172
192
  return;
173
193
  }
174
- event.stopPropagation();
175
- if (event.button === 0) {
176
- this.select(id);
194
+ this.#endPress();
195
+ if (!press.dragging && press.id && event.button === 0 && this.#pick(this.#toLocal(event)) === press.id) {
196
+ this.select(press.id);
177
197
  }
178
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
+ }
179
206
  for (const event of ["pointerdown", "wheel", "keydown"]) {
180
207
  this.#addListener(host.canvas, event, () => this.cancel());
181
208
  }
182
209
  this.#resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.resize());
183
210
  this.#resizeObserver?.observe(host.canvas);
184
211
  this.#viewportResizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.#publishLayout());
185
- this.#viewportResizeObserver?.observe(this.#svg);
212
+ this.#viewportResizeObserver?.observe(this.#canvas);
213
+ this.#watchPixelRatio();
186
214
  this.setEnabled(true);
187
215
  }
188
216
 
@@ -291,17 +319,6 @@ export default class BabylonJSViewCubeController {
291
319
  regions.push({ id: axis.join(","), direction, surfaces, kind: ["", "face", "edge", "corner"][axis.filter(Boolean).length] });
292
320
  }
293
321
 
294
- /**
295
- * Finds the semantic cube-region id under the event target.
296
- * @private
297
- * @static
298
- * @param {PointerEvent|MouseEvent} event - Overlay input event.
299
- * @returns {string|null}
300
- */
301
- static #getRegionAt(event) {
302
- return event.target?.closest?.("polygon[data-region]")?.dataset.region ?? null;
303
- }
304
-
305
322
  /**
306
323
  * Interpolates an orthographic extent from its start or fallback using the current eased transition.
307
324
  * @private
@@ -317,36 +334,109 @@ export default class BabylonJSViewCubeController {
317
334
  }
318
335
 
319
336
  /**
320
- * Projects a cube-space vertex into the overlay SVG using the effective camera projection.
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.
321
358
  * @private
322
359
  * @static
323
- * @param {Vector3} vertex - Cube-space vertex.
324
- * @param {ViewCubeProjection} projection - Current virtual cube projection.
325
- * @returns {{x:number,y:number}}
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}
326
422
  */
327
- static #projectVertex(vertex, projection) {
328
- const { view, perspective, depthSign, eyeDistance } = projection;
329
- const point = Vector3.TransformNormal(vertex, view);
330
- const scale = perspective ? eyeDistance / (eyeDistance + point.z * depthSign) : 1;
331
- return { x: 60 + point.x * 31 * scale, y: 60 - point.y * 31 * scale };
423
+ static #formatHex(channels) {
424
+ return `#${channels.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("")}`;
332
425
  }
333
426
 
334
427
  /**
335
- * Updates surface visibility and projected polygon points for the current camera orientation.
428
+ * Mixes two "#rrggbb" colours.
336
429
  * @private
337
430
  * @static
338
- * @param {object} surface - Surface containing its normal, vertices and SVG polygon.
339
- * @param {ViewCubeProjection} projection - Current virtual cube projection.
340
- * @returns {{shown:boolean,points:Array<{x:number,y:number}>}}
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.
341
435
  */
342
- static #drawSurface(surface, projection) {
343
- const { view, perspective, depthSign, eyeDistance } = projection;
344
- const normal = Vector3.TransformNormal(surface.normal, view);
345
- const shown = perspective ? -normal.z * depthSign * eyeDistance > 1.001 : normal.z * depthSign < -0.001;
346
- surface.polygon.style.display = shown ? "" : "none";
347
- const points = surface.vertices.map((vertex) => BabylonJSViewCubeController.#projectVertex(vertex, projection));
348
- surface.polygon.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" "));
349
- return { shown, points };
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("")}`;
350
440
  }
351
441
 
352
442
  /**
@@ -395,7 +485,8 @@ export default class BabylonJSViewCubeController {
395
485
  * @returns {void}
396
486
  */
397
487
  #clearInertia(camera) {
398
- for (const key of ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"]) {
488
+ BabylonJSCameraWheelMotion.cancel(camera);
489
+ for (const key of INERTIA_KEYS) {
399
490
  if (key in camera) {
400
491
  camera[key] = 0;
401
492
  }
@@ -415,7 +506,7 @@ export default class BabylonJSViewCubeController {
415
506
  * @param {Vector3} position - Interpolated world-space position.
416
507
  * @param {Vector3} target - Interpolated world-space model target.
417
508
  * @param {number} distance - Interpolated camera-to-target distance.
418
- * @param {ViewCubeFit} fit - Destination bounds/framing used to adjust clipping.
509
+ * @param {ViewCubeFit} [fit] - Destination bounds/framing used to adjust clipping; drag rotation omits it and keeps the clipping planes.
419
510
  * @returns {void}
420
511
  */
421
512
  #writePose(camera, position, target, distance, fit) {
@@ -447,8 +538,117 @@ export default class BabylonJSViewCubeController {
447
538
  camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
448
539
  this.#cameraDistances.set(camera, distance);
449
540
  }
450
- camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
451
- camera.maxZ = Math.max(camera.maxZ, distance + fit.radius * 2);
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
+ this.#host.requestRender();
585
+ }
586
+
587
+ /**
588
+ * Follows a move of the pressed pointer: crossing the threshold turns the press into a drag, which cancels any
589
+ * running click transition and clears hover; every later move rotates by the delta since the previous one.
590
+ * @private
591
+ * @param {PointerEvent} event - Move event on the cube canvas.
592
+ * @returns {void}
593
+ */
594
+ #dragTo(event) {
595
+ const press = this.#press;
596
+ if (event.pointerId !== press.pointerId) {
597
+ return;
598
+ }
599
+ const camera = this.#host.getCamera();
600
+ if (!this.#isInteractive()) {
601
+ this.#endPress();
602
+ return;
603
+ }
604
+ if (!press.dragging) {
605
+ if (Math.hypot(event.clientX - press.startX, event.clientY - press.startY) <= DRAG_THRESHOLD_PX) {
606
+ return;
607
+ }
608
+ press.dragging = true;
609
+ this.cancel();
610
+ this.#setHover(null);
611
+ }
612
+ const dx = event.clientX - press.lastX;
613
+ const dy = event.clientY - press.lastY;
614
+ press.lastX = event.clientX;
615
+ press.lastY = event.clientY;
616
+ if (dx !== 0 || dy !== 0) {
617
+ this.#rotateCamera(camera, dx, dy);
618
+ }
619
+ this.#updateCursor();
620
+ }
621
+
622
+ /**
623
+ * Ends the current press, if any: releases the pointer capture and, after a drag, brings hover back under the pointer.
624
+ * @private
625
+ * @returns {void}
626
+ */
627
+ #endPress() {
628
+ const press = this.#press;
629
+ if (!press) {
630
+ return;
631
+ }
632
+ this.#press = null;
633
+ try {
634
+ this.#canvas.releasePointerCapture?.(press.pointerId);
635
+ } catch (_error) {}
636
+ if (press.dragging && this.#pointer) {
637
+ this.#setHover(this.#pick(this.#pointer));
638
+ }
639
+ this.#updateCursor();
640
+ }
641
+
642
+ /**
643
+ * Sets the canvas cursor: none while display-only, grabbing while dragging, pointer over a region, grab otherwise.
644
+ * @private
645
+ * @returns {void}
646
+ */
647
+ #updateCursor() {
648
+ const cursor = !this.#isInteractive() ? "" : this.#press?.dragging ? "grabbing" : this.#hover ? "pointer" : "grab";
649
+ if (this.#canvas.style.cursor !== cursor) {
650
+ this.#canvas.style.cursor = cursor;
651
+ }
452
652
  }
453
653
 
454
654
  /**
@@ -461,7 +661,7 @@ export default class BabylonJSViewCubeController {
461
661
  if (this.#disposed) {
462
662
  return;
463
663
  }
464
- const viewportHeight = this.#svg.getBoundingClientRect().height || parseFloat(this.#root.style.height) || 0;
664
+ const viewportHeight = this.#canvas.getBoundingClientRect().height || parseFloat(this.#root.style.height) || 0;
465
665
  const cubeHeight = this.#enabled ? viewportHeight : 0;
466
666
  const sceneHeight = this.#host.getViewport().height;
467
667
  const key = `${cubeHeight},${sceneHeight}`;
@@ -486,41 +686,235 @@ export default class BabylonJSViewCubeController {
486
686
  }
487
687
 
488
688
  /**
489
- * Updates hover feedback for every surface and the outline of one region.
689
+ * Reports whether the cube accepts pointer interaction: enabled, supported camera and not locked.
690
+ * @private
691
+ * @returns {boolean}
692
+ */
693
+ #isInteractive() {
694
+ const camera = this.#host.getCamera();
695
+ return this.#enabled && !this.#disposed && Boolean(this.#renderer) && BabylonJSCameraFraming.isCameraSupported(camera) && !this.#host.isLocked(camera);
696
+ }
697
+
698
+ /**
699
+ * Reads the current device pixel ratio of the owning window; browser zoom or a move to another monitor changes it.
700
+ * @private
701
+ * @returns {number} Positive ratio, 1 when unknown.
702
+ */
703
+ #readPixelRatio() {
704
+ const ratio = this.#root.ownerDocument.defaultView?.devicePixelRatio;
705
+ return ratio > 0 ? ratio : 1;
706
+ }
707
+
708
+ /**
709
+ * Listens for the next change of the device pixel ratio with a `(resolution: <ratio>dppx)` media query. The query
710
+ * only matches the ratio it was built with, so each change re-reads the ratio, resizes and arms a fresh query.
711
+ * Does nothing without matchMedia. Replaces any listener armed before.
490
712
  * @private
491
- * @param {string|null} key - Cube region id whose hover feedback is updated.
492
- * @param {boolean} active - Whether hover feedback is active.
493
713
  * @returns {void}
494
714
  */
495
- #setRegionHover(key, active) {
496
- const node = this.#nodes.get(key);
497
- if (!node) {
715
+ #watchPixelRatio() {
716
+ this.#stopPixelRatioWatch?.();
717
+ this.#stopPixelRatioWatch = null;
718
+ const view = this.#root.ownerDocument.defaultView;
719
+ if (this.#disposed || typeof view?.matchMedia !== "function") {
498
720
  return;
499
721
  }
500
- for (const surface of node.surfaces) {
501
- surface.polygon.toggleAttribute("data-hover", active);
722
+ const list = view.matchMedia(`(resolution: ${this.#readPixelRatio()}dppx)`);
723
+ const onChange = () => {
724
+ this.#watchPixelRatio();
725
+ this.resize();
726
+ };
727
+ if (typeof list.addEventListener === "function") {
728
+ list.addEventListener("change", onChange);
729
+ this.#stopPixelRatioWatch = () => list.removeEventListener("change", onChange);
730
+ } else if (typeof list.addListener === "function") {
731
+ list.addListener(onChange);
732
+ this.#stopPixelRatioWatch = () => list.removeListener(onChange);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Repaints after the renderer's WebGL context was restored: marks the cube dirty and asks the host for a frame.
738
+ * @private
739
+ * @returns {void}
740
+ */
741
+ #handleContextRestored() {
742
+ if (this.#disposed || !this.#renderer) {
743
+ return;
502
744
  }
503
- node.outline.toggleAttribute("data-hover", active);
745
+ this.#dirty = true;
746
+ this.#host.requestRender();
747
+ }
748
+
749
+ /**
750
+ * Converts pointer client coordinates into CSS pixels from the cube canvas top-left.
751
+ * @private
752
+ * @param {PointerEvent|MouseEvent} event - Canvas input event.
753
+ * @returns {{x:number,y:number}}
754
+ */
755
+ #toLocal(event) {
756
+ const rect = this.#canvas.getBoundingClientRect();
757
+ return { x: event.clientX - (rect.left ?? 0), y: event.clientY - (rect.top ?? 0) };
758
+ }
759
+
760
+ /**
761
+ * Resolves the region under a canvas point; null while the cube is display-only.
762
+ * @private
763
+ * @param {{x:number,y:number}} point - Canvas-local CSS pixel position.
764
+ * @returns {string|null}
765
+ */
766
+ #pick(point) {
767
+ return this.#isInteractive() && Number.isFinite(point.x + point.y) ? this.#renderer.pick(point.x, point.y) : null;
504
768
  }
505
769
 
506
770
  /**
507
- * Updates only the old/new region feedback attributes without rebuilding geometry.
508
- * Clears hover when disabled or locked; all surfaces of an edge or corner share the same state.
771
+ * Forwards hover to the renderer and repaints the cube right away, since hover changes outside the host loop.
772
+ * The cube has its own engine and scene, so the host's model scene is not re-rendered (no host.requestRender()).
773
+ * During sync() the cube is rendered once at its end instead. Clears hover when disabled, locked or unsupported.
509
774
  * @private
510
775
  * @param {string|null} id - Signed-axis region identifier, or null to remove hover.
511
776
  * @returns {void}
512
777
  */
513
778
  #setHover(id) {
514
- if (!this.#enabled || this.#host.isLocked(this.#host.getCamera())) {
779
+ if (!this.#isInteractive()) {
515
780
  id = null;
516
781
  }
517
782
  if (id === this.#hover) {
518
783
  return;
519
784
  }
520
-
521
- this.#setRegionHover(this.#hover, false);
522
785
  this.#hover = id;
523
- this.#setRegionHover(id, true);
786
+ this.#updateCursor();
787
+ this.#renderer?.setHover(id);
788
+ this.#dirty = true;
789
+ if (!this.#syncing) {
790
+ this.#renderDirty();
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Renders the cube when something visible changed. A renderer that skipped the frame (zero-size canvas) returns
796
+ * false: the cube stays dirty so a later size change or sync repaints.
797
+ * @private
798
+ * @returns {void}
799
+ */
800
+ #renderDirty() {
801
+ if (this.#enabled && this.#renderer && this.#dirty) {
802
+ this.#dirty = this.#renderer.render() === false;
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Builds the translated face labels in the renderer's key order.
808
+ * @private
809
+ * @param {string} [locale] - Localization culture.
810
+ * @returns {Object<string, string>}
811
+ */
812
+ #getLabels(locale) {
813
+ const labels = {};
814
+ for (const key of ["right", "left", "bottom", "top", "back", "front"]) {
815
+ labels[key] = I18n.translate(`prefViewer.viewCube.${key}`, { locale });
816
+ }
817
+ return labels;
818
+ }
819
+
820
+ /**
821
+ * Reads the primary colour from the root's computed style and derives the hover and current colours.
822
+ * Returns null when unchanged since the last call, when the last read is younger than COLOR_REFRESH_MS (unless
823
+ * forced or invalidated) or when no colour can be resolved; environments without getComputedStyle fall back to the
824
+ * configured style variable.
825
+ * @private
826
+ * @param {boolean} [force=false] - Reads now and returns the colours even when the source value did not change.
827
+ * @returns {{hover:string,current:string}|null}
828
+ */
829
+ #readColors(force = false) {
830
+ const now = this.#now();
831
+ if (!force && this.#colorsReadAt !== null && now - this.#colorsReadAt < COLOR_REFRESH_MS) {
832
+ return null;
833
+ }
834
+ this.#colorsReadAt = now;
835
+ let raw = "";
836
+ try {
837
+ raw = this.#root.ownerDocument.defaultView.getComputedStyle(this.#root).getPropertyValue(PRIMARY_COLOR_VARIABLE).trim();
838
+ } catch (_error) {}
839
+ raw ||= String(PrefViewerStyleVariables.colorPrimary ?? "").trim();
840
+ if (raw === this.#primaryRaw && !force) {
841
+ return null;
842
+ }
843
+ this.#primaryRaw = raw;
844
+ const hover = BabylonJSViewCubeController.#toHex(raw, this.#root) ?? BabylonJSViewCubeController.#toHex(PrefViewerStyleVariables.colorPrimary, this.#root);
845
+ return hover ? { hover, current: BabylonJSViewCubeController.#mix(CURRENT_FILL_COLOR, hover, CURRENT_MIX) } : null;
846
+ }
847
+
848
+ /**
849
+ * Compares the pose inputs with the last ones the cube was posed for and remembers them.
850
+ * @private
851
+ * @param {ArrayLike<number>} matrix - Camera view matrix elements.
852
+ * @param {number} mode - Camera projection mode.
853
+ * @param {boolean} rightHanded - Whether the scene is right-handed.
854
+ * @param {number} fov - Camera vertical field of view in radians.
855
+ * @returns {boolean} Whether anything differs from the previous call.
856
+ */
857
+ #updateGeometryKey(matrix, mode, rightHanded, fov) {
858
+ const key = this.#geometryKey;
859
+ if (key && key.mode === mode && key.rightHanded === rightHanded && key.fov === fov && key.matrix.every((value, i) => value === matrix[i])) {
860
+ return false;
861
+ }
862
+ this.#geometryKey = { matrix: Array.from(matrix), mode, rightHanded, fov };
863
+ return true;
864
+ }
865
+
866
+ /**
867
+ * Current time in milliseconds: the host clock when it provides one, otherwise the performance clock.
868
+ * @private
869
+ * @returns {number}
870
+ */
871
+ #now() {
872
+ return this.#host.now?.() ?? performance.now();
873
+ }
874
+
875
+ /**
876
+ * Creates the renderer once a camera exists while the cube is enabled.
877
+ * @private
878
+ * @param {string} [locale] - Localization culture used for the initial labels.
879
+ * @returns {void}
880
+ */
881
+ #ensureRenderer(locale) {
882
+ if (this.#renderer) {
883
+ return;
884
+ }
885
+ const options = { canvas: this.#canvas, labels: this.#getLabels(locale), colors: this.#readColors(true) ?? {}, devicePixelRatio: this.#readPixelRatio(), onContextRestored: () => this.#handleContextRestored() };
886
+ this.#renderer = this.#host.createRenderer ? this.#host.createRenderer(options) : new BabylonJSViewCubeRenderer(options);
887
+ this.#renderer.resize(this.#readPixelRatio());
888
+ this.#applyLocale(locale);
889
+ this.#geometryKey = null;
890
+ this.#dirty = true;
891
+ }
892
+
893
+ /**
894
+ * Remembers the culture and localizes the canvas accessible name.
895
+ * @private
896
+ * @param {string} [locale] - Localization culture.
897
+ * @returns {void}
898
+ */
899
+ #applyLocale(locale) {
900
+ this.#locale = locale;
901
+ this.#canvas.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
902
+ }
903
+
904
+ /**
905
+ * Disposes the renderer and forgets everything derived from it.
906
+ * @private
907
+ * @returns {void}
908
+ */
909
+ #disposeRenderer() {
910
+ this.#endPress();
911
+ this.#renderer?.dispose();
912
+ this.#renderer = null;
913
+ this.#hover = null;
914
+ this.#primaryRaw = null;
915
+ this.#colorsReadAt = null;
916
+ this.#geometryKey = null;
917
+ this.#canvas.style.cursor = "";
524
918
  }
525
919
 
526
920
  /**
@@ -535,7 +929,7 @@ export default class BabylonJSViewCubeController {
535
929
  /**
536
930
  * Returns the overlay root for host integration or inspection.
537
931
  * @public
538
- * @returns {HTMLDivElement} Root element containing styles and the SVG.
932
+ * @returns {HTMLDivElement} Root element containing the cube canvas.
539
933
  */
540
934
  get element() {
541
935
  return this.#root;
@@ -553,18 +947,20 @@ export default class BabylonJSViewCubeController {
553
947
  return;
554
948
  }
555
949
  this.#enabled = Boolean(enabled);
950
+ this.#colorsReadAt = null;
556
951
  this.#root.hidden = !this.#enabled;
557
952
  if (!this.#enabled) {
558
953
  this.cancel();
559
954
  this.#pointer = null;
560
955
  this.#setHover(null);
956
+ this.#disposeRenderer();
561
957
  }
562
958
  this.resize();
563
959
  this.sync();
564
960
  }
565
961
 
566
962
  /**
567
- * Fits the widget to a maximum of 100 x 100 CSS pixels inside the scene viewport and publishes its layout.
963
+ * Sizes the square widget from the scene viewport (see computeViewCubeSize), resizes the renderer and publishes its layout.
568
964
  * Cancels obsolete navigation and synchronizes the indicator without changing the camera pose.
569
965
  * @public
570
966
  * @returns {void}
@@ -574,10 +970,15 @@ export default class BabylonJSViewCubeController {
574
970
  return;
575
971
  }
576
972
  this.cancel();
973
+ this.#colorsReadAt = null;
577
974
  const viewport = this.#host.getViewport();
578
- const size = Math.max(0, Math.min(100, viewport.width, viewport.height));
975
+ const size = computeViewCubeSize(viewport.width, viewport.height);
579
976
  this.#root.style.width = `${size}px`;
580
977
  this.#root.style.height = `${size}px`;
978
+ if (this.#renderer) {
979
+ this.#renderer.resize(this.#readPixelRatio());
980
+ this.#dirty = true;
981
+ }
581
982
  this.#publishLayout();
582
983
  this.sync();
583
984
  }
@@ -656,10 +1057,12 @@ export default class BabylonJSViewCubeController {
656
1057
  }
657
1058
 
658
1059
  /**
659
- * Synchronizes projection, nearest-orientation feedback, labels and lock state from the active camera.
1060
+ * Synchronizes the 3D cube with the active camera: pose, nearest orientation, labels, colours and lock state.
660
1061
  * Rebinds the view-matrix observer on replacement and guards against recursive notifications.
661
- * Reprojects SVG geometry only for camera pose/projection changes; hover and localization reuse it.
662
- * The virtual cube camera follows orientation/FOV independently of the model zoom.
1062
+ * Creates the renderer lazily once a camera exists. The cube orientation follows the camera direction only,
1063
+ * independent of model zoom and projection. Locked, unsupported and XR cameras keep the cube following the
1064
+ * camera but make it display-only. Renders only when something visible changed; it is called by the host
1065
+ * render loop after the scene renders, and hover changes ask the host for a frame.
663
1066
  * @public
664
1067
  * @returns {void}
665
1068
  */
@@ -681,114 +1084,67 @@ export default class BabylonJSViewCubeController {
681
1084
  this.#locked = locked;
682
1085
  if (lockChanged) {
683
1086
  this.#root.dataset.locked = String(locked);
1087
+ this.#canvas.style.pointerEvents = locked ? "none" : "auto";
1088
+ this.#endPress();
1089
+ this.#dirty = true;
684
1090
  }
685
1091
  if (locked) {
686
1092
  this.#setHover(null);
687
1093
  }
1094
+ this.#updateCursor();
688
1095
  if (!camera?.getViewMatrix) {
689
- this.#svg.style.visibility = "hidden";
1096
+ this.#canvas.style.visibility = "hidden";
690
1097
  return;
691
1098
  }
692
- if (this.#svg.style.visibility !== "visible") {
693
- this.#svg.style.visibility = "visible";
1099
+ if (this.#canvas.style.visibility !== "visible") {
1100
+ this.#canvas.style.visibility = "visible";
694
1101
  }
695
1102
  const locale = this.#host.getCulture?.();
696
- const localeChanged = locale !== this.#locale || this.#geometryKey === null;
697
- const geometryKey = `${camera.getViewMatrix().m.join(",")}|${camera.mode}|${camera.fov}|${camera.getScene().useRightHandedSystem}`;
698
- const geometryChanged = geometryKey !== this.#geometryKey;
699
- if (!geometryChanged && !localeChanged && !lockChanged) {
700
- return;
1103
+ this.#ensureRenderer(locale);
1104
+ this.#updateCursor();
1105
+ const renderer = this.#renderer;
1106
+ if (locale !== this.#locale) {
1107
+ this.#applyLocale(locale);
1108
+ renderer.setLabels(this.#getLabels(locale));
1109
+ this.#colorsReadAt = null;
1110
+ this.#dirty = true;
1111
+ }
1112
+ const colors = this.#readColors();
1113
+ if (colors) {
1114
+ renderer.setColors(colors);
1115
+ this.#dirty = true;
701
1116
  }
702
- this.#geometryKey = geometryKey;
703
- this.#locale = locale;
704
- const pose = this.#readPose(camera);
705
- const view = this.#rotationMatrix(pose.rotation).invert();
706
1117
  const rightHanded = camera.getScene().useRightHandedSystem;
707
- const depthSign = rightHanded ? -1 : 1;
708
- const perspective = camera.mode !== 1;
709
- const fov = Number.isFinite(camera.fov) && camera.fov > 0 && camera.fov < Math.PI ? camera.fov : 0.8;
710
- // An isolated virtual eye follows the scene camera's orientation/FOV, not its model zoom.
711
- const eyeDistance = 1.8 + 1.8 / Math.tan(fov / 2);
712
- const projection = { view, perspective, depthSign, eyeDistance };
713
- if (geometryChanged) {
714
- for (const surface of this.#shell) {
715
- BabylonJSViewCubeController.#drawSurface(surface, projection);
1118
+ if (this.#updateGeometryKey(camera.getViewMatrix().m, camera.mode, rightHanded, camera.fov)) {
1119
+ const pose = this.#readPose(camera);
1120
+ const rotation = this.#rotationMatrix(pose.rotation);
1121
+ const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, rightHanded ? 1 : -1), rotation).normalize();
1122
+ // At the poles alpha is undefined and the horizontal part is numerical noise: snap it to
1123
+ // zero so the previous alpha is kept instead of spinning the cube arbitrarily.
1124
+ const horizontal = Math.hypot(towardObserver.x, towardObserver.z) < POLE_SNAP ? { x: 0, y: towardObserver.y, z: 0 } : towardObserver;
1125
+ const angles = anglesFromDirection(horizontal, this.#alpha);
1126
+ if (angles) {
1127
+ this.#alpha = angles.alpha;
1128
+ renderer.setPose({ ...angles, mode: camera.mode, fov: camera.fov });
716
1129
  }
717
- }
718
- const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, -depthSign), this.#rotationMatrix(pose.rotation)).normalize();
719
- const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
720
- if (geometryChanged) {
721
- this.#root.dataset.projection = perspective ? "perspective" : "orthographic";
1130
+ const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
1131
+ renderer.setCurrent(nearest.id);
1132
+ this.#root.dataset.projection = camera.mode === 1 ? "orthographic" : "perspective";
722
1133
  this.#root.dataset.currentRegion = nearest.id;
723
- }
724
- // Draw full faces first, then the coplanar edge prisms and corner cubes.
725
- // Visibility belongs to each physical face, not the semantic preset direction.
726
- if (localeChanged) {
727
- this.#svg.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
728
- }
729
- for (const r of this.#regions) {
730
- const node = this.#nodes.get(r.id);
731
- const parts = r.id.split(",").map(Number);
732
- const names = parts
733
- .map((s, i) =>
734
- s
735
- ? I18n.translate(
736
- `prefViewer.viewCube.${
737
- [
738
- ["right", "left"],
739
- ["bottom", "top"],
740
- ["back", "front"],
741
- ][i][s > 0 ? 1 : 0]
742
- }`,
743
- { locale },
744
- )
745
- : null,
746
- )
747
- .filter(Boolean);
748
- let visible = false;
749
- const outlineSegments = [];
750
- for (const surface of node.surfaces) {
751
- const { shown, points } = geometryChanged ? BabylonJSViewCubeController.#drawSurface(surface, projection) : { shown: surface.polygon.style.display !== "none", points: [] };
752
- visible ||= shown;
753
- if (lockChanged || geometryChanged) {
754
- surface.polygon.style.pointerEvents = locked ? "none" : "visiblePainted";
755
- }
756
- if (geometryChanged) {
757
- surface.polygon.toggleAttribute("data-current", r.id === nearest.id);
758
- }
759
- if (localeChanged) {
760
- surface.polygon.setAttribute("aria-label", names.join(" / "));
761
- }
762
- if (geometryChanged && shown) {
763
- outlineSegments.push(`M${points.map((p) => `${p.x},${p.y}`).join(" L")} Z`);
764
- }
765
- if (geometryChanged && r.kind === "face") {
766
- node.label.setAttribute("x", String(points.reduce((sum, v) => sum + v.x, 0) / points.length));
767
- node.label.setAttribute("y", String(points.reduce((sum, v) => sum + v.y, 0) / points.length));
768
- }
769
- }
770
- if (geometryChanged) {
771
- node.group.style.display = visible ? "" : "none";
772
- node.outline.setAttribute("d", outlineSegments.join(" "));
773
- }
774
- if (localeChanged) {
775
- node.label.textContent = r.kind === "face" ? names[0] : "";
776
- }
777
- }
778
- // Camera motion can move hit regions under a stationary mouse.
779
- if (geometryChanged && this.#pointer && Number.isFinite(this.#pointer.x + this.#pointer.y)) {
780
- const hit = this.#root.getRootNode().elementFromPoint?.(this.#pointer.x, this.#pointer.y);
781
- if (hit) {
782
- this.#setHover(this.#root.contains(hit) ? (hit.closest?.("polygon[data-region]")?.dataset.region ?? null) : null);
1134
+ this.#dirty = true;
1135
+ // Camera motion can move hit regions under a stationary pointer.
1136
+ if (this.#pointer) {
1137
+ this.#setHover(this.#pick(this.#pointer));
783
1138
  }
784
1139
  }
1140
+ this.#renderDirty();
785
1141
  } finally {
786
1142
  this.#syncing = false;
787
1143
  }
788
1144
  }
789
1145
 
790
1146
  /**
791
- * Idempotently cancels navigation and releases listeners, camera/resize observers and overlay DOM.
1147
+ * Idempotently cancels navigation and releases listeners, camera/resize observers, the renderer and overlay DOM.
792
1148
  * Reports zero layout footprint without disposing the host camera, scene or model assets.
793
1149
  * @public
794
1150
  * @returns {void}
@@ -802,8 +1158,11 @@ export default class BabylonJSViewCubeController {
802
1158
  this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
803
1159
  this.#resizeObserver?.disconnect();
804
1160
  this.#viewportResizeObserver?.disconnect();
1161
+ this.#stopPixelRatioWatch?.();
1162
+ this.#stopPixelRatioWatch = null;
805
1163
  this.#listeners.forEach((remove) => remove());
806
1164
  this.#listeners.length = 0;
1165
+ this.#disposeRenderer();
807
1166
  this.#root.remove();
808
1167
  this.#host.onLayout?.(0, 0);
809
1168
  }