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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +507 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +7 -4
  22. package/src/gltf-resolver.js +13 -8
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +280 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
@@ -1,548 +0,0 @@
1
- // Visual design adapted from the PrefSketch view cube (PrefSketch src/viewer/view-cube.ts): a 1-unit cube with
2
- // 128 px labelled face cells, thin edge bars, corner spheres, a hemispheric light and the PrefSketch 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, DynamicTexture, Engine, EngineStore, HemisphericLight, MeshBuilder, Quaternion, Ray, Scene, StandardMaterial, Vector3 } from "@babylonjs/core";
5
- import { VIEW_CUBE_DIRECTIONS, clampBeta } from "./view-cube-orbit-math.js";
6
-
7
- const CUBE_HALF = 0.5;
8
- const EDGE_THICKNESS = 0.09;
9
- const EDGE_LENGTH = 1 + EDGE_THICKNESS;
10
- const CORNER_DIAMETER = 0.26;
11
- const CORNER_SEGMENTS = 8;
12
- // Edges and corners are lit and also glow at this share of their colour, as in PrefSketch.
13
- const EMISSIVE_SCALE = 0.35;
14
- // PrefSketch light direction is (0.4, 1, 0.6) in a right-handed scene; X is mirrored for this left-handed one.
15
- const LIGHT_DIRECTION = new Vector3(-0.4, 1, 0.6);
16
- const LIGHT_INTENSITY = 1.1;
17
- const LIGHT_GROUND_COLOR = new Color3(0.55, 0.55, 0.6);
18
- const LABEL_FONT_SIZE = 26;
19
- const BORDER_INSET = 4;
20
- const BORDER_WIDTH = 4;
21
- const ORTHO_EXTENT = 1.15;
22
- const CAMERA_RADIUS = 5;
23
- const DEFAULT_FOV = 0.8;
24
- // Perspective depth range around the cube, which is at most ~0.87 from its centre (corner), so +-2 leaves margin.
25
- const PERSPECTIVE_DEPTH_MARGIN = 2;
26
- // Distance from the cube centre to a corner sphere centre (half the cube diagonal).
27
- const CORNER_DISTANCE = Math.sqrt(3) / 2;
28
- const TEXTURE_SIZE = 128;
29
- // Hits farther than this behind the nearest one are other sides of the cube, not overlapping parts.
30
- const PICK_DEPTH_TOLERANCE = 0.15;
31
- // Screen-space pick tolerance, in CSS pixels: an edge or corner whose projected centre is this close to the pointer
32
- // wins over the face under it, because the drawn edge bars (~4 px) and corner spheres (~11 px) are below the 24 px
33
- // WCAG 2.2 minimum target size. It is capped at PICK_TOLERANCE_PX and shrinks with the cube (see computePickTolerance)
34
- // so that the tolerance discs of the edge and corner centres never swallow the faces of a small cube.
35
- const PICK_TOLERANCE_PX = 8;
36
- // 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
37
- // only ~13 px away from the nearest edge centre.
38
- const PICK_TOLERANCE_SIDE_RATIO = 1 / 12;
39
- // Minimum dot product between a region's direction and the direction towards the camera for the region to count as
40
- // front-facing (about 78 degrees from the view axis). Edge-on regions on the silhouette stay out of tolerance picking.
41
- const FRONT_FACING_MIN_DOT = 0.2;
42
-
43
- /**
44
- * Pick tolerance for a cube drawn with the given side: min(PICK_TOLERANCE_PX, PICK_TOLERANCE_SIDE_RATIO * side).
45
- * @param {number} side - Rendered cube side in CSS pixels.
46
- * @returns {number} Tolerance radius in CSS pixels.
47
- */
48
- function computePickTolerance(side) {
49
- return Math.min(PICK_TOLERANCE_PX, PICK_TOLERANCE_SIDE_RATIO * side);
50
- }
51
-
52
- /**
53
- * PrefSketch dark-theme palette (its --cubo-* tokens). Hosts that follow a dark theme can pass it to
54
- * setColors(); the renderer itself never reads the page theme.
55
- */
56
- export const VIEW_CUBE_DARK_COLORS = Object.freeze({
57
- fill: "#2f363a",
58
- border: "#9ba4a9",
59
- text: "#e6e3da",
60
- edge: "#56606a",
61
- corner: "#6b7886",
62
- });
63
-
64
- // Light palette = PrefSketch --cubo-cara, --cubo-borde, --cubo-texto, --cubo-arista, --cubo-esquina. PrefSketch has no hover or current state.
65
- const DEFAULT_COLORS = Object.freeze({
66
- fill: "#f7f3ea",
67
- border: "#4a5058",
68
- text: "#2a2e33",
69
- edge: "#b8c2d1",
70
- corner: "#9eadc7",
71
- hover: "#ff6700",
72
- current: "#ffd1b3",
73
- });
74
-
75
- /**
76
- * Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose). Babylon only calls
77
- * WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL context
78
- * would linger until garbage collection, and browsers cap the number of live contexts per page.
79
- */
80
- export const VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true });
81
-
82
- const DEFAULT_LABELS = Object.freeze({ right: "Right", left: "Left", bottom: "Bottom", top: "Top", back: "Back", front: "Front" });
83
-
84
- // Label key per signed axis; matches the SVG cube: +X is "left", +Y is "top", +Z is "front".
85
- const 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" });
86
-
87
- /**
88
- * Renders the 3D view cube on its own Babylon.js engine and scene, bound to a small canvas.
89
- *
90
- * The scene is left-handed and Y-up like the viewer scene and its ArcRotateCamera (orthographic or perspective, following
91
- * the host camera) uses the host camera's alpha/beta unchanged, so no axis mirroring is needed. Every pickable mesh carries
92
- * `metadata.regionId` equal to a VIEW_CUBE_DIRECTIONS id. The class owns no render loop and no
93
- * interaction logic: the host calls render() and decides what pick() results mean. It touches neither
94
- * `window` nor `document` itself, so it can be imported in any environment.
95
- */
96
- export default class BabylonJSViewCubeRenderer {
97
- #canvas;
98
- #engine;
99
- #scene;
100
- #camera;
101
- #mode = Camera.ORTHOGRAPHIC_CAMERA;
102
- #fov = DEFAULT_FOV;
103
- #colors;
104
- #labels;
105
- #devicePixelRatio;
106
- #regions = new Map();
107
- #state = { hover: null, current: null };
108
- #fontFamily;
109
- #disposed = false;
110
- #contextRestoredObserver = null;
111
-
112
- /**
113
- * Creates the engine, scene, camera and the 26 cube regions.
114
- * @param {Object} options - Renderer options.
115
- * @param {HTMLCanvasElement} options.canvas - Canvas the cube is drawn on; expected to be square.
116
- * @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.
117
- * @param {Object<string, string>} [options.labels] - Face labels keyed right, left, bottom, top, back, front.
118
- * @param {Object<string, string>} [options.colors] - "#rrggbb" colours: fill, border, text (faces), edge, corner, hover, current.
119
- * @param {number} [options.devicePixelRatio=1] - Pixel ratio used by resize(); never read from `window`.
120
- * @param {string} [options.fontFamily="sans-serif"] - Font family of the face labels.
121
- * @param {function(): void} [options.onContextRestored] - Called after the cube's own WebGL context was restored; the host should repaint.
122
- */
123
- constructor({ canvas, createEngine, labels, colors, devicePixelRatio, fontFamily = "sans-serif", onContextRestored }) {
124
- this.#canvas = canvas;
125
- this.#colors = { ...DEFAULT_COLORS, ...colors };
126
- this.#labels = { ...DEFAULT_LABELS, ...labels };
127
- this.#devicePixelRatio = devicePixelRatio > 0 ? devicePixelRatio : 1;
128
- this.#fontFamily = fontFamily;
129
- this.#engine = createEngine ? createEngine(canvas) : new Engine(canvas, true, { ...VIEW_CUBE_ENGINE_OPTIONS });
130
- // Second line of defence: if Babylon's input manager were ever attached, it would write engine.canvasTabIndex
131
- // (default 1) to the canvas on every pointer move and make it keyboard-focusable. -1 keeps it out of the tab order.
132
- this.#engine.canvasTabIndex = -1;
133
- this.#contextRestoredObserver = this.#engine.onContextRestoredObservable?.add(() => {
134
- if (!this.#disposed) {
135
- onContextRestored?.();
136
- }
137
- });
138
- this.#engine.setHardwareScalingLevel(1 / this.#devicePixelRatio);
139
- // Babylon resolves objects created without an explicit scene or engine (textures, IBL filtering, ...) through
140
- // EngineStore.LastCreatedScene and EngineStore.LastCreatedEngine (the last entry of EngineStore.Instances).
141
- // The cube must stay out of both, or the main viewer would create resources on the cube's WebGL context
142
- // ("object does not belong to this context"). A virtual scene never becomes LastCreatedScene, and the engine
143
- // is unregistered right after creation. The host calls render() itself, so neither registry is needed.
144
- this.#unregisterEngine();
145
- this.#scene = new Scene(this.#engine, { virtual: true });
146
- // The cube needs no Babylon input: the controller handles pointers itself and picks with an explicit ray. Even a
147
- // virtual scene attaches its InputManager to the canvas when a `window` exists, which would reset the cursor on
148
- // every move, call preventDefault() and focus() on press (stealing keyboard focus from the host canvas) and
149
- // capture the pointer a second time. Detach it, and keep the flags as a guard in case something re-attaches it.
150
- this.#scene.detachControl();
151
- this.#scene.doNotHandleCursors = true;
152
- this.#scene.preventDefaultOnPointerDown = false;
153
- this.#scene.preventDefaultOnPointerUp = false;
154
- this.#scene.clearColor = new Color4(0, 0, 0, 0);
155
- this.#camera = this.#createCamera();
156
- this.#createLight();
157
- this.#createRegions();
158
- }
159
-
160
- /**
161
- * The Babylon scene, exposed for hosts and tests that need to inspect it.
162
- * @returns {Scene}
163
- */
164
- get scene() {
165
- return this.#scene;
166
- }
167
-
168
- /**
169
- * Orients the cube like the host camera and follows its projection. An orthographic host gives an orthographic
170
- * cube (radius 5, extents +-1.15). A perspective host gives a perspective cube with the host's vertical fov at
171
- * radius 0.5 + 1.15 / tan(fov / 2), so the near face and its corner knobs keep the on-screen size of the
172
- * orthographic layout (the rest of the cube foreshortens behind it).
173
- * @param {{alpha: number, beta: number, mode?: number, fov?: number}} pose - Host camera angles in radians (beta is clamped away from the poles), projection mode (Camera.ORTHOGRAPHIC_CAMERA or Camera.PERSPECTIVE_CAMERA; absent keeps the last one) and vertical field of view in radians (absent or not a finite positive number keeps the last one, 0.8 initially).
174
- * @returns {void}
175
- */
176
- setPose({ alpha, beta, mode, fov }) {
177
- if (this.#disposed) {
178
- return;
179
- }
180
- const camera = this.#camera;
181
- if (mode === Camera.ORTHOGRAPHIC_CAMERA || mode === Camera.PERSPECTIVE_CAMERA) {
182
- this.#mode = mode;
183
- }
184
- if (typeof fov === "number" && Number.isFinite(fov) && fov > 0) {
185
- this.#fov = fov;
186
- }
187
- this.#applyProjection(camera);
188
- camera.alpha = alpha;
189
- camera.beta = clampBeta(beta);
190
- camera.getViewMatrix(true);
191
- }
192
-
193
- /**
194
- * Finds the region under a point. Coordinates are CSS pixels from the canvas top-left; they are scaled by
195
- * the canvas client size (falling back to the render size when the client size is unknown) and cast as an
196
- * 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
197
- * faces when they overlap on screen. A front-facing edge or corner whose projected centre is within the pick tolerance
198
- * of the pointer wins over the face under it (corners before edges, then the nearest centre). The tolerance is
199
- * min(8 CSS px, 1/12 of the cube side), so small cubes keep their face centres pickable. It also rescues a ray that
200
- * misses the cube: a pointer just outside the silhouette but within tolerance of a front-facing edge or corner
201
- * centre returns that region, which makes the thin outer bars and corner spheres easier to hit; hover and click
202
- * both go through pick(), so they stay consistent. Back-facing and edge-on regions are never chosen this way, and a
203
- * point outside the cube farther than the tolerance from every front-facing centre still returns null.
204
- * @param {number} x - Horizontal position in CSS pixels.
205
- * @param {number} y - Vertical position in CSS pixels.
206
- * @returns {string|null} Region id such as "0,0,1", "1,0,1" or "1,1,1", or null outside the cube.
207
- */
208
- pick(x, y) {
209
- if (this.#disposed) {
210
- return null;
211
- }
212
- const { width, height } = this.#getCssSize();
213
- if (!(width > 0 && height > 0)) {
214
- return null;
215
- }
216
- const camera = this.#camera;
217
- camera.getViewMatrix(true);
218
- const forward = camera.target.subtract(camera.position).normalize();
219
- const right = Vector3.Cross(camera.upVector, forward).normalize();
220
- const up = Vector3.Cross(forward, right);
221
- const perspective = camera.mode === Camera.PERSPECTIVE_CAMERA;
222
- // Half extents of the view plane through the cube centre: fixed for orthographic, radius * tan(fov / 2) for perspective.
223
- const halfHeight = perspective ? camera.radius * Math.tan(camera.fov / 2) : (camera.orthoTop - camera.orthoBottom) / 2;
224
- const halfWidth = perspective ? halfHeight * (width / height) : (camera.orthoRight - camera.orthoLeft) / 2;
225
- const offset = right.scale((x / width * 2 - 1) * halfWidth).add(up.scale((1 - y / height * 2) * halfHeight));
226
- const ray = perspective
227
- ? new Ray(camera.position, forward.scale(camera.radius).add(offset).normalize(), 100)
228
- : new Ray(camera.position.add(offset), forward, 100);
229
- const hits = this.#scene
230
- .multiPickWithRay(ray, (mesh) => Boolean(mesh.metadata?.regionId))
231
- .filter((hit) => hit.hit)
232
- .sort((a, b) => a.distance - b.distance);
233
- const near = this.#pickByTolerance(x, y, width, height, forward, computePickTolerance(Math.min(width, height)));
234
- if (near) {
235
- return near;
236
- }
237
- if (!hits.length) {
238
- return null;
239
- }
240
- const front = hits.filter((hit) => hit.distance - hits[0].distance <= PICK_DEPTH_TOLERANCE);
241
- const rank = (hit) => hit.pickedMesh.metadata.rank;
242
- return front.reduce((best, hit) => (rank(hit) > rank(best) ? hit : best)).pickedMesh.metadata.regionId;
243
- }
244
-
245
- /**
246
- * Sets the hovered region, shown with the hover colour. Hover wins over current.
247
- * @param {string|null} regionId - Region id, or null to clear.
248
- * @returns {void}
249
- */
250
- setHover(regionId) {
251
- this.#setState("hover", regionId);
252
- }
253
-
254
- /**
255
- * Sets the region that matches the current view, shown with the current colour.
256
- * @param {string|null} regionId - Region id, or null to clear.
257
- * @returns {void}
258
- */
259
- setCurrent(regionId) {
260
- this.#setState("current", regionId);
261
- }
262
-
263
- /**
264
- * Merges colours into the palette and repaints every region; meshes are kept.
265
- * @param {Object<string, string>} colors - Partial "#rrggbb" palette, see the constructor.
266
- * @returns {void}
267
- */
268
- setColors(colors) {
269
- if (this.#disposed) {
270
- return;
271
- }
272
- this.#colors = { ...this.#colors, ...colors };
273
- this.#paintAll();
274
- }
275
-
276
- /**
277
- * Merges face labels and repaints the face textures; meshes are kept.
278
- * @param {Object<string, string>} labels - Partial labels keyed right, left, bottom, top, back, front.
279
- * @returns {void}
280
- */
281
- setLabels(labels) {
282
- if (this.#disposed) {
283
- return;
284
- }
285
- this.#labels = { ...this.#labels, ...labels };
286
- this.#paintAll();
287
- }
288
-
289
- /**
290
- * Returns the colour a region is currently painted with (face fill, edge, corner, hover or current).
291
- * @param {string} regionId - Region id.
292
- * @returns {string|null} "#rrggbb" colour, or null for an unknown region.
293
- */
294
- getRegionColor(regionId) {
295
- return this.#regions.has(regionId) ? this.#resolveColor(this.#regions.get(regionId)) : null;
296
- }
297
-
298
- /**
299
- * Projects a region centre with the current camera.
300
- * @param {string} regionId - Region id.
301
- * @returns {{x: number, y: number}|null} Position in CSS pixels from the canvas top-left, or null for an unknown region or after dispose.
302
- */
303
- getRegionScreenPosition(regionId) {
304
- const region = this.#regions.get(regionId);
305
- if (this.#disposed || !region) {
306
- return null;
307
- }
308
- const { width, height } = this.#getCssSize();
309
- const camera = this.#camera;
310
- const viewProjection = camera.getViewMatrix(true).multiply(camera.getProjectionMatrix(true));
311
- const ndc = Vector3.TransformCoordinates(region.mesh.position, viewProjection);
312
- return { x: (ndc.x * 0.5 + 0.5) * width, y: (0.5 - ndc.y * 0.5) * height };
313
- }
314
-
315
- /**
316
- * Applies the canvas size and the hardware scaling derived from devicePixelRatio.
317
- * @param {number} [devicePixelRatio] - Current pixel ratio; when valid (> 0) it replaces the stored one, otherwise the stored one is kept.
318
- * @returns {void}
319
- */
320
- resize(devicePixelRatio) {
321
- if (this.#disposed) {
322
- return;
323
- }
324
- if (devicePixelRatio > 0) {
325
- this.#devicePixelRatio = devicePixelRatio;
326
- }
327
- this.#engine.setHardwareScalingLevel(1 / this.#devicePixelRatio);
328
- this.#engine.resize();
329
- }
330
-
331
- /**
332
- * Renders one frame; the host render loop calls this. No-op after dispose or for a zero-size canvas.
333
- * @returns {boolean} Whether a frame was drawn; false means the host should keep its dirty flag and retry later.
334
- */
335
- render() {
336
- if (this.#disposed || this.#engine.getRenderWidth() === 0 || this.#engine.getRenderHeight() === 0) {
337
- return false;
338
- }
339
- this.#scene.render();
340
- return true;
341
- }
342
-
343
- /**
344
- * Releases the scene, its textures and the engine. Safe to call more than once.
345
- * @returns {void}
346
- */
347
- dispose() {
348
- if (this.#disposed) {
349
- return;
350
- }
351
- this.#disposed = true;
352
- this.#engine.onContextRestoredObservable?.remove(this.#contextRestoredObserver);
353
- this.#contextRestoredObserver = null;
354
- this.#regions.clear();
355
- this.#scene.dispose();
356
- // Engine.dispose() tolerates a missing entry, but it also tears down shared state (such as the audio engine)
357
- // when it believes it is the last registered engine. Register it again so it disposes like any other engine.
358
- if (!EngineStore.Instances.includes(this.#engine)) {
359
- EngineStore.Instances.push(this.#engine);
360
- }
361
- this.#engine.dispose();
362
- }
363
-
364
- #unregisterEngine() {
365
- const index = EngineStore.Instances.indexOf(this.#engine);
366
- if (index >= 0) {
367
- EngineStore.Instances.splice(index, 1);
368
- }
369
- }
370
-
371
- // Best front-facing edge or corner within `tolerance` CSS px of the pointer: highest rank first, then nearest centre.
372
- #pickByTolerance(x, y, width, height, forward, tolerance) {
373
- const camera = this.#camera;
374
- const viewProjection = camera.getViewMatrix(true).multiply(camera.getProjectionMatrix(true));
375
- let best = null;
376
- for (const region of this.#regions.values()) {
377
- if (region.kind < 2) {
378
- continue;
379
- }
380
- const length = Math.hypot(...region.axes);
381
- const facing = -(region.axes[0] * forward.x + region.axes[1] * forward.y + region.axes[2] * forward.z) / length;
382
- if (facing < FRONT_FACING_MIN_DOT) {
383
- continue;
384
- }
385
- const ndc = Vector3.TransformCoordinates(region.mesh.position, viewProjection);
386
- const distance = Math.hypot((ndc.x * 0.5 + 0.5) * width - x, (0.5 - ndc.y * 0.5) * height - y);
387
- if (distance > tolerance) {
388
- continue;
389
- }
390
- if (!best || region.kind > best.kind || (region.kind === best.kind && distance < best.distance)) {
391
- best = { id: region.id, kind: region.kind, distance };
392
- }
393
- }
394
- return best?.id ?? null;
395
- }
396
-
397
- // Orthographic: fixed radius and extents. Perspective: the host fov and the radius that frames the same half extent
398
- // at the near face plane (CUBE_HALF in front of the centre), where the corner knobs and the face in view are drawn.
399
- #applyProjection(camera) {
400
- if (this.#mode === Camera.PERSPECTIVE_CAMERA) {
401
- camera.mode = Camera.PERSPECTIVE_CAMERA;
402
- camera.fovMode = Camera.FOVMODE_VERTICAL_FIXED;
403
- camera.fov = this.#fov;
404
- camera.radius = CUBE_HALF + ORTHO_EXTENT / Math.tan(this.#fov / 2);
405
- // A wide fov brings the camera closer than the margin: keep the near plane positive and before the nearest corner.
406
- camera.minZ = Math.max(camera.radius - PERSPECTIVE_DEPTH_MARGIN, Math.min(0.1, (camera.radius - CORNER_DISTANCE) / 2));
407
- camera.maxZ = camera.radius + PERSPECTIVE_DEPTH_MARGIN;
408
- return;
409
- }
410
- camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
411
- camera.radius = CAMERA_RADIUS;
412
- camera.minZ = 0.1;
413
- camera.maxZ = CAMERA_RADIUS * 4;
414
- }
415
-
416
- #createCamera() {
417
- const camera = new ArcRotateCamera("view-cube-camera", Math.PI / 2, Math.PI / 2, CAMERA_RADIUS, Vector3.Zero(), this.#scene);
418
- camera.inputs.clear();
419
- camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
420
- camera.orthoLeft = -ORTHO_EXTENT;
421
- camera.orthoRight = ORTHO_EXTENT;
422
- camera.orthoBottom = -ORTHO_EXTENT;
423
- camera.orthoTop = ORTHO_EXTENT;
424
- camera.minZ = 0.1;
425
- camera.maxZ = CAMERA_RADIUS * 4;
426
- this.#scene.activeCamera = camera;
427
- return camera;
428
- }
429
-
430
- #createLight() {
431
- const light = new HemisphericLight("view-cube-light", LIGHT_DIRECTION.clone(), this.#scene);
432
- light.intensity = LIGHT_INTENSITY;
433
- light.groundColor = LIGHT_GROUND_COLOR.clone();
434
- }
435
-
436
- #createRegions() {
437
- for (const { id } of VIEW_CUBE_DIRECTIONS) {
438
- const axes = id.split(",").map(Number);
439
- const kind = axes.filter(Boolean).length; // 1 face, 2 edge, 3 corner
440
- const material = new StandardMaterial(`view-cube-${id}`, this.#scene);
441
- material.specularColor = kind === 1 ? new Color3(0.05, 0.05, 0.05) : Color3.Black();
442
- const region = { id, kind, axes, material, mesh: null, texture: null };
443
- region.mesh = kind === 1 ? this.#createFace(region) : kind === 2 ? this.#createEdge(region) : this.#createCorner(region);
444
- region.mesh.material = material;
445
- region.mesh.metadata = { regionId: id, rank: kind };
446
- region.mesh.computeWorldMatrix(true);
447
- this.#regions.set(id, region);
448
- }
449
- this.#paintAll();
450
- }
451
-
452
- #createFace(region) {
453
- const [x, y, z] = region.axes;
454
- const mesh = MeshBuilder.CreatePlane(`view-cube-face-${region.id}`, { size: CUBE_HALF * 2 }, this.#scene);
455
- mesh.position.set(x * CUBE_HALF, y * CUBE_HALF, z * CUBE_HALF);
456
- // Babylon's plane is visible, with its texture upright and unmirrored, from the side its normal points to; aim local
457
- // +Z outward so back-face culling keeps the near faces (checked on screen: aimed inward, the far faces showed through).
458
- const up = y ? new Vector3(0, 0, -y) : Vector3.Up();
459
- mesh.rotationQuaternion = Quaternion.FromLookDirectionLH(new Vector3(x, y, z), up);
460
- region.texture = new DynamicTexture(`view-cube-label-${region.id}`, this.#createLabelCanvas(), this.#scene, true);
461
- region.material.diffuseTexture = region.texture;
462
- region.material.emissiveColor = new Color3(EMISSIVE_SCALE, EMISSIVE_SCALE, EMISSIVE_SCALE);
463
- region.material.backFaceCulling = true;
464
- return mesh;
465
- }
466
-
467
- #createEdge(region) {
468
- const [x, y, z] = region.axes;
469
- const size = (axis) => (axis ? EDGE_THICKNESS : EDGE_LENGTH);
470
- const mesh = MeshBuilder.CreateBox(`view-cube-edge-${region.id}`, { width: size(x), height: size(y), depth: size(z) }, this.#scene);
471
- mesh.position.set(x * CUBE_HALF, y * CUBE_HALF, z * CUBE_HALF);
472
- return mesh;
473
- }
474
-
475
- #createCorner(region) {
476
- const [x, y, z] = region.axes;
477
- const mesh = MeshBuilder.CreateSphere(`view-cube-corner-${region.id}`, { diameter: CORNER_DIAMETER, segments: CORNER_SEGMENTS }, this.#scene);
478
- mesh.position.set(x * CUBE_HALF, y * CUBE_HALF, z * CUBE_HALF);
479
- return mesh;
480
- }
481
-
482
- // The canvas comes from the host document so the renderer never touches a global `document`.
483
- #createLabelCanvas() {
484
- const canvas = this.#canvas.ownerDocument.createElement("canvas");
485
- canvas.width = TEXTURE_SIZE;
486
- canvas.height = TEXTURE_SIZE;
487
- return canvas;
488
- }
489
-
490
- #resolveColor(region) {
491
- if (region.id === this.#state.hover) {
492
- return this.#colors.hover;
493
- }
494
- if (region.id === this.#state.current) {
495
- return this.#colors.current;
496
- }
497
- return region.kind === 1 ? this.#colors.fill : region.kind === 2 ? this.#colors.edge : this.#colors.corner;
498
- }
499
-
500
- #paint(region) {
501
- const color = this.#resolveColor(region);
502
- if (region.kind > 1) {
503
- const base = Color3.FromHexString(color);
504
- region.material.diffuseColor = base;
505
- region.material.emissiveColor = base.scale(EMISSIVE_SCALE);
506
- return;
507
- }
508
- const context = region.texture.getContext();
509
- if (context) {
510
- context.fillStyle = color;
511
- context.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
512
- context.strokeStyle = this.#colors.border;
513
- context.lineWidth = BORDER_WIDTH;
514
- context.strokeRect(BORDER_INSET, BORDER_INSET, TEXTURE_SIZE - BORDER_INSET * 2, TEXTURE_SIZE - BORDER_INSET * 2);
515
- context.fillStyle = this.#colors.text;
516
- context.font = `bold ${LABEL_FONT_SIZE}px ${this.#fontFamily}`;
517
- context.textAlign = "center";
518
- context.textBaseline = "middle";
519
- context.fillText(this.#labels[FACE_LABEL_KEYS[region.id]], TEXTURE_SIZE / 2, TEXTURE_SIZE / 2);
520
- }
521
- region.texture.update();
522
- }
523
-
524
- #paintAll() {
525
- this.#regions.forEach((region) => this.#paint(region));
526
- }
527
-
528
- #setState(key, regionId) {
529
- if (this.#disposed) {
530
- return;
531
- }
532
- const previous = this.#state[key];
533
- const next = this.#regions.has(regionId) ? regionId : null;
534
- if (previous === next) {
535
- return;
536
- }
537
- this.#state[key] = next;
538
- [previous, next].forEach((id) => id && this.#paint(this.#regions.get(id)));
539
- }
540
-
541
- #getCssSize() {
542
- const scaling = this.#engine.getHardwareScalingLevel();
543
- return {
544
- width: this.#canvas.clientWidth || this.#engine.getRenderWidth() * scaling,
545
- height: this.#canvas.clientHeight || this.#engine.getRenderHeight() * scaling,
546
- };
547
- }
548
- }