@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
@@ -0,0 +1,775 @@
1
+ // Visual design adapted from the PrefSketch view cube (PrefSketch src/viewer/view-cube.ts): a 1-unit cube with
2
+ // 512 px labelled face cells, thin edge bars, corner spheres, a hemispheric light and the shared SVG interface palette.
3
+ // Geometry, lighting and painting are translated to this viewer's left-handed scene and region-per-mesh picking.
4
+ import { ArcRotateCamera, Camera, Color3, Color4, DirectionalLight, DynamicTexture, Engine, EngineStore, HemisphericLight, MeshBuilder, Quaternion, Ray, Scene, StandardMaterial, Texture, Vector3 } from "@babylonjs/core";
5
+ import { PrefViewerViewCubePalette, PrefViewerViewCubeTypography } from "./styles.js";
6
+ import { SSAO2RenderingPipeline } from "@babylonjs/core/PostProcesses/RenderPipeline/Pipelines/ssao2RenderingPipeline.js";
7
+ import "@babylonjs/core/Rendering/geometryBufferRendererSceneComponent.js";
8
+ import { FxaaPostProcess } from "@babylonjs/core/PostProcesses/fxaaPostProcess.js";
9
+ import BabylonJSViewCubeCanvasOrbitMath from "./babylonjs-view-cube-canvas-orbit-math.js";
10
+
11
+ /**
12
+ * Renders the 3D view cube on its own Babylon.js engine and scene, bound to a small canvas.
13
+ *
14
+ * The scene is left-handed and Y-up like the viewer scene and its ArcRotateCamera (orthographic or perspective, following
15
+ * the host camera) uses the host camera's alpha/beta unchanged, so no axis mirroring is needed. Every pickable mesh carries
16
+ * `metadata.regionId` equal to a VIEW_CUBE_DIRECTIONS id. The class owns no render loop or interaction logic.
17
+ * Geometry constants and picking helpers belong to this class; the default palette comes from styles.js.
18
+ * Surface emission is disabled for this lighting-only trial; transparent text planes retain independent unlit shading.
19
+ * All 26 surfaces use diffuseColor consistently; six non-pickable transparent text planes carry the shared SVG labels.
20
+ * Text planes are unlit for legibility, depth-tested against the cube and disposed with the owned scene.
21
+ * Hemispheric lighting is supplemented by a weak diffuse-only upward light for the lower surfaces.
22
+ * A rotation-invariant bounding sphere tightly frames every mesh with 2% padding in either projection.
23
+ * An optional lightweight SSAO trial shades contacts using only solid surfaces; final FXAA preserves the alpha output.
24
+ * SSAO and antialiasing resources stay inside this private scene and are released on disposal.
25
+ * The host calls render() and interprets pick() results. GPU readiness requests a host repaint even when idle.
26
+ * It touches neither
27
+ * `window` nor `document` itself, so it can be imported in any environment.
28
+ */
29
+ export default class BabylonJSViewCubeCanvasRenderer {
30
+ static #CUBE_HALF = 0.5;
31
+ static #EDGE_THICKNESS = 0.09;
32
+ static #EDGE_LENGTH = 1 + BabylonJSViewCubeCanvasRenderer.#EDGE_THICKNESS;
33
+ static #CORNER_DIAMETER = 0.26;
34
+ static #CORNER_SEGMENTS = 8;
35
+ // PrefSketch light direction is (0.4, 1, 0.6) in a right-handed scene; X is mirrored for this left-handed one.
36
+ static #LIGHT_DIRECTION = new Vector3(-0.4, 1, 0.6);
37
+ static #LIGHT_INTENSITY = 1.0;
38
+ /** Support rays travel upward and diagonally to illuminate Bottom (-Y), Left (+X) and Outer (-Z). */
39
+ static #BOTTOM_LIGHT_DIRECTION = new Vector3(-0.4, 1, 0.4);
40
+ /** Low diffuse-only support, independent of hemispheric fill and surface emission. */
41
+ static #BOTTOM_LIGHT_INTENSITY = 0.16;
42
+ /** Reduces light specular strength by 25% to soften broad highlights, most apparent in Top. */
43
+ static #LIGHT_SPECULAR_COLOR = new Color3(0.75, 0.75, 0.75);
44
+ /** Shared neutral reflection strength for every face, edge and corner material. */
45
+ static #SPECULAR_COLOR = new Color3(0.5, 0.5, 0.5);
46
+ /** Highlight concentration for face and edge surface materials. */
47
+ static #SPECULAR_POWER = 64;
48
+ /** Lower concentration broadens the highlights specifically on corner spheres. */
49
+ static #CORNER_SPECULAR_POWER = 32;
50
+ /** Fraction of surface color emitted independently of lighting; zero for the current lighting-only trial. */
51
+ static #EMISSIVE_STRENGTH = 0;
52
+ /** Neutral lower fill restores moderate hemispheric contrast and makes the cube volume easier to read. */
53
+ static #LIGHT_GROUND_COLOR = new Color3(0.75, 0.75, 0.75);
54
+ /** Frontal SVG face width: two units projected at 31 reference pixels per unit. */
55
+ static #SVG_FACE_WIDTH = 62;
56
+ /** Small rasterization margin around the rotation-invariant geometry envelope. */
57
+ static #FRAME_PADDING = 1.02;
58
+ static #CAMERA_RADIUS = 5;
59
+ /** Numerical pole offset only; unlike drag limits, it must not visibly tilt top/bottom faces. */
60
+ static #POLE_RENDER_MARGIN = 1e-7;
61
+ static #DEFAULT_FOV = 0.8;
62
+ // Far-plane margin exceeds the full radius including the outer corner sphere surfaces.
63
+ static #PERSPECTIVE_DEPTH_MARGIN = 2;
64
+ // Distance from the cube centre to a corner sphere centre (half the cube diagonal).
65
+ static #CORNER_DISTANCE = Math.sqrt(3) / 2;
66
+ /** All faces, edge bars and corner spheres fit inside this sphere for every camera orientation. */
67
+ static #BOUNDING_RADIUS = BabylonJSViewCubeCanvasRenderer.#CORNER_DISTANCE + BabylonJSViewCubeCanvasRenderer.#CORNER_DIAMETER / 2;
68
+ /** Supersampled label texture side; typography scales proportionally to preserve its apparent size. */
69
+ static #TEXTURE_SIZE = 512;
70
+ /** Improves label definition on oblique faces; Babylon clamps this to the GPU capability. */
71
+ static #LABEL_ANISOTROPY = 8;
72
+ /** Small outward offset separates label planes from their solid parent faces without visible parallax. */
73
+ static #LABEL_SURFACE_OFFSET = 0.0005;
74
+ // Hits farther than this behind the nearest one are other sides of the cube, not overlapping parts.
75
+ static #PICK_DEPTH_TOLERANCE = 0.15;
76
+ // Screen-space pick tolerance, in CSS pixels: an edge or corner whose projected centre is this close to the pointer
77
+ // wins over the face under it, because the drawn edge bars (~4 px) and corner spheres (~11 px) are below the 24 px
78
+ // WCAG 2.2 minimum target size. It is capped at PICK_TOLERANCE_PX and shrinks with the cube (see computePickTolerance)
79
+ // so that the tolerance discs of the edge and corner centres never swallow the faces of a small cube.
80
+ static #PICK_TOLERANCE_PX = 8;
81
+ // 1/12 of the cube side: exactly 8 px for the 96 px minimum cube and 5 px for a 60 px one, where the face centre is
82
+ // only ~13 px away from the nearest edge centre.
83
+ static #PICK_TOLERANCE_SIDE_RATIO = 1 / 12;
84
+ // Minimum dot product between a region's direction and the direction towards the camera for the region to count as
85
+ // front-facing (about 78 degrees from the view axis). Edge-on regions on the silhouette stay out of tolerance picking.
86
+ static #FRONT_FACING_MIN_DOT = 0.2;
87
+
88
+
89
+ /**
90
+ * Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose). Babylon only calls
91
+ * WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL context
92
+ * would linger until garbage collection, and browsers cap the number of live contexts per page.
93
+ */
94
+ static #VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true });
95
+
96
+ static #DEFAULT_LABELS = Object.freeze({ right: "Right", left: "Left", bottom: "Bottom", top: "Top", back: "Back", front: "Front" });
97
+
98
+ // Label key per signed axis; matches the SVG cube: +X is "left", +Y is "top", +Z is "front".
99
+ static #FACE_LABEL_KEYS = Object.freeze({ "1,0,0": "left", "-1,0,0": "right", "0,1,0": "top", "0,-1,0": "bottom", "0,0,1": "front", "0,0,-1": "back" });
100
+
101
+ /** Experimental SSAO is localized here so the visual trial can be switched off independently. */
102
+ static #SSAO_ENABLED = true;
103
+ static #SSAO_SETTINGS = Object.freeze({ samples: 8, ratio: 0.5, radius: 0.12, strength: 0.6 });
104
+
105
+ #ambientOcclusion;
106
+ #antiAliasing;
107
+ #canvas;
108
+ #engine;
109
+ #scene;
110
+ #camera;
111
+ #mode = Camera.ORTHOGRAPHIC_CAMERA;
112
+ #fov = BabylonJSViewCubeCanvasRenderer.#DEFAULT_FOV;
113
+ #colors;
114
+ #labels;
115
+ #devicePixelRatio;
116
+ #regions = new Map();
117
+ #state = { hover: null, current: null };
118
+ #labelStyle;
119
+ #disposed = false;
120
+ #contextRestoredObserver = null;
121
+
122
+ /**
123
+ * Creates the engine, isolated scene, optional SSAO pipeline, final FXAA pass, 26 lit surfaces and six label layers.
124
+ * @param {Object} options - Renderer options.
125
+ * @param {HTMLCanvasElement} options.canvas - Canvas the cube is drawn on; expected to be square.
126
+ * @param {function(HTMLCanvasElement): Engine} [options.createEngine] - Engine factory; defaults to an Engine built with VIEW_CUBE_ENGINE_OPTIONS (antialiased, alpha-enabled, releases its WebGL context on dispose). Tests inject a NullEngine.
127
+ * @param {Object<string, string>} [options.labels] - Face labels keyed right, left, bottom, top, back, front.
128
+ * @param {object} [options.colors] - Hex colors: fill, text, edge, corner, hover and current; border is used only by SVG; hoverOpacity/currentOpacity are numeric 0..1.
129
+ * @param {number} [options.devicePixelRatio=1] - Pixel ratio used by resize(); never read from `window`.
130
+ * @param {string} [options.fontFamily] - Optional font-family override for internal hosts.
131
+ * @param {object} [options.labelStyle] - Shared SVG typography: fontFamily, fontSize and letterSpacing in reference pixels, fontWeight and textTransform.
132
+ * @param {function(): void} [options.onReady] - Requests a repaint after initial shader/texture readiness; ignored after disposal.
133
+ * @param {function(): void} [options.onContextRestored] - Called after the cube's own WebGL context was restored; the host should repaint.
134
+ */
135
+ constructor({ canvas, createEngine, labels, colors, devicePixelRatio, fontFamily, labelStyle, onContextRestored, onReady }) {
136
+ this.#canvas = canvas;
137
+ this.#colors = { ...PrefViewerViewCubePalette.colors, ...colors };
138
+ this.#labels = { ...BabylonJSViewCubeCanvasRenderer.#DEFAULT_LABELS, ...labels };
139
+ this.#devicePixelRatio = devicePixelRatio > 0 ? devicePixelRatio : 1;
140
+ this.#labelStyle = { ...PrefViewerViewCubeTypography.defaults, ...labelStyle, ...(fontFamily ? { fontFamily } : {}) };
141
+ this.#engine = createEngine ? createEngine(canvas) : new Engine(canvas, true, { ...BabylonJSViewCubeCanvasRenderer.#VIEW_CUBE_ENGINE_OPTIONS });
142
+ // Second line of defence: if Babylon's input manager were ever attached, it would write engine.canvasTabIndex
143
+ // (default 1) to the canvas on every pointer move and make it keyboard-focusable. -1 keeps it out of the tab order.
144
+ this.#engine.canvasTabIndex = -1;
145
+ this.#contextRestoredObserver = this.#engine.onContextRestoredObservable?.add(() => {
146
+ if (!this.#disposed) {
147
+ onContextRestored?.();
148
+ }
149
+ });
150
+ this.#engine.setHardwareScalingLevel(1 / this.#devicePixelRatio);
151
+ // Babylon resolves objects created without an explicit scene or engine (textures, IBL filtering, ...) through
152
+ // EngineStore.LastCreatedScene and EngineStore.LastCreatedEngine (the last entry of EngineStore.Instances).
153
+ // The cube must stay out of both, or the main viewer would create resources on the cube's WebGL context
154
+ // ("object does not belong to this context"). A virtual scene never becomes LastCreatedScene, and the engine
155
+ // is unregistered right after creation. The host calls render() itself, so neither registry is needed.
156
+ this.#unregisterEngine();
157
+ this.#scene = new Scene(this.#engine, { virtual: true });
158
+ // The cube needs no Babylon input: the controller handles pointers itself and picks with an explicit ray. Even a
159
+ // virtual scene attaches its InputManager to the canvas when a `window` exists, which would reset the cursor on
160
+ // every move, call preventDefault() and focus() on press (stealing keyboard focus from the host canvas) and
161
+ // capture the pointer a second time. Detach it, and keep the flags as a guard in case something re-attaches it.
162
+ this.#scene.detachControl();
163
+ this.#scene.doNotHandleCursors = true;
164
+ this.#scene.preventDefaultOnPointerDown = false;
165
+ this.#scene.preventDefaultOnPointerUp = false;
166
+ this.#scene.clearColor = new Color4(0, 0, 0, 0);
167
+ this.#camera = this.#createCamera();
168
+ this.#createLight();
169
+ this.#createRegions();
170
+ this.#createAmbientOcclusion();
171
+ this.#antiAliasing = new FxaaPostProcess("view-cube-fxaa", 1, this.#camera, undefined, this.#engine);
172
+ if (onReady) {
173
+ this.#scene.executeWhenReady(() => {
174
+ if (!this.#disposed) {
175
+ onReady();
176
+ }
177
+ });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The Babylon scene, exposed for hosts and tests that need to inspect it.
183
+ * @returns {Scene}
184
+ */
185
+ get scene() {
186
+ return this.#scene;
187
+ }
188
+
189
+ /**
190
+ * Orients the cube like the host camera and follows its projection. An orthographic host gives an orthographic
191
+ * cube fitted to the full geometry envelope. Perspective uses the host vertical FOV and the smallest
192
+ * rotation-invariant sphere framing, with a 2% safety margin in both modes.
193
+ * Top/bottom synchronization uses a numerical pole offset rather than the separate user-drag safety limit.
194
+ * @param {{alpha: number, beta: number, mode?: number, fov?: number}} pose - Host camera angles in radians (beta uses only a 1e-7 rad numerical pole offset), projection mode (Camera.ORTHOGRAPHIC_CAMERA or Camera.PERSPECTIVE_CAMERA; absent keeps the last one) and vertical field of view in radians (absent or outside the finite (0, PI) range keeps the last one, 0.8 initially).
195
+ * @returns {void}
196
+ */
197
+ setPose({ alpha, beta, mode, fov }) {
198
+ if (this.#disposed) {
199
+ return;
200
+ }
201
+ const camera = this.#camera;
202
+ if (mode === Camera.ORTHOGRAPHIC_CAMERA || mode === Camera.PERSPECTIVE_CAMERA) {
203
+ this.#mode = mode;
204
+ }
205
+ if (typeof fov === "number" && Number.isFinite(fov) && fov > 0 && fov < Math.PI) {
206
+ this.#fov = fov;
207
+ }
208
+ this.#applyProjection(camera);
209
+ camera.alpha = alpha;
210
+ camera.beta = Math.min(Math.max(beta, BabylonJSViewCubeCanvasRenderer.#POLE_RENDER_MARGIN), Math.PI - BabylonJSViewCubeCanvasRenderer.#POLE_RENDER_MARGIN);
211
+ camera.getViewMatrix(true);
212
+ }
213
+
214
+ /**
215
+ * Finds the region under a point. Coordinates are CSS pixels from the canvas top-left; they are scaled by
216
+ * the canvas client size (falling back to the render size when the client size is unknown) and cast as an
217
+ * ray (parallel for an orthographic cube, from the camera for a perspective one), so the result does not depend on hardware scaling. Corners win over edges and edges over
218
+ * faces when they overlap on screen. A front-facing edge or corner whose projected centre is within the pick tolerance
219
+ * of the pointer wins over the face under it (corners before edges, then the nearest centre). The tolerance is
220
+ * min(8 CSS px, 1/12 of the cube side), so small cubes keep their face centres pickable. It also rescues a ray that
221
+ * misses the cube: a pointer just outside the silhouette but within tolerance of a front-facing edge or corner
222
+ * centre returns that region, which makes the thin outer bars and corner spheres easier to hit; hover and click
223
+ * both go through pick(), so they stay consistent. Back-facing and edge-on regions are never chosen this way, and a
224
+ * point outside the cube farther than the tolerance from every front-facing centre still returns null.
225
+ * @param {number} x - Horizontal position in CSS pixels.
226
+ * @param {number} y - Vertical position in CSS pixels.
227
+ * @returns {string|null} Region id such as "0,0,1", "1,0,1" or "1,1,1", or null outside the cube.
228
+ */
229
+ pick(x, y) {
230
+ if (this.#disposed) {
231
+ return null;
232
+ }
233
+ const { width, height } = this.#getCssSize();
234
+ if (!(width > 0 && height > 0)) {
235
+ return null;
236
+ }
237
+ const camera = this.#camera;
238
+ camera.getViewMatrix(true);
239
+ const forward = camera.target.subtract(camera.position).normalize();
240
+ const right = Vector3.Cross(camera.upVector, forward).normalize();
241
+ const up = Vector3.Cross(forward, right);
242
+ const perspective = camera.mode === Camera.PERSPECTIVE_CAMERA;
243
+ // Half extents of the view plane through the cube centre: fixed for orthographic, radius * tan(fov / 2) for perspective.
244
+ const halfHeight = perspective ? camera.radius * Math.tan(camera.fov / 2) : (camera.orthoTop - camera.orthoBottom) / 2;
245
+ const halfWidth = perspective ? halfHeight * (width / height) : (camera.orthoRight - camera.orthoLeft) / 2;
246
+ const offset = right.scale((x / width * 2 - 1) * halfWidth).add(up.scale((1 - y / height * 2) * halfHeight));
247
+ const ray = perspective
248
+ ? new Ray(camera.position, forward.scale(camera.radius).add(offset).normalize(), 100)
249
+ : new Ray(camera.position.add(offset), forward, 100);
250
+ const hits = this.#scene
251
+ .multiPickWithRay(ray, (mesh) => Boolean(mesh.metadata?.regionId))
252
+ .filter((hit) => hit.hit)
253
+ .sort((a, b) => a.distance - b.distance);
254
+ const near = this.#pickByTolerance(x, y, width, height, forward, BabylonJSViewCubeCanvasRenderer.#computePickTolerance(Math.min(width, height)));
255
+ if (near) {
256
+ return near;
257
+ }
258
+ if (!hits.length) {
259
+ return null;
260
+ }
261
+ const front = hits.filter((hit) => hit.distance - hits[0].distance <= BabylonJSViewCubeCanvasRenderer.#PICK_DEPTH_TOLERANCE);
262
+ const rank = (hit) => hit.pickedMesh.metadata.rank;
263
+ return front.reduce((best, hit) => (rank(hit) > rank(best) ? hit : best)).pickedMesh.metadata.regionId;
264
+ }
265
+
266
+ /**
267
+ * Sets the hovered region, shown with the hover colour. Hover wins over current.
268
+ * @param {string|null} regionId - Region id, or null to clear.
269
+ * @returns {void}
270
+ */
271
+ setHover(regionId) {
272
+ this.#setState("hover", regionId);
273
+ }
274
+
275
+ /**
276
+ * Sets the region that matches the current view, shown with the current colour.
277
+ * @param {string|null} regionId - Region id, or null to clear.
278
+ * @returns {void}
279
+ */
280
+ setCurrent(regionId) {
281
+ this.#setState("current", regionId);
282
+ }
283
+
284
+ /**
285
+ * Merges colours into the palette and repaints every region; meshes are kept.
286
+ * @param {object} colors - Partial hex palette and hoverOpacity/currentOpacity values in 0..1.
287
+ * @returns {void}
288
+ */
289
+ setColors(colors) {
290
+ if (this.#disposed) {
291
+ return;
292
+ }
293
+ this.#colors = { ...this.#colors, ...colors };
294
+ this.#paintAll();
295
+ }
296
+
297
+ /**
298
+ * Applies resolved SVG typography and repaints existing face textures only when it changes.
299
+ * Font size and letter spacing are converted from the SVG face width to texture pixels during painting.
300
+ * @param {object} style - Font family, size, weight, spacing and textTransform in SVG reference units.
301
+ * @returns {void}
302
+ */
303
+ setLabelStyle(style) {
304
+ if (this.#disposed) {
305
+ return;
306
+ }
307
+ const next = { ...this.#labelStyle, ...style };
308
+ if (JSON.stringify(next) === JSON.stringify(this.#labelStyle)) {
309
+ return;
310
+ }
311
+ this.#labelStyle = next;
312
+ this.#paintAll();
313
+ }
314
+
315
+ /**
316
+ * Merges face labels and repaints the face textures; meshes are kept.
317
+ * @param {Object<string, string>} labels - Partial labels keyed right, left, bottom, top, back, front.
318
+ * @returns {void}
319
+ */
320
+ setLabels(labels) {
321
+ if (this.#disposed) {
322
+ return;
323
+ }
324
+ this.#labels = { ...this.#labels, ...labels };
325
+ this.#paintAll();
326
+ }
327
+
328
+ /**
329
+ * Returns the colour a region is currently painted with (face fill, edge, corner, hover or current).
330
+ * @param {string} regionId - Region id.
331
+ * @returns {string|null} "#rrggbb" colour, or null for an unknown region.
332
+ */
333
+ getRegionColor(regionId) {
334
+ return this.#regions.has(regionId) ? this.#resolveColor(this.#regions.get(regionId)) : null;
335
+ }
336
+
337
+ /**
338
+ * Projects a region centre with the current camera.
339
+ * @param {string} regionId - Region id.
340
+ * @returns {{x: number, y: number}|null} Position in CSS pixels from the canvas top-left, or null for an unknown region or after dispose.
341
+ */
342
+ getRegionScreenPosition(regionId) {
343
+ const region = this.#regions.get(regionId);
344
+ if (this.#disposed || !region) {
345
+ return null;
346
+ }
347
+ const { width, height } = this.#getCssSize();
348
+ const camera = this.#camera;
349
+ const viewProjection = camera.getViewMatrix(true).multiply(camera.getProjectionMatrix(true));
350
+ const ndc = Vector3.TransformCoordinates(region.mesh.position, viewProjection);
351
+ return { x: (ndc.x * 0.5 + 0.5) * width, y: (0.5 - ndc.y * 0.5) * height };
352
+ }
353
+
354
+ /**
355
+ * Applies canvas size, hardware scaling and aspect-aware cube framing after a viewport resize.
356
+ * @param {number} [devicePixelRatio] - Current pixel ratio; when valid (> 0) it replaces the stored one, otherwise the stored one is kept.
357
+ * @returns {void}
358
+ */
359
+ resize(devicePixelRatio) {
360
+ if (this.#disposed) {
361
+ return;
362
+ }
363
+ if (devicePixelRatio > 0) {
364
+ this.#devicePixelRatio = devicePixelRatio;
365
+ }
366
+ this.#engine.setHardwareScalingLevel(1 / this.#devicePixelRatio);
367
+ this.#engine.resize();
368
+ this.#applyProjection(this.#camera);
369
+ }
370
+
371
+ /**
372
+ * Renders one frame; the host render loop calls this. No-op after dispose or for a zero-size canvas.
373
+ * @returns {boolean} Whether a frame was drawn; false means the host should keep its dirty flag and retry later.
374
+ */
375
+ render() {
376
+ if (this.#disposed || this.#engine.getRenderWidth() === 0 || this.#engine.getRenderHeight() === 0) {
377
+ return false;
378
+ }
379
+ this.#scene.render();
380
+ return true;
381
+ }
382
+
383
+ /**
384
+ * Releases the experimental SSAO pipeline, cube FXAA pass, scene, textures and engine. Safe to call more than once; host graphics are untouched.
385
+ * @returns {void}
386
+ */
387
+ dispose() {
388
+ if (this.#disposed) {
389
+ return;
390
+ }
391
+ this.#disposed = true;
392
+ this.#engine.onContextRestoredObservable?.remove(this.#contextRestoredObserver);
393
+ this.#contextRestoredObserver = null;
394
+ this.#regions.clear();
395
+ if (this.#ambientOcclusion) {
396
+ this.#ambientOcclusion.dispose();
397
+ this.#ambientOcclusion = null;
398
+ this.#scene.disableGeometryBufferRenderer();
399
+ }
400
+ this.#antiAliasing.dispose(this.#camera);
401
+ this.#scene.dispose();
402
+ // Engine.dispose() tolerates a missing entry, but it also tears down shared state (such as the audio engine)
403
+ // when it believes it is the last registered engine. Register it again so it disposes like any other engine.
404
+ if (!EngineStore.Instances.includes(this.#engine)) {
405
+ EngineStore.Instances.push(this.#engine);
406
+ }
407
+ this.#engine.dispose();
408
+ }
409
+
410
+ /**
411
+ * Pick tolerance for a cube drawn with the given side: min(PICK_TOLERANCE_PX, PICK_TOLERANCE_SIDE_RATIO * side).
412
+ * @param {number} side - Rendered cube side in CSS pixels.
413
+ * @returns {number} Tolerance radius in CSS pixels.
414
+ */
415
+ static #computePickTolerance(side) {
416
+ return Math.min(BabylonJSViewCubeCanvasRenderer.#PICK_TOLERANCE_PX, BabylonJSViewCubeCanvasRenderer.#PICK_TOLERANCE_SIDE_RATIO * side);
417
+ }
418
+
419
+
420
+ /**
421
+ * Removes this private engine from Babylon global engine discovery without disposing it.
422
+ * @returns {void}
423
+ */
424
+ #unregisterEngine() {
425
+ const index = EngineStore.Instances.indexOf(this.#engine);
426
+ if (index >= 0) {
427
+ EngineStore.Instances.splice(index, 1);
428
+ }
429
+ }
430
+
431
+ // Best front-facing edge or corner within `tolerance` CSS px of the pointer: highest rank first, then nearest centre.
432
+ /**
433
+ * Selects the nearest highest-rank front-facing edge or corner inside a screen-space tolerance.
434
+ * @param {number} x - Pointer X in CSS pixels.
435
+ * @param {number} y - Pointer Y in CSS pixels.
436
+ * @param {number} width - Canvas CSS width.
437
+ * @param {number} height - Canvas CSS height.
438
+ * @param {Vector3} forward - Unit camera forward direction.
439
+ * @param {number} tolerance - Maximum pointer distance in CSS pixels.
440
+ * @returns {?string} Region identifier, or null when no eligible region is nearby.
441
+ */
442
+ #pickByTolerance(x, y, width, height, forward, tolerance) {
443
+ const camera = this.#camera;
444
+ const viewProjection = camera.getViewMatrix(true).multiply(camera.getProjectionMatrix(true));
445
+ let best = null;
446
+ for (const region of this.#regions.values()) {
447
+ if (region.kind < 2) {
448
+ continue;
449
+ }
450
+ const length = Math.hypot(...region.axes);
451
+ const facing = -(region.axes[0] * forward.x + region.axes[1] * forward.y + region.axes[2] * forward.z) / length;
452
+ if (facing < BabylonJSViewCubeCanvasRenderer.#FRONT_FACING_MIN_DOT) {
453
+ continue;
454
+ }
455
+ const ndc = Vector3.TransformCoordinates(region.mesh.position, viewProjection);
456
+ const distance = Math.hypot((ndc.x * 0.5 + 0.5) * width - x, (0.5 - ndc.y * 0.5) * height - y);
457
+ if (distance > tolerance) {
458
+ continue;
459
+ }
460
+ if (!best || region.kind > best.kind || (region.kind === best.kind && distance < best.distance)) {
461
+ best = { id: region.id, kind: region.kind, distance };
462
+ }
463
+ }
464
+ return best?.id ?? null;
465
+ }
466
+
467
+ /**
468
+ * Fits the rotation-invariant bounding sphere, including corner spheres, with a 2% rasterization margin.
469
+ * Orthographic extents and perspective tangent framing use the narrower viewport dimension; the host FOV
470
+ * is retained. Framing does not change while orbiting, so the cube never pulses or clips during rotation.
471
+ * Near/far planes enclose the complete geometry, even for a very wide perspective field of view.
472
+ * @param {ArcRotateCamera} camera - Owned cube camera to update; the host camera is untouched.
473
+ * @returns {void}
474
+ */
475
+ #applyProjection(camera) {
476
+ const { width, height } = this.#getCssSize();
477
+ const aspect = width > 0 && height > 0 ? width / height : 1;
478
+ const radius = BabylonJSViewCubeCanvasRenderer.#BOUNDING_RADIUS;
479
+ const extent = radius * BabylonJSViewCubeCanvasRenderer.#FRAME_PADDING;
480
+ if (this.#mode === Camera.PERSPECTIVE_CAMERA) {
481
+ camera.mode = Camera.PERSPECTIVE_CAMERA;
482
+ camera.fovMode = Camera.FOVMODE_VERTICAL_FIXED;
483
+ camera.fov = this.#fov;
484
+ const tangent = Math.tan(this.#fov / 2) * Math.min(1, aspect) / BabylonJSViewCubeCanvasRenderer.#FRAME_PADDING;
485
+ camera.radius = radius * Math.sqrt(1 + 1 / (tangent * tangent));
486
+ camera.minZ = Math.min(0.1, (camera.radius - radius) / 2);
487
+ camera.maxZ = camera.radius + BabylonJSViewCubeCanvasRenderer.#PERSPECTIVE_DEPTH_MARGIN;
488
+ return;
489
+ }
490
+ camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
491
+ camera.radius = BabylonJSViewCubeCanvasRenderer.#CAMERA_RADIUS;
492
+ camera.orthoLeft = -extent * Math.max(1, aspect);
493
+ camera.orthoRight = extent * Math.max(1, aspect);
494
+ camera.orthoBottom = -extent / Math.min(1, aspect);
495
+ camera.orthoTop = extent / Math.min(1, aspect);
496
+ camera.minZ = 0.1;
497
+ camera.maxZ = BabylonJSViewCubeCanvasRenderer.#CAMERA_RADIUS * 4;
498
+ }
499
+
500
+ /**
501
+ * Creates the detached-input cube camera, frames the geometry and permits pole views with only a numerical offset.
502
+ * Explicit beta limits prevent Babylon from imposing its default visible tilt during scene updates.
503
+ * @returns {ArcRotateCamera} Camera disposed with the owned scene.
504
+ */
505
+ #createCamera() {
506
+ const camera = new ArcRotateCamera("view-cube-camera", Math.PI / 2, Math.PI / 2, BabylonJSViewCubeCanvasRenderer.#CAMERA_RADIUS, Vector3.Zero(), this.#scene);
507
+ camera.inputs.clear();
508
+ camera.lowerBetaLimit = BabylonJSViewCubeCanvasRenderer.#POLE_RENDER_MARGIN;
509
+ camera.upperBetaLimit = Math.PI - BabylonJSViewCubeCanvasRenderer.#POLE_RENDER_MARGIN;
510
+ this.#applyProjection(camera);
511
+ this.#scene.activeCamera = camera;
512
+ return camera;
513
+ }
514
+
515
+ /**
516
+ * Enables the lightweight SSAO visual trial when this private engine supports the required buffers.
517
+ * Own-engine checks avoid SSAO2.IsSupported, which reads Babylon's global engine registry and can inspect
518
+ * the host model engine instead. Unsupported engines (including NullEngine) keep the normal cube rendering.
519
+ * Half-resolution geometry excludes transparent labels; the composite retains scene alpha and final FXAA
520
+ * runs afterward. The host's demand-render loop remains the only frame owner. dispose() releases the pipeline
521
+ * and its dedicated geometry buffer before the rest of the scene. No main-scene resources are changed.
522
+ * @returns {void}
523
+ */
524
+ #createAmbientOcclusion() {
525
+ const caps = this.#engine.getCaps();
526
+ if (!BabylonJSViewCubeCanvasRenderer.#SSAO_ENABLED || !this.#engine._features?.supportSSAO2
527
+ || !caps.drawBuffersExtension || !(caps.textureFloatRender || caps.textureHalfFloatRender)) {
528
+ return;
529
+ }
530
+ const settings = BabylonJSViewCubeCanvasRenderer.#SSAO_SETTINGS;
531
+ const geometry = this.#scene.enableGeometryBufferRenderer(settings.ratio);
532
+ if (!geometry?.isSupported) {
533
+ this.#scene.disableGeometryBufferRenderer();
534
+ return;
535
+ }
536
+ geometry.renderList = [...this.#regions.values()].map(region => region.mesh);
537
+ geometry.renderTransparentMeshes = false;
538
+ const pipeline = new SSAO2RenderingPipeline("view-cube-ssao", this.#scene,
539
+ { ssaoRatio: settings.ratio, blurRatio: 1 }, [this.#camera], geometry);
540
+ pipeline.samples = settings.samples;
541
+ pipeline.radius = settings.radius;
542
+ pipeline.totalStrength = settings.strength;
543
+ pipeline.textureSamples = 1;
544
+ this.#ambientOcclusion = pipeline;
545
+ }
546
+
547
+ /**
548
+ * Adds hemispheric illumination with neutral 75% lower fill for moderate surface contrast.
549
+ * Light specular strength is reduced to soften Top highlights while preserving diffuse fill and material settings.
550
+ * A weak tilted lower directional light adds diffuse illumination and white specular highlights to Bottom, Left and Outer,
551
+ * preserving the hemispheric fill and upper-face illumination. Scene disposal releases both lights.
552
+ * @returns {void}
553
+ */
554
+ #createLight() {
555
+ const light = new HemisphericLight("view-cube-light", BabylonJSViewCubeCanvasRenderer.#LIGHT_DIRECTION.clone(), this.#scene);
556
+ light.intensity = BabylonJSViewCubeCanvasRenderer.#LIGHT_INTENSITY;
557
+ light.specular = BabylonJSViewCubeCanvasRenderer.#LIGHT_SPECULAR_COLOR.clone();
558
+ light.groundColor = BabylonJSViewCubeCanvasRenderer.#LIGHT_GROUND_COLOR.clone();
559
+ const bottomLight = new DirectionalLight("view-cube-bottom-light", BabylonJSViewCubeCanvasRenderer.#BOTTOM_LIGHT_DIRECTION.clone(), this.#scene);
560
+ bottomLight.intensity = BabylonJSViewCubeCanvasRenderer.#BOTTOM_LIGHT_INTENSITY;
561
+ bottomLight.diffuse = Color3.White();
562
+ bottomLight.specular = Color3.White();
563
+ }
564
+
565
+ /**
566
+ * Creates 26 pickable lit surfaces with broader highlights on corner spheres plus six transparent label layers, then paints the palette.
567
+ * @returns {void} Resources are owned by this renderer and its scene.
568
+ */
569
+ #createRegions() {
570
+ for (const { id } of BabylonJSViewCubeCanvasOrbitMath.VIEW_CUBE_DIRECTIONS) {
571
+ const axes = id.split(",").map(Number);
572
+ const kind = axes.filter(Boolean).length; // 1 face, 2 edge, 3 corner
573
+ const material = new StandardMaterial(`view-cube-${id}`, this.#scene);
574
+ material.specularColor = BabylonJSViewCubeCanvasRenderer.#SPECULAR_COLOR.clone();
575
+ material.specularPower = kind === 3 ? BabylonJSViewCubeCanvasRenderer.#CORNER_SPECULAR_POWER : BabylonJSViewCubeCanvasRenderer.#SPECULAR_POWER;
576
+ const region = { id, kind, axes, material, mesh: null, texture: null, labelMesh: null };
577
+ region.mesh = kind === 1 ? this.#createFace(region) : kind === 2 ? this.#createEdge(region) : this.#createCorner(region);
578
+ region.mesh.material = material;
579
+ region.mesh.metadata = { regionId: id, rank: kind };
580
+ region.mesh.computeWorldMatrix(true);
581
+ this.#regions.set(id, region);
582
+ }
583
+ this.#paintAll();
584
+ }
585
+
586
+ /**
587
+ * Creates a solid outward-facing plane plus a separately owned transparent label layer.
588
+ * The base material has no texture, so its diffuse/specular/emission response matches edges and corners.
589
+ * @param {object} region - Region axes, identifier and material; receives its texture.
590
+ * @returns {import("@babylonjs/core").Mesh} Plane owned by the cube scene.
591
+ */
592
+ #createFace(region) {
593
+ const [x, y, z] = region.axes;
594
+ const mesh = MeshBuilder.CreatePlane(`view-cube-face-${region.id}`, { size: BabylonJSViewCubeCanvasRenderer.#CUBE_HALF * 2 }, this.#scene);
595
+ mesh.position.set(x * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, y * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, z * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF);
596
+ // Babylon's plane is visible, with its texture upright and unmirrored, from the side its normal points to; aim local
597
+ // +Z outward so back-face culling keeps the near faces (checked on screen: aimed inward, the far faces showed through).
598
+ const up = y ? new Vector3(0, 0, -y) : Vector3.Up();
599
+ mesh.rotationQuaternion = Quaternion.FromLookDirectionLH(new Vector3(x, y, z), up);
600
+ region.material.backFaceCulling = true;
601
+ this.#createFaceLabel(region, mesh);
602
+ return mesh;
603
+ }
604
+
605
+ /**
606
+ * Adds an alpha-blended text-only plane just outside its solid parent face; it never participates in picking.
607
+ * The 512 px mipmapped texture owns only glyph pixels. Depth testing/back-face culling hide rear labels,
608
+ * while a small geometric offset and depth bias avoid flickering against the parent face.
609
+ * Label material, mesh and texture belong to the cube scene and are released by dispose().
610
+ * @param {object} region - Face region receiving its label mesh and texture.
611
+ * @param {import("@babylonjs/core").Mesh} face - Solid parent plane whose orientation is inherited.
612
+ * @returns {void}
613
+ */
614
+ #createFaceLabel(region, face) {
615
+ region.texture = new DynamicTexture(`view-cube-label-${region.id}`, this.#createLabelCanvas(), this.#scene, true, Texture.TRILINEAR_SAMPLINGMODE);
616
+ region.texture.anisotropicFilteringLevel = BabylonJSViewCubeCanvasRenderer.#LABEL_ANISOTROPY;
617
+ region.texture.hasAlpha = true;
618
+ const material = new StandardMaterial(`view-cube-label-material-${region.id}`, this.#scene);
619
+ material.diffuseTexture = region.texture;
620
+ material.useAlphaFromDiffuseTexture = true;
621
+ material.transparencyMode = StandardMaterial.MATERIAL_ALPHABLEND;
622
+ material.disableLighting = true;
623
+ material.emissiveColor = Color3.White();
624
+ material.specularColor = Color3.Black();
625
+ material.backFaceCulling = true;
626
+ material.zOffset = -1;
627
+ const label = MeshBuilder.CreatePlane(`view-cube-label-plane-${region.id}`, { size: BabylonJSViewCubeCanvasRenderer.#CUBE_HALF * 2 }, this.#scene);
628
+ label.parent = face;
629
+ // Babylon plane vertices face local -Z, so a negative local offset moves the label outward.
630
+ label.position.z = -BabylonJSViewCubeCanvasRenderer.#LABEL_SURFACE_OFFSET;
631
+ label.material = material;
632
+ label.isPickable = false;
633
+ region.labelMesh = label;
634
+ }
635
+
636
+ /**
637
+ * Creates an axis-aligned edge bar in the owned cube scene.
638
+ * @param {object} region - Region identifier and signed axes.
639
+ * @returns {import("@babylonjs/core").Mesh} Bar disposed with the cube scene.
640
+ */
641
+ #createEdge(region) {
642
+ const [x, y, z] = region.axes;
643
+ /** @param {number} axis - Signed region axis. @returns {number} Edge length along that axis. */
644
+ const size = (axis) => (axis ? BabylonJSViewCubeCanvasRenderer.#EDGE_THICKNESS : BabylonJSViewCubeCanvasRenderer.#EDGE_LENGTH);
645
+ const mesh = MeshBuilder.CreateBox(`view-cube-edge-${region.id}`, { width: size(x), height: size(y), depth: size(z) }, this.#scene);
646
+ mesh.position.set(x * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, y * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, z * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF);
647
+ return mesh;
648
+ }
649
+
650
+ /**
651
+ * Creates a corner sphere in the owned cube scene.
652
+ * @param {object} region - Region identifier and signed axes.
653
+ * @returns {import("@babylonjs/core").Mesh} Sphere disposed with the cube scene.
654
+ */
655
+ #createCorner(region) {
656
+ const [x, y, z] = region.axes;
657
+ const mesh = MeshBuilder.CreateSphere(`view-cube-corner-${region.id}`, { diameter: BabylonJSViewCubeCanvasRenderer.#CORNER_DIAMETER, segments: BabylonJSViewCubeCanvasRenderer.#CORNER_SEGMENTS }, this.#scene);
658
+ mesh.position.set(x * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, y * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF, z * BabylonJSViewCubeCanvasRenderer.#CUBE_HALF);
659
+ return mesh;
660
+ }
661
+
662
+ // The canvas comes from the host document so the renderer never touches a global `document`.
663
+ /**
664
+ * Allocates a supersampled 512 px label texture canvas using the host document without accessing a global document.
665
+ * @returns {HTMLCanvasElement} Offscreen canvas retained by the face texture.
666
+ */
667
+ #createLabelCanvas() {
668
+ const canvas = this.#canvas.ownerDocument.createElement("canvas");
669
+ canvas.width = BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE;
670
+ canvas.height = BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE;
671
+ return canvas;
672
+ }
673
+
674
+ /**
675
+ * Composites hover or current tint over a neutral gray derived from base RGB luminance; hover wins.
676
+ * Uses perceptual channel weights (0.2126 R, 0.7152 G, 0.0722 B) so the base contributes brightness without hue.
677
+ * Unhighlighted surfaces retain their original base color; material opacity and lighting remain unchanged.
678
+ * @param {object} region - Region identifier and face/edge/corner kind.
679
+ * @returns {string} Hex color from the active palette.
680
+ */
681
+ #resolveColor(region) {
682
+ const base = region.kind === 1 ? this.#colors.fill : region.kind === 2 ? this.#colors.edge : this.#colors.corner;
683
+ const rgb = Color3.FromHexString(base);
684
+ const luminance = rgb.r * 0.2126 + rgb.g * 0.7152 + rgb.b * 0.0722;
685
+ const gray = new Color3(luminance, luminance, luminance);
686
+ if (region.id === this.#state.hover) {
687
+ return Color3.Lerp(gray, Color3.FromHexString(this.#colors.hover), this.#colors.hoverOpacity).toHexString().toLowerCase();
688
+ }
689
+ if (region.id === this.#state.current) {
690
+ return Color3.Lerp(gray, Color3.FromHexString(this.#colors.current), this.#colors.currentOpacity).toHexString().toLowerCase();
691
+ }
692
+ return base;
693
+ }
694
+
695
+ /**
696
+ * Updates diffuse color and the configured emissive fraction (currently zero) on all surfaces, then redraws transparent glyphs.
697
+ * Palette and interaction changes therefore update emission as well as the visible fill and labels.
698
+ * Shared SVG typography and spacing scale with texture resolution, retaining centered text at its apparent size.
699
+ * @param {object} region - Region with its material and optional face texture.
700
+ * @returns {void}
701
+ */
702
+ #paint(region) {
703
+ const color = this.#resolveColor(region);
704
+ const base = Color3.FromHexString(color);
705
+ region.material.diffuseColor = base;
706
+ region.material.emissiveColor = base.scale(BabylonJSViewCubeCanvasRenderer.#EMISSIVE_STRENGTH);
707
+ if (region.kind > 1) {
708
+ return;
709
+ }
710
+ const context = region.texture.getContext();
711
+ if (context) {
712
+ context.clearRect(0, 0, BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE, BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE);
713
+ context.fillStyle = this.#colors.text;
714
+ const typography = this.#labelStyle;
715
+ const scale = BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE / BabylonJSViewCubeCanvasRenderer.#SVG_FACE_WIDTH;
716
+ context.font = `${typography.fontWeight} ${typography.fontSize * scale}px ${typography.fontFamily}`;
717
+ context.letterSpacing = `${typography.letterSpacing * scale}px`;
718
+ context.textAlign = "center";
719
+ context.textBaseline = "middle";
720
+ const label = this.#labels[BabylonJSViewCubeCanvasRenderer.#FACE_LABEL_KEYS[region.id]];
721
+ const text = typography.textTransform === "uppercase" ? label.toUpperCase() : typography.textTransform === "lowercase" ? label.toLowerCase() : label;
722
+ context.fillText(text, BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE / 2, BabylonJSViewCubeCanvasRenderer.#TEXTURE_SIZE / 2);
723
+ }
724
+ region.texture.update();
725
+ }
726
+
727
+ /**
728
+ * Repaints all registered regions using the current palette, labels and interaction state.
729
+ * @returns {void}
730
+ */
731
+ #paintAll() {
732
+ this.#regions.forEach((region) => this.#paint(region));
733
+ }
734
+
735
+ /**
736
+ * Updates a hover/current region and repaints the affected regions; ignores disposed or unchanged state.
737
+ * @param {"hover"|"current"} key - State entry to update.
738
+ * @param {?string} regionId - Region to activate, or null to clear.
739
+ * @returns {void}
740
+ */
741
+ #setState(key, regionId) {
742
+ if (this.#disposed) {
743
+ return;
744
+ }
745
+ const previous = this.#state[key];
746
+ const next = this.#regions.has(regionId) ? regionId : null;
747
+ if (previous === next) {
748
+ return;
749
+ }
750
+ this.#state[key] = next;
751
+ [previous, next].forEach((id) => id && this.#paint(this.#regions.get(id)));
752
+ }
753
+
754
+ /**
755
+ * Reads canvas CSS dimensions, falling back to engine render dimensions multiplied by hardware scaling.
756
+ * @returns {{width: number, height: number}} Canvas dimensions in CSS pixels.
757
+ */
758
+ #getCssSize() {
759
+ const scaling = this.#engine.getHardwareScalingLevel();
760
+ return {
761
+ width: this.#canvas.clientWidth || this.#engine.getRenderWidth() * scaling,
762
+ height: this.#canvas.clientHeight || this.#engine.getRenderHeight() * scaling,
763
+ };
764
+ }
765
+
766
+ /** @returns {Readonly<Object<string, string>>} Immutable palette available to dark-theme hosts. */
767
+ static get VIEW_CUBE_DARK_COLORS() {
768
+ return PrefViewerViewCubePalette.darkColors;
769
+ }
770
+
771
+ /** @returns {Readonly<object>} Immutable default Engine options, including context cleanup policy. */
772
+ static get VIEW_CUBE_ENGINE_OPTIONS() {
773
+ return BabylonJSViewCubeCanvasRenderer.#VIEW_CUBE_ENGINE_OPTIONS;
774
+ }
775
+ }