@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.
- package/README.md +267 -31
- package/index.d.ts +75 -6
- package/package.json +8 -2
- package/src/babylonjs-animation-controller.js +18 -10
- package/src/babylonjs-animation-opening-menu.js +80 -14
- package/src/babylonjs-animation-opening.js +3 -3
- package/src/babylonjs-ar-floor-bounds.js +169 -0
- package/src/babylonjs-ar-glass.js +128 -0
- package/src/babylonjs-ar-placement-feedback.js +449 -0
- package/src/babylonjs-ar-placement.js +1038 -0
- package/src/babylonjs-camera-wheel-motion.js +35 -2
- package/src/babylonjs-controller.js +507 -125
- package/src/babylonjs-orbital-pointers-input.js +186 -0
- package/src/{render-math.js → babylonjs-render-math.js} +11 -11
- package/src/babylonjs-selection-controller.js +4 -4
- package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
- package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
- package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
- package/src/babylonjs-view-cube-controller.js +74 -1103
- package/src/babylonjs-view-cube-svg-controller.js +1012 -0
- package/src/file-storage.js +7 -4
- package/src/gltf-resolver.js +13 -8
- package/src/localization/translations.js +12 -0
- package/src/panzoom-controller.js +4 -4
- package/src/pref-viewer-2d.js +2 -2
- package/src/pref-viewer-3d.js +55 -9
- package/src/pref-viewer-controls-presentation.js +292 -0
- package/src/pref-viewer-dialog.js +86 -7
- package/src/pref-viewer-menu-3d.js +134 -17
- package/src/pref-viewer-style-controller.js +320 -0
- package/src/pref-viewer.js +133 -109
- package/src/quick-look-action.js +280 -0
- package/src/styles.js +702 -98
- package/src/babylonjs-view-cube-renderer.js +0 -548
- 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
|
-
}
|