@preference-sl/pref-viewer 2.17.0-beta.3 → 2.17.0-beta.5
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 +59 -36
- package/package.json +1 -1
- package/src/babylonjs-controller.js +13 -6
- package/src/babylonjs-view-cube-controller.js +569 -213
- package/src/babylonjs-view-cube-renderer.js +507 -0
- package/src/styles.js +0 -88
- package/src/view-cube-orbit-math.js +199 -0
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
|
|
2
|
-
import {
|
|
2
|
+
import { PrefViewerStyleVariables } from "./styles.js";
|
|
3
3
|
import I18n from "./localization/i18n.js";
|
|
4
4
|
import BabylonJSController from "./babylonjs-controller.js";
|
|
5
5
|
import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
|
|
6
6
|
import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
|
|
7
|
+
import BabylonJSViewCubeRenderer from "./babylonjs-view-cube-renderer.js";
|
|
8
|
+
import { anglesFromDirection, applyDragDelta, computeViewCubeSize, directionFromAngles } from "./view-cube-orbit-math.js";
|
|
9
|
+
|
|
10
|
+
// Light PrefSketch palette tint: the "current view" colour is the primary colour mixed into the face fill.
|
|
11
|
+
const CURRENT_FILL_COLOR = "#f7f3ea";
|
|
12
|
+
const CURRENT_MIX = 0.25;
|
|
13
|
+
// Horizontal component of the unit view direction below which the camera counts as being on a pole.
|
|
14
|
+
const POLE_SNAP = 1e-6;
|
|
15
|
+
const PRIMARY_COLOR_VARIABLE = "--pref-viewer-color-primary";
|
|
16
|
+
// The app can change the primary colour at runtime without notifying the cube, so it is polled; reading the computed
|
|
17
|
+
// style forces a style recalculation, hence at most one read per interval (creation, culture change, resize and
|
|
18
|
+
// setEnabled(true) always read).
|
|
19
|
+
const COLOR_REFRESH_MS = 250;
|
|
20
|
+
// CSS-wide keywords and currentcolor resolve against the probe's parent, not to a colour of their own.
|
|
21
|
+
const CSS_COLOR_KEYWORDS = /^(?:inherit|initial|unset|revert|revert-layer|currentcolor)$/i;
|
|
22
|
+
// Host-settable CSS custom property (set on any ancestor of the viewer; it inherits through the shadow boundary)
|
|
23
|
+
// that insets the cube from the right edge of its container. Unset, the cube stays pinned at right:0px.
|
|
24
|
+
const INSET_RIGHT_VARIABLE = "--pref-viewer-view-cube-inset-right";
|
|
25
|
+
// Pointer travel, in CSS pixels, beyond which a press on the cube stops being a click and becomes a drag.
|
|
26
|
+
const DRAG_THRESHOLD_PX = 3;
|
|
27
|
+
// Orbit angle, in radians, applied per CSS pixel of drag; the PrefSketch view cube uses the same value.
|
|
28
|
+
const DRAG_RADIANS_PER_PX = 0.012;
|
|
29
|
+
const INERTIA_KEYS = ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"];
|
|
7
30
|
|
|
8
31
|
/**
|
|
9
32
|
* @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
|
|
@@ -48,39 +71,42 @@ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
|
|
|
48
71
|
*/
|
|
49
72
|
|
|
50
73
|
/**
|
|
51
|
-
*
|
|
52
|
-
* @typedef {Object}
|
|
53
|
-
* @property {
|
|
54
|
-
* @property {
|
|
55
|
-
* @property {
|
|
56
|
-
* @property {number}
|
|
74
|
+
* Options handed to the renderer factory.
|
|
75
|
+
* @typedef {Object} ViewCubeRendererOptions
|
|
76
|
+
* @property {HTMLCanvasElement} canvas - Cube canvas inside the overlay root.
|
|
77
|
+
* @property {Object<string, string>} labels - Translated face labels keyed right, left, bottom, top, back, front.
|
|
78
|
+
* @property {Object<string, string>} colors - "#rrggbb" hover and current colours derived from the primary colour.
|
|
79
|
+
* @property {number} devicePixelRatio - Pixel ratio of the owning window.
|
|
80
|
+
* @property {function(): void} onContextRestored - Called when the renderer's own WebGL context was restored; the controller then repaints.
|
|
57
81
|
*/
|
|
58
82
|
|
|
59
83
|
/**
|
|
60
|
-
* BabylonJSViewCubeController - Owns
|
|
84
|
+
* BabylonJSViewCubeController - Owns a 3D orientation indicator and model-framing camera navigation.
|
|
61
85
|
*
|
|
62
86
|
* Displays 26 face/edge/corner presets with primary-color hover and nearest-view feedback.
|
|
63
|
-
*
|
|
87
|
+
* The visual layer is a BabylonJSViewCubeRenderer drawing on a canvas inside the overlay root; this class
|
|
88
|
+
* keeps the interaction (hover, click), the camera pose reading and the camera transitions.
|
|
89
|
+
* Synchronizes camera orientation while locked, unsupported and XR cameras remain display-only.
|
|
64
90
|
* Accepted clicks preserve projection, fit only model bounds and retain world +Y as camera up.
|
|
65
91
|
*
|
|
66
92
|
* The host owns the camera, scene, assets and render loop. It calls tick() to advance transitions
|
|
67
|
-
* and sync() to refresh the indicator
|
|
68
|
-
*
|
|
93
|
+
* and sync() to refresh the indicator; sync() renders the cube, so the cube has no render loop of its own.
|
|
94
|
+
* The renderer owns a separate Engine and Scene, created lazily once a camera exists and disposed when the cube
|
|
95
|
+
* is disabled or disposed. dispose() releases the overlay, its listeners/observers and the renderer.
|
|
69
96
|
*
|
|
70
97
|
* Members are ordered as properties, constructor, static methods, private methods and public methods.
|
|
71
98
|
* @class
|
|
72
99
|
*/
|
|
73
100
|
export default class BabylonJSViewCubeController {
|
|
74
|
-
static #SVG_NS = "http://www.w3.org/2000/svg";
|
|
75
|
-
|
|
76
101
|
#cameraDistances = new WeakMap();
|
|
77
102
|
#host;
|
|
78
103
|
#root;
|
|
79
|
-
#
|
|
104
|
+
#canvas;
|
|
105
|
+
#renderer = null;
|
|
80
106
|
#regions = BabylonJSViewCubeController.createViewCubeRegions();
|
|
81
|
-
|
|
82
|
-
#shell = [];
|
|
107
|
+
// View matrix, projection mode and handedness the cube was last posed for; null forces a new pose.
|
|
83
108
|
#geometryKey = null;
|
|
109
|
+
#colorsReadAt = null;
|
|
84
110
|
#locale;
|
|
85
111
|
#locked;
|
|
86
112
|
#enabled = false;
|
|
@@ -91,13 +117,18 @@ export default class BabylonJSViewCubeController {
|
|
|
91
117
|
#disposed = false;
|
|
92
118
|
#hover = null;
|
|
93
119
|
#pointer = null;
|
|
120
|
+
#press = null;
|
|
121
|
+
#alpha = 0;
|
|
122
|
+
#dirty = false;
|
|
123
|
+
#primaryRaw = null;
|
|
94
124
|
#listeners = [];
|
|
95
125
|
#resizeObserver;
|
|
96
126
|
#viewportResizeObserver;
|
|
97
127
|
#layoutKey = null;
|
|
128
|
+
#stopPixelRatioWatch = null;
|
|
98
129
|
|
|
99
130
|
/**
|
|
100
|
-
* Creates the
|
|
131
|
+
* Creates the overlay root and canvas, attaches input/resize observers and enables the cube.
|
|
101
132
|
* The host retains ownership of the camera, scene, assets and render loop. Callbacks resolve
|
|
102
133
|
* current state so camera/model replacement does not leave the cube with stale references.
|
|
103
134
|
*
|
|
@@ -110,80 +141,76 @@ export default class BabylonJSViewCubeController {
|
|
|
110
141
|
* @param {function(): {width: number, height: number}} host.getViewport - Returns the current scene viewport dimensions in CSS pixels.
|
|
111
142
|
* @param {function(): void} host.requestRender - Requests rendering through the host scheduler after accepted navigation.
|
|
112
143
|
* @param {function(): string} [host.getCulture] - Returns the current localization culture; omitted callbacks use the translator's default culture.
|
|
113
|
-
* @param {function(number, number): void} [host.onLayout] - Receives the measured cube
|
|
144
|
+
* @param {function(number, number): void} [host.onLayout] - Receives the measured cube viewport height and scene viewport height, in CSS pixels; zero cube height clears the menu offset, and disposal reports (0, 0).
|
|
145
|
+
* @param {function(ViewCubeRendererOptions): BabylonJSViewCubeRenderer} [host.createRenderer] - Renderer factory, mainly for tests; defaults to a BabylonJSViewCubeRenderer with its own Engine.
|
|
114
146
|
*/
|
|
115
147
|
constructor(host) {
|
|
116
148
|
this.#host = host;
|
|
117
149
|
const doc = host.canvas.ownerDocument;
|
|
118
150
|
this.#root = doc.createElement("div");
|
|
119
151
|
this.#root.className = "pref-viewer-view-cube";
|
|
120
|
-
this.#root.style.cssText =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this
|
|
125
|
-
|
|
126
|
-
this.#
|
|
127
|
-
this.#root.append(this.#
|
|
128
|
-
for (const region of this.#regions.filter((r) => r.kind === "face")) {
|
|
129
|
-
const polygon = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "polygon");
|
|
130
|
-
polygon.setAttribute("class", "cube-shell");
|
|
131
|
-
this.#svg.append(polygon);
|
|
132
|
-
this.#shell.push({ ...region.outline, polygon });
|
|
133
|
-
}
|
|
134
|
-
for (const region of this.#regions) {
|
|
135
|
-
const group = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "g");
|
|
136
|
-
const surfaces = region.surfaces.map((surface) => {
|
|
137
|
-
const polygon = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "polygon");
|
|
138
|
-
polygon.dataset.region = region.id;
|
|
139
|
-
polygon.dataset.kind = region.kind;
|
|
140
|
-
group.append(polygon);
|
|
141
|
-
return { ...surface, polygon };
|
|
142
|
-
});
|
|
143
|
-
const label = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "text");
|
|
144
|
-
if (region.kind === "face") {
|
|
145
|
-
group.append(label);
|
|
146
|
-
}
|
|
147
|
-
this.#svg.append(group);
|
|
148
|
-
const outline = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "path");
|
|
149
|
-
outline.setAttribute("class", "hover-outline");
|
|
150
|
-
this.#nodes.set(region.id, { group, surfaces, label, outline });
|
|
151
|
-
}
|
|
152
|
-
// Stroke every clickable boundary above all filled regions, including interior face boundaries.
|
|
153
|
-
for (const node of this.#nodes.values()) {
|
|
154
|
-
this.#svg.append(node.outline);
|
|
155
|
-
}
|
|
152
|
+
this.#root.style.cssText = `position:absolute;right:var(${INSET_RIGHT_VARIABLE}, 0px);top:0px;width:0px;height:0px;z-index:900;pointer-events:none`;
|
|
153
|
+
this.#canvas = doc.createElement("canvas");
|
|
154
|
+
this.#canvas.setAttribute("role", "img");
|
|
155
|
+
// The canvas is the only pointer-active part; the layout wrapper never intercepts scene input.
|
|
156
|
+
// The renderer keeps Babylon's input manager detached from this canvas, so nothing else focuses it or moves the
|
|
157
|
+
// cursor. The ring, selection and tap highlight are still suppressed as a harmless extra guard: the cube is pointer-driven.
|
|
158
|
+
this.#canvas.style.cssText = "display:block;width:100%;height:100%;touch-action:none;pointer-events:auto;outline:none;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none";
|
|
159
|
+
this.#root.append(this.#canvas);
|
|
156
160
|
host.canvas.parentElement.append(this.#root);
|
|
157
|
-
this.#addListener(this.#
|
|
158
|
-
this.#pointer =
|
|
159
|
-
this.#
|
|
161
|
+
this.#addListener(this.#canvas, "pointermove", (event) => {
|
|
162
|
+
this.#pointer = this.#toLocal(event);
|
|
163
|
+
if (this.#press) {
|
|
164
|
+
this.#dragTo(event);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.#setHover(this.#pick(this.#pointer));
|
|
160
168
|
});
|
|
161
|
-
this.#addListener(this.#
|
|
169
|
+
this.#addListener(this.#canvas, "pointerleave", () => {
|
|
162
170
|
this.#pointer = null;
|
|
171
|
+
this.#endPress();
|
|
163
172
|
this.#setHover(null);
|
|
164
173
|
});
|
|
165
|
-
this.#addListener(this.#
|
|
166
|
-
if (
|
|
167
|
-
|
|
174
|
+
this.#addListener(this.#canvas, "pointerdown", (event) => {
|
|
175
|
+
if (!this.#isInteractive()) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// The whole canvas is a drag surface, transparent corners included: the main camera never starts from it.
|
|
179
|
+
event.stopPropagation();
|
|
180
|
+
if (event.button !== 0 || event.isPrimary === false || (this.#press && this.#press.pointerId !== event.pointerId)) {
|
|
181
|
+
return;
|
|
168
182
|
}
|
|
183
|
+
this.#endPress();
|
|
184
|
+
this.#press = { pointerId: event.pointerId, id: this.#pick(this.#toLocal(event)), startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, dragging: false };
|
|
185
|
+
try {
|
|
186
|
+
this.#canvas.setPointerCapture?.(event.pointerId);
|
|
187
|
+
} catch (_error) {}
|
|
169
188
|
});
|
|
170
|
-
this.#addListener(this.#
|
|
171
|
-
const
|
|
172
|
-
if (!
|
|
189
|
+
this.#addListener(this.#canvas, "pointerup", (event) => {
|
|
190
|
+
const press = this.#press;
|
|
191
|
+
if (!press || press.pointerId !== event.pointerId) {
|
|
173
192
|
return;
|
|
174
193
|
}
|
|
175
|
-
|
|
176
|
-
if (event.button === 0) {
|
|
177
|
-
this.select(id);
|
|
194
|
+
this.#endPress();
|
|
195
|
+
if (!press.dragging && press.id && event.button === 0 && this.#pick(this.#toLocal(event)) === press.id) {
|
|
196
|
+
this.select(press.id);
|
|
178
197
|
}
|
|
179
198
|
});
|
|
199
|
+
for (const name of ["pointercancel", "lostpointercapture"]) {
|
|
200
|
+
this.#addListener(this.#canvas, name, (event) => {
|
|
201
|
+
if (this.#press?.pointerId === event.pointerId) {
|
|
202
|
+
this.#endPress();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
180
206
|
for (const event of ["pointerdown", "wheel", "keydown"]) {
|
|
181
207
|
this.#addListener(host.canvas, event, () => this.cancel());
|
|
182
208
|
}
|
|
183
209
|
this.#resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.resize());
|
|
184
210
|
this.#resizeObserver?.observe(host.canvas);
|
|
185
211
|
this.#viewportResizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.#publishLayout());
|
|
186
|
-
this.#viewportResizeObserver?.observe(this.#
|
|
212
|
+
this.#viewportResizeObserver?.observe(this.#canvas);
|
|
213
|
+
this.#watchPixelRatio();
|
|
187
214
|
this.setEnabled(true);
|
|
188
215
|
}
|
|
189
216
|
|
|
@@ -292,17 +319,6 @@ export default class BabylonJSViewCubeController {
|
|
|
292
319
|
regions.push({ id: axis.join(","), direction, surfaces, kind: ["", "face", "edge", "corner"][axis.filter(Boolean).length] });
|
|
293
320
|
}
|
|
294
321
|
|
|
295
|
-
/**
|
|
296
|
-
* Finds the semantic cube-region id under the event target.
|
|
297
|
-
* @private
|
|
298
|
-
* @static
|
|
299
|
-
* @param {PointerEvent|MouseEvent} event - Overlay input event.
|
|
300
|
-
* @returns {string|null}
|
|
301
|
-
*/
|
|
302
|
-
static #getRegionAt(event) {
|
|
303
|
-
return event.target?.closest?.("polygon[data-region]")?.dataset.region ?? null;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
322
|
/**
|
|
307
323
|
* Interpolates an orthographic extent from its start or fallback using the current eased transition.
|
|
308
324
|
* @private
|
|
@@ -318,36 +334,109 @@ export default class BabylonJSViewCubeController {
|
|
|
318
334
|
}
|
|
319
335
|
|
|
320
336
|
/**
|
|
321
|
-
*
|
|
337
|
+
* Converts any CSS colour into lowercase "#rrggbb". The browser's own parser does the work when available: the value
|
|
338
|
+
* is assigned to a probe element's style (an invalid value leaves it empty) and the computed `color`, always
|
|
339
|
+
* "rgb()" or "rgba()", is converted. Environments without usable computed styles use a small pure-JS parser
|
|
340
|
+
* (hex 3/4/6/8 digits, rgb()/rgba() with numbers or percentages, hsl()/hsla()); named colours need the browser.
|
|
341
|
+
* Alpha is ignored and nothing is premultiplied: the colour is used as an opaque tint.
|
|
342
|
+
* @private
|
|
343
|
+
* @param {string} value - CSS colour text.
|
|
344
|
+
* @param {Element} [probeParent] - Element the probe is attached to while its computed colour is read.
|
|
345
|
+
* @returns {string|null} Hex colour, or null when the text is not a colour.
|
|
346
|
+
*/
|
|
347
|
+
static #toHex(value, probeParent) {
|
|
348
|
+
const text = String(value ?? "").trim();
|
|
349
|
+
if (!text || CSS_COLOR_KEYWORDS.test(text)) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return BabylonJSViewCubeController.#toHexWithBrowser(text, probeParent) ?? BabylonJSViewCubeController.#toHexWithoutBrowser(text);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Normalises a colour through the browser. Returns null when the browser is unavailable or cannot resolve it,
|
|
357
|
+
* so the caller can try the pure-JS parser.
|
|
322
358
|
* @private
|
|
323
359
|
* @static
|
|
324
|
-
* @param {
|
|
325
|
-
* @param {
|
|
326
|
-
* @returns {
|
|
360
|
+
* @param {string} text - Trimmed CSS colour text.
|
|
361
|
+
* @param {Element} [parent] - Element that hosts the probe while its computed colour is read.
|
|
362
|
+
* @returns {string|null}
|
|
363
|
+
*/
|
|
364
|
+
static #toHexWithBrowser(text, parent) {
|
|
365
|
+
const view = parent?.ownerDocument?.defaultView;
|
|
366
|
+
if (!view?.getComputedStyle) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const probe = parent.ownerDocument.createElement("span");
|
|
370
|
+
try {
|
|
371
|
+
probe.style.color = text;
|
|
372
|
+
if (!probe.style.color) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
parent.appendChild(probe);
|
|
376
|
+
return BabylonJSViewCubeController.#toHexWithoutBrowser(String(view.getComputedStyle(probe).color ?? "").trim());
|
|
377
|
+
} catch (_error) {
|
|
378
|
+
return null;
|
|
379
|
+
} finally {
|
|
380
|
+
probe.remove?.();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Pure-JS colour parser used when the browser cannot normalise a value.
|
|
386
|
+
* @private
|
|
387
|
+
* @static
|
|
388
|
+
* @param {string} value - Trimmed CSS colour text.
|
|
389
|
+
* @returns {string|null}
|
|
390
|
+
*/
|
|
391
|
+
static #toHexWithoutBrowser(value) {
|
|
392
|
+
const text = value.toLowerCase();
|
|
393
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1];
|
|
394
|
+
if (hex) {
|
|
395
|
+
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
|
396
|
+
return `#${full.slice(0, 6)}`;
|
|
397
|
+
}
|
|
398
|
+
const channel = "(\\d*\\.?\\d+)(%?)";
|
|
399
|
+
const rgb = new RegExp(`^rgba?\\(\\s*${channel}[\\s,]+${channel}[\\s,]+${channel}\\s*(?:[,/][^)]*)?\\)$`).exec(text);
|
|
400
|
+
if (rgb) {
|
|
401
|
+
const channels = [1, 3, 5].map((i) => (rgb[i + 1] ? Number(rgb[i]) * 2.55 : Number(rgb[i])));
|
|
402
|
+
return BabylonJSViewCubeController.#formatHex(channels);
|
|
403
|
+
}
|
|
404
|
+
const hsl = /^hsla?\(\s*(-?\d*\.?\d+)(?:deg)?[\s,]+(\d*\.?\d+)%[\s,]+(\d*\.?\d+)%\s*(?:[,/][^)]*)?\)$/.exec(text);
|
|
405
|
+
if (hsl) {
|
|
406
|
+
const hue = ((Number(hsl[1]) % 360) + 360) % 360;
|
|
407
|
+
const s = Math.min(1, Number(hsl[2]) / 100);
|
|
408
|
+
const l = Math.min(1, Number(hsl[3]) / 100);
|
|
409
|
+
const a = s * Math.min(l, 1 - l);
|
|
410
|
+
const f = (n) => l - a * Math.max(-1, Math.min((n + hue / 30) % 12 - 3, 9 - ((n + hue / 30) % 12), 1));
|
|
411
|
+
return BabylonJSViewCubeController.#formatHex([f(0) * 255, f(8) * 255, f(4) * 255]);
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Formats 0-255 channels as "#rrggbb", rounding and clamping each one.
|
|
418
|
+
* @private
|
|
419
|
+
* @static
|
|
420
|
+
* @param {number[]} channels - Red, green and blue.
|
|
421
|
+
* @returns {string}
|
|
327
422
|
*/
|
|
328
|
-
static #
|
|
329
|
-
|
|
330
|
-
const point = Vector3.TransformNormal(vertex, view);
|
|
331
|
-
const scale = perspective ? eyeDistance / (eyeDistance + point.z * depthSign) : 1;
|
|
332
|
-
return { x: 60 + point.x * 31 * scale, y: 60 - point.y * 31 * scale };
|
|
423
|
+
static #formatHex(channels) {
|
|
424
|
+
return `#${channels.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("")}`;
|
|
333
425
|
}
|
|
334
426
|
|
|
335
427
|
/**
|
|
336
|
-
*
|
|
428
|
+
* Mixes two "#rrggbb" colours.
|
|
337
429
|
* @private
|
|
338
430
|
* @static
|
|
339
|
-
* @param {
|
|
340
|
-
* @param {
|
|
341
|
-
* @
|
|
431
|
+
* @param {string} base - Base colour.
|
|
432
|
+
* @param {string} tint - Colour mixed into the base.
|
|
433
|
+
* @param {number} amount - Share of the tint, from 0 to 1.
|
|
434
|
+
* @returns {string} Mixed "#rrggbb" colour.
|
|
342
435
|
*/
|
|
343
|
-
static #
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
surface.polygon.style.display = shown ? "" : "none";
|
|
348
|
-
const points = surface.vertices.map((vertex) => BabylonJSViewCubeController.#projectVertex(vertex, projection));
|
|
349
|
-
surface.polygon.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" "));
|
|
350
|
-
return { shown, points };
|
|
436
|
+
static #mix(base, tint, amount) {
|
|
437
|
+
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
438
|
+
const [b, t] = [channels(base), channels(tint)];
|
|
439
|
+
return `#${b.map((c, i) => Math.round(c + (t[i] - c) * amount).toString(16).padStart(2, "0")).join("")}`;
|
|
351
440
|
}
|
|
352
441
|
|
|
353
442
|
/**
|
|
@@ -397,7 +486,7 @@ export default class BabylonJSViewCubeController {
|
|
|
397
486
|
*/
|
|
398
487
|
#clearInertia(camera) {
|
|
399
488
|
BabylonJSCameraWheelMotion.cancel(camera);
|
|
400
|
-
for (const key of
|
|
489
|
+
for (const key of INERTIA_KEYS) {
|
|
401
490
|
if (key in camera) {
|
|
402
491
|
camera[key] = 0;
|
|
403
492
|
}
|
|
@@ -417,7 +506,7 @@ export default class BabylonJSViewCubeController {
|
|
|
417
506
|
* @param {Vector3} position - Interpolated world-space position.
|
|
418
507
|
* @param {Vector3} target - Interpolated world-space model target.
|
|
419
508
|
* @param {number} distance - Interpolated camera-to-target distance.
|
|
420
|
-
* @param {ViewCubeFit} fit - Destination bounds/framing used to adjust clipping.
|
|
509
|
+
* @param {ViewCubeFit} [fit] - Destination bounds/framing used to adjust clipping; drag rotation omits it and keeps the clipping planes.
|
|
421
510
|
* @returns {void}
|
|
422
511
|
*/
|
|
423
512
|
#writePose(camera, position, target, distance, fit) {
|
|
@@ -449,8 +538,117 @@ export default class BabylonJSViewCubeController {
|
|
|
449
538
|
camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
|
|
450
539
|
this.#cameraDistances.set(camera, distance);
|
|
451
540
|
}
|
|
452
|
-
|
|
453
|
-
|
|
541
|
+
if (fit) {
|
|
542
|
+
camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
|
|
543
|
+
camera.maxZ = Math.max(camera.maxZ, distance + fit.radius * 2);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Applies a drag increment to the host camera so the model follows the pointer.
|
|
549
|
+
*
|
|
550
|
+
* Sign convention, verified against the main-canvas input of Babylon: ArcRotateCameraPointersInput accumulates
|
|
551
|
+
* -offsetX and -offsetY, and ArcRotateCamera adds them to alpha (times -1 in right-handed scenes or mirrored
|
|
552
|
+
* parents, and times -1 with invertRotation) and to beta (times -1 with invertRotation). The same factors are
|
|
553
|
+
* applied here, so dragging the cube and dragging the main canvas rotate the model identically.
|
|
554
|
+
* ArcRotateCameras write alpha/beta directly with beta kept away from the poles; other cameras orbit their
|
|
555
|
+
* current target at the same distance, with the same angles, through the shared pose writer.
|
|
556
|
+
* @private
|
|
557
|
+
* @param {ViewCubeCamera} camera - Active host camera.
|
|
558
|
+
* @param {number} dx - Horizontal pointer travel in CSS pixels, positive to the right.
|
|
559
|
+
* @param {number} dy - Vertical pointer travel in CSS pixels, positive downwards.
|
|
560
|
+
* @returns {void}
|
|
561
|
+
*/
|
|
562
|
+
#rotateCamera(camera, dx, dy) {
|
|
563
|
+
const invert = camera.invertRotation ? -1 : 1;
|
|
564
|
+
let handedness = camera.getScene().useRightHandedSystem ? -1 : 1;
|
|
565
|
+
if (camera.parent?.getWorldMatrix?.().determinant() < 0) {
|
|
566
|
+
handedness *= -1;
|
|
567
|
+
}
|
|
568
|
+
const dAlpha = -dx * DRAG_RADIANS_PER_PX * handedness * invert;
|
|
569
|
+
const dBeta = -dy * DRAG_RADIANS_PER_PX * invert;
|
|
570
|
+
if (this.#isOrbit(camera)) {
|
|
571
|
+
const next = applyDragDelta({ alpha: camera.alpha, beta: camera.beta }, dAlpha, dBeta);
|
|
572
|
+
this.#clearInertia(camera);
|
|
573
|
+
camera.alpha = next.alpha;
|
|
574
|
+
camera.beta = next.beta;
|
|
575
|
+
} else {
|
|
576
|
+
const pose = this.#readPose(camera);
|
|
577
|
+
const angles = anglesFromDirection(pose.position.subtract(pose.target), this.#alpha);
|
|
578
|
+
if (!angles) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const direction = directionFromAngles(applyDragDelta(angles, dAlpha, dBeta));
|
|
582
|
+
this.#writePose(camera, pose.target.add(new Vector3(direction.x, direction.y, direction.z).scale(pose.distance)), pose.target, pose.distance);
|
|
583
|
+
}
|
|
584
|
+
this.#host.requestRender();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Follows a move of the pressed pointer: crossing the threshold turns the press into a drag, which cancels any
|
|
589
|
+
* running click transition and clears hover; every later move rotates by the delta since the previous one.
|
|
590
|
+
* @private
|
|
591
|
+
* @param {PointerEvent} event - Move event on the cube canvas.
|
|
592
|
+
* @returns {void}
|
|
593
|
+
*/
|
|
594
|
+
#dragTo(event) {
|
|
595
|
+
const press = this.#press;
|
|
596
|
+
if (event.pointerId !== press.pointerId) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const camera = this.#host.getCamera();
|
|
600
|
+
if (!this.#isInteractive()) {
|
|
601
|
+
this.#endPress();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (!press.dragging) {
|
|
605
|
+
if (Math.hypot(event.clientX - press.startX, event.clientY - press.startY) <= DRAG_THRESHOLD_PX) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
press.dragging = true;
|
|
609
|
+
this.cancel();
|
|
610
|
+
this.#setHover(null);
|
|
611
|
+
}
|
|
612
|
+
const dx = event.clientX - press.lastX;
|
|
613
|
+
const dy = event.clientY - press.lastY;
|
|
614
|
+
press.lastX = event.clientX;
|
|
615
|
+
press.lastY = event.clientY;
|
|
616
|
+
if (dx !== 0 || dy !== 0) {
|
|
617
|
+
this.#rotateCamera(camera, dx, dy);
|
|
618
|
+
}
|
|
619
|
+
this.#updateCursor();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Ends the current press, if any: releases the pointer capture and, after a drag, brings hover back under the pointer.
|
|
624
|
+
* @private
|
|
625
|
+
* @returns {void}
|
|
626
|
+
*/
|
|
627
|
+
#endPress() {
|
|
628
|
+
const press = this.#press;
|
|
629
|
+
if (!press) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
this.#press = null;
|
|
633
|
+
try {
|
|
634
|
+
this.#canvas.releasePointerCapture?.(press.pointerId);
|
|
635
|
+
} catch (_error) {}
|
|
636
|
+
if (press.dragging && this.#pointer) {
|
|
637
|
+
this.#setHover(this.#pick(this.#pointer));
|
|
638
|
+
}
|
|
639
|
+
this.#updateCursor();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Sets the canvas cursor: none while display-only, grabbing while dragging, pointer over a region, grab otherwise.
|
|
644
|
+
* @private
|
|
645
|
+
* @returns {void}
|
|
646
|
+
*/
|
|
647
|
+
#updateCursor() {
|
|
648
|
+
const cursor = !this.#isInteractive() ? "" : this.#press?.dragging ? "grabbing" : this.#hover ? "pointer" : "grab";
|
|
649
|
+
if (this.#canvas.style.cursor !== cursor) {
|
|
650
|
+
this.#canvas.style.cursor = cursor;
|
|
651
|
+
}
|
|
454
652
|
}
|
|
455
653
|
|
|
456
654
|
/**
|
|
@@ -463,7 +661,7 @@ export default class BabylonJSViewCubeController {
|
|
|
463
661
|
if (this.#disposed) {
|
|
464
662
|
return;
|
|
465
663
|
}
|
|
466
|
-
const viewportHeight = this.#
|
|
664
|
+
const viewportHeight = this.#canvas.getBoundingClientRect().height || parseFloat(this.#root.style.height) || 0;
|
|
467
665
|
const cubeHeight = this.#enabled ? viewportHeight : 0;
|
|
468
666
|
const sceneHeight = this.#host.getViewport().height;
|
|
469
667
|
const key = `${cubeHeight},${sceneHeight}`;
|
|
@@ -488,41 +686,234 @@ export default class BabylonJSViewCubeController {
|
|
|
488
686
|
}
|
|
489
687
|
|
|
490
688
|
/**
|
|
491
|
-
*
|
|
689
|
+
* Reports whether the cube accepts pointer interaction: enabled, supported camera and not locked.
|
|
690
|
+
* @private
|
|
691
|
+
* @returns {boolean}
|
|
692
|
+
*/
|
|
693
|
+
#isInteractive() {
|
|
694
|
+
const camera = this.#host.getCamera();
|
|
695
|
+
return this.#enabled && !this.#disposed && Boolean(this.#renderer) && BabylonJSCameraFraming.isCameraSupported(camera) && !this.#host.isLocked(camera);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Reads the current device pixel ratio of the owning window; browser zoom or a move to another monitor changes it.
|
|
700
|
+
* @private
|
|
701
|
+
* @returns {number} Positive ratio, 1 when unknown.
|
|
702
|
+
*/
|
|
703
|
+
#readPixelRatio() {
|
|
704
|
+
const ratio = this.#root.ownerDocument.defaultView?.devicePixelRatio;
|
|
705
|
+
return ratio > 0 ? ratio : 1;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Listens for the next change of the device pixel ratio with a `(resolution: <ratio>dppx)` media query. The query
|
|
710
|
+
* only matches the ratio it was built with, so each change re-reads the ratio, resizes and arms a fresh query.
|
|
711
|
+
* Does nothing without matchMedia. Replaces any listener armed before.
|
|
492
712
|
* @private
|
|
493
|
-
* @param {string|null} key - Cube region id whose hover feedback is updated.
|
|
494
|
-
* @param {boolean} active - Whether hover feedback is active.
|
|
495
713
|
* @returns {void}
|
|
496
714
|
*/
|
|
497
|
-
#
|
|
498
|
-
|
|
499
|
-
|
|
715
|
+
#watchPixelRatio() {
|
|
716
|
+
this.#stopPixelRatioWatch?.();
|
|
717
|
+
this.#stopPixelRatioWatch = null;
|
|
718
|
+
const view = this.#root.ownerDocument.defaultView;
|
|
719
|
+
if (this.#disposed || typeof view?.matchMedia !== "function") {
|
|
500
720
|
return;
|
|
501
721
|
}
|
|
502
|
-
|
|
503
|
-
|
|
722
|
+
const list = view.matchMedia(`(resolution: ${this.#readPixelRatio()}dppx)`);
|
|
723
|
+
const onChange = () => {
|
|
724
|
+
this.#watchPixelRatio();
|
|
725
|
+
this.resize();
|
|
726
|
+
};
|
|
727
|
+
if (typeof list.addEventListener === "function") {
|
|
728
|
+
list.addEventListener("change", onChange);
|
|
729
|
+
this.#stopPixelRatioWatch = () => list.removeEventListener("change", onChange);
|
|
730
|
+
} else if (typeof list.addListener === "function") {
|
|
731
|
+
list.addListener(onChange);
|
|
732
|
+
this.#stopPixelRatioWatch = () => list.removeListener(onChange);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Repaints after the renderer's WebGL context was restored: marks the cube dirty and asks the host for a frame.
|
|
738
|
+
* @private
|
|
739
|
+
* @returns {void}
|
|
740
|
+
*/
|
|
741
|
+
#handleContextRestored() {
|
|
742
|
+
if (this.#disposed || !this.#renderer) {
|
|
743
|
+
return;
|
|
504
744
|
}
|
|
505
|
-
|
|
745
|
+
this.#dirty = true;
|
|
746
|
+
this.#host.requestRender();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Converts pointer client coordinates into CSS pixels from the cube canvas top-left.
|
|
751
|
+
* @private
|
|
752
|
+
* @param {PointerEvent|MouseEvent} event - Canvas input event.
|
|
753
|
+
* @returns {{x:number,y:number}}
|
|
754
|
+
*/
|
|
755
|
+
#toLocal(event) {
|
|
756
|
+
const rect = this.#canvas.getBoundingClientRect();
|
|
757
|
+
return { x: event.clientX - (rect.left ?? 0), y: event.clientY - (rect.top ?? 0) };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Resolves the region under a canvas point; null while the cube is display-only.
|
|
762
|
+
* @private
|
|
763
|
+
* @param {{x:number,y:number}} point - Canvas-local CSS pixel position.
|
|
764
|
+
* @returns {string|null}
|
|
765
|
+
*/
|
|
766
|
+
#pick(point) {
|
|
767
|
+
return this.#isInteractive() && Number.isFinite(point.x + point.y) ? this.#renderer.pick(point.x, point.y) : null;
|
|
506
768
|
}
|
|
507
769
|
|
|
508
770
|
/**
|
|
509
|
-
*
|
|
510
|
-
*
|
|
771
|
+
* Forwards hover to the renderer and repaints the cube right away, since hover changes outside the host loop.
|
|
772
|
+
* The cube has its own engine and scene, so the host's model scene is not re-rendered (no host.requestRender()).
|
|
773
|
+
* During sync() the cube is rendered once at its end instead. Clears hover when disabled, locked or unsupported.
|
|
511
774
|
* @private
|
|
512
775
|
* @param {string|null} id - Signed-axis region identifier, or null to remove hover.
|
|
513
776
|
* @returns {void}
|
|
514
777
|
*/
|
|
515
778
|
#setHover(id) {
|
|
516
|
-
if (!this.#
|
|
779
|
+
if (!this.#isInteractive()) {
|
|
517
780
|
id = null;
|
|
518
781
|
}
|
|
519
782
|
if (id === this.#hover) {
|
|
520
783
|
return;
|
|
521
784
|
}
|
|
522
|
-
|
|
523
|
-
this.#setRegionHover(this.#hover, false);
|
|
524
785
|
this.#hover = id;
|
|
525
|
-
this.#
|
|
786
|
+
this.#updateCursor();
|
|
787
|
+
this.#renderer?.setHover(id);
|
|
788
|
+
this.#dirty = true;
|
|
789
|
+
if (!this.#syncing) {
|
|
790
|
+
this.#renderDirty();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Renders the cube when something visible changed. A renderer that skipped the frame (zero-size canvas) returns
|
|
796
|
+
* false: the cube stays dirty so a later size change or sync repaints.
|
|
797
|
+
* @private
|
|
798
|
+
* @returns {void}
|
|
799
|
+
*/
|
|
800
|
+
#renderDirty() {
|
|
801
|
+
if (this.#enabled && this.#renderer && this.#dirty) {
|
|
802
|
+
this.#dirty = this.#renderer.render() === false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Builds the translated face labels in the renderer's key order.
|
|
808
|
+
* @private
|
|
809
|
+
* @param {string} [locale] - Localization culture.
|
|
810
|
+
* @returns {Object<string, string>}
|
|
811
|
+
*/
|
|
812
|
+
#getLabels(locale) {
|
|
813
|
+
const labels = {};
|
|
814
|
+
for (const key of ["right", "left", "bottom", "top", "back", "front"]) {
|
|
815
|
+
labels[key] = I18n.translate(`prefViewer.viewCube.${key}`, { locale });
|
|
816
|
+
}
|
|
817
|
+
return labels;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Reads the primary colour from the root's computed style and derives the hover and current colours.
|
|
822
|
+
* Returns null when unchanged since the last call, when the last read is younger than COLOR_REFRESH_MS (unless
|
|
823
|
+
* forced or invalidated) or when no colour can be resolved; environments without getComputedStyle fall back to the
|
|
824
|
+
* configured style variable.
|
|
825
|
+
* @private
|
|
826
|
+
* @param {boolean} [force=false] - Reads now and returns the colours even when the source value did not change.
|
|
827
|
+
* @returns {{hover:string,current:string}|null}
|
|
828
|
+
*/
|
|
829
|
+
#readColors(force = false) {
|
|
830
|
+
const now = this.#now();
|
|
831
|
+
if (!force && this.#colorsReadAt !== null && now - this.#colorsReadAt < COLOR_REFRESH_MS) {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
this.#colorsReadAt = now;
|
|
835
|
+
let raw = "";
|
|
836
|
+
try {
|
|
837
|
+
raw = this.#root.ownerDocument.defaultView.getComputedStyle(this.#root).getPropertyValue(PRIMARY_COLOR_VARIABLE).trim();
|
|
838
|
+
} catch (_error) {}
|
|
839
|
+
raw ||= String(PrefViewerStyleVariables.colorPrimary ?? "").trim();
|
|
840
|
+
if (raw === this.#primaryRaw && !force) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
this.#primaryRaw = raw;
|
|
844
|
+
const hover = BabylonJSViewCubeController.#toHex(raw, this.#root) ?? BabylonJSViewCubeController.#toHex(PrefViewerStyleVariables.colorPrimary, this.#root);
|
|
845
|
+
return hover ? { hover, current: BabylonJSViewCubeController.#mix(CURRENT_FILL_COLOR, hover, CURRENT_MIX) } : null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Compares the pose inputs with the last ones the cube was posed for and remembers them.
|
|
850
|
+
* @private
|
|
851
|
+
* @param {ArrayLike<number>} matrix - Camera view matrix elements.
|
|
852
|
+
* @param {number} mode - Camera projection mode.
|
|
853
|
+
* @param {boolean} rightHanded - Whether the scene is right-handed.
|
|
854
|
+
* @returns {boolean} Whether anything differs from the previous call.
|
|
855
|
+
*/
|
|
856
|
+
#updateGeometryKey(matrix, mode, rightHanded) {
|
|
857
|
+
const key = this.#geometryKey;
|
|
858
|
+
if (key && key.mode === mode && key.rightHanded === rightHanded && key.matrix.every((value, i) => value === matrix[i])) {
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
this.#geometryKey = { matrix: Array.from(matrix), mode, rightHanded };
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Current time in milliseconds: the host clock when it provides one, otherwise the performance clock.
|
|
867
|
+
* @private
|
|
868
|
+
* @returns {number}
|
|
869
|
+
*/
|
|
870
|
+
#now() {
|
|
871
|
+
return this.#host.now?.() ?? performance.now();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Creates the renderer once a camera exists while the cube is enabled.
|
|
876
|
+
* @private
|
|
877
|
+
* @param {string} [locale] - Localization culture used for the initial labels.
|
|
878
|
+
* @returns {void}
|
|
879
|
+
*/
|
|
880
|
+
#ensureRenderer(locale) {
|
|
881
|
+
if (this.#renderer) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const options = { canvas: this.#canvas, labels: this.#getLabels(locale), colors: this.#readColors(true) ?? {}, devicePixelRatio: this.#readPixelRatio(), onContextRestored: () => this.#handleContextRestored() };
|
|
885
|
+
this.#renderer = this.#host.createRenderer ? this.#host.createRenderer(options) : new BabylonJSViewCubeRenderer(options);
|
|
886
|
+
this.#renderer.resize(this.#readPixelRatio());
|
|
887
|
+
this.#applyLocale(locale);
|
|
888
|
+
this.#geometryKey = null;
|
|
889
|
+
this.#dirty = true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Remembers the culture and localizes the canvas accessible name.
|
|
894
|
+
* @private
|
|
895
|
+
* @param {string} [locale] - Localization culture.
|
|
896
|
+
* @returns {void}
|
|
897
|
+
*/
|
|
898
|
+
#applyLocale(locale) {
|
|
899
|
+
this.#locale = locale;
|
|
900
|
+
this.#canvas.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Disposes the renderer and forgets everything derived from it.
|
|
905
|
+
* @private
|
|
906
|
+
* @returns {void}
|
|
907
|
+
*/
|
|
908
|
+
#disposeRenderer() {
|
|
909
|
+
this.#endPress();
|
|
910
|
+
this.#renderer?.dispose();
|
|
911
|
+
this.#renderer = null;
|
|
912
|
+
this.#hover = null;
|
|
913
|
+
this.#primaryRaw = null;
|
|
914
|
+
this.#colorsReadAt = null;
|
|
915
|
+
this.#geometryKey = null;
|
|
916
|
+
this.#canvas.style.cursor = "";
|
|
526
917
|
}
|
|
527
918
|
|
|
528
919
|
/**
|
|
@@ -537,7 +928,7 @@ export default class BabylonJSViewCubeController {
|
|
|
537
928
|
/**
|
|
538
929
|
* Returns the overlay root for host integration or inspection.
|
|
539
930
|
* @public
|
|
540
|
-
* @returns {HTMLDivElement} Root element containing
|
|
931
|
+
* @returns {HTMLDivElement} Root element containing the cube canvas.
|
|
541
932
|
*/
|
|
542
933
|
get element() {
|
|
543
934
|
return this.#root;
|
|
@@ -555,18 +946,20 @@ export default class BabylonJSViewCubeController {
|
|
|
555
946
|
return;
|
|
556
947
|
}
|
|
557
948
|
this.#enabled = Boolean(enabled);
|
|
949
|
+
this.#colorsReadAt = null;
|
|
558
950
|
this.#root.hidden = !this.#enabled;
|
|
559
951
|
if (!this.#enabled) {
|
|
560
952
|
this.cancel();
|
|
561
953
|
this.#pointer = null;
|
|
562
954
|
this.#setHover(null);
|
|
955
|
+
this.#disposeRenderer();
|
|
563
956
|
}
|
|
564
957
|
this.resize();
|
|
565
958
|
this.sync();
|
|
566
959
|
}
|
|
567
960
|
|
|
568
961
|
/**
|
|
569
|
-
*
|
|
962
|
+
* Sizes the square widget from the scene viewport (see computeViewCubeSize), resizes the renderer and publishes its layout.
|
|
570
963
|
* Cancels obsolete navigation and synchronizes the indicator without changing the camera pose.
|
|
571
964
|
* @public
|
|
572
965
|
* @returns {void}
|
|
@@ -576,10 +969,15 @@ export default class BabylonJSViewCubeController {
|
|
|
576
969
|
return;
|
|
577
970
|
}
|
|
578
971
|
this.cancel();
|
|
972
|
+
this.#colorsReadAt = null;
|
|
579
973
|
const viewport = this.#host.getViewport();
|
|
580
|
-
const size =
|
|
974
|
+
const size = computeViewCubeSize(viewport.width, viewport.height);
|
|
581
975
|
this.#root.style.width = `${size}px`;
|
|
582
976
|
this.#root.style.height = `${size}px`;
|
|
977
|
+
if (this.#renderer) {
|
|
978
|
+
this.#renderer.resize(this.#readPixelRatio());
|
|
979
|
+
this.#dirty = true;
|
|
980
|
+
}
|
|
583
981
|
this.#publishLayout();
|
|
584
982
|
this.sync();
|
|
585
983
|
}
|
|
@@ -658,10 +1056,12 @@ export default class BabylonJSViewCubeController {
|
|
|
658
1056
|
}
|
|
659
1057
|
|
|
660
1058
|
/**
|
|
661
|
-
* Synchronizes
|
|
1059
|
+
* Synchronizes the 3D cube with the active camera: pose, nearest orientation, labels, colours and lock state.
|
|
662
1060
|
* Rebinds the view-matrix observer on replacement and guards against recursive notifications.
|
|
663
|
-
*
|
|
664
|
-
*
|
|
1061
|
+
* Creates the renderer lazily once a camera exists. The cube orientation follows the camera direction only,
|
|
1062
|
+
* independent of model zoom and projection. Locked, unsupported and XR cameras keep the cube following the
|
|
1063
|
+
* camera but make it display-only. Renders only when something visible changed; it is called by the host
|
|
1064
|
+
* render loop after the scene renders, and hover changes ask the host for a frame.
|
|
665
1065
|
* @public
|
|
666
1066
|
* @returns {void}
|
|
667
1067
|
*/
|
|
@@ -683,114 +1083,67 @@ export default class BabylonJSViewCubeController {
|
|
|
683
1083
|
this.#locked = locked;
|
|
684
1084
|
if (lockChanged) {
|
|
685
1085
|
this.#root.dataset.locked = String(locked);
|
|
1086
|
+
this.#canvas.style.pointerEvents = locked ? "none" : "auto";
|
|
1087
|
+
this.#endPress();
|
|
1088
|
+
this.#dirty = true;
|
|
686
1089
|
}
|
|
687
1090
|
if (locked) {
|
|
688
1091
|
this.#setHover(null);
|
|
689
1092
|
}
|
|
1093
|
+
this.#updateCursor();
|
|
690
1094
|
if (!camera?.getViewMatrix) {
|
|
691
|
-
this.#
|
|
1095
|
+
this.#canvas.style.visibility = "hidden";
|
|
692
1096
|
return;
|
|
693
1097
|
}
|
|
694
|
-
if (this.#
|
|
695
|
-
this.#
|
|
1098
|
+
if (this.#canvas.style.visibility !== "visible") {
|
|
1099
|
+
this.#canvas.style.visibility = "visible";
|
|
696
1100
|
}
|
|
697
1101
|
const locale = this.#host.getCulture?.();
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
if (
|
|
702
|
-
|
|
1102
|
+
this.#ensureRenderer(locale);
|
|
1103
|
+
this.#updateCursor();
|
|
1104
|
+
const renderer = this.#renderer;
|
|
1105
|
+
if (locale !== this.#locale) {
|
|
1106
|
+
this.#applyLocale(locale);
|
|
1107
|
+
renderer.setLabels(this.#getLabels(locale));
|
|
1108
|
+
this.#colorsReadAt = null;
|
|
1109
|
+
this.#dirty = true;
|
|
1110
|
+
}
|
|
1111
|
+
const colors = this.#readColors();
|
|
1112
|
+
if (colors) {
|
|
1113
|
+
renderer.setColors(colors);
|
|
1114
|
+
this.#dirty = true;
|
|
703
1115
|
}
|
|
704
|
-
this.#geometryKey = geometryKey;
|
|
705
|
-
this.#locale = locale;
|
|
706
|
-
const pose = this.#readPose(camera);
|
|
707
|
-
const view = this.#rotationMatrix(pose.rotation).invert();
|
|
708
1116
|
const rightHanded = camera.getScene().useRightHandedSystem;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1117
|
+
if (this.#updateGeometryKey(camera.getViewMatrix().m, camera.mode, rightHanded)) {
|
|
1118
|
+
const pose = this.#readPose(camera);
|
|
1119
|
+
const rotation = this.#rotationMatrix(pose.rotation);
|
|
1120
|
+
const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, rightHanded ? 1 : -1), rotation).normalize();
|
|
1121
|
+
// At the poles alpha is undefined and the horizontal part is numerical noise: snap it to
|
|
1122
|
+
// zero so the previous alpha is kept instead of spinning the cube arbitrarily.
|
|
1123
|
+
const horizontal = Math.hypot(towardObserver.x, towardObserver.z) < POLE_SNAP ? { x: 0, y: towardObserver.y, z: 0 } : towardObserver;
|
|
1124
|
+
const angles = anglesFromDirection(horizontal, this.#alpha);
|
|
1125
|
+
if (angles) {
|
|
1126
|
+
this.#alpha = angles.alpha;
|
|
1127
|
+
renderer.setPose(angles);
|
|
718
1128
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (geometryChanged) {
|
|
723
|
-
this.#root.dataset.projection = perspective ? "perspective" : "orthographic";
|
|
1129
|
+
const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
|
|
1130
|
+
renderer.setCurrent(nearest.id);
|
|
1131
|
+
this.#root.dataset.projection = camera.mode === 1 ? "orthographic" : "perspective";
|
|
724
1132
|
this.#root.dataset.currentRegion = nearest.id;
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
this.#svg.setAttribute("aria-label", I18n.translate("prefViewer.viewCube.title", { locale }));
|
|
730
|
-
}
|
|
731
|
-
for (const r of this.#regions) {
|
|
732
|
-
const node = this.#nodes.get(r.id);
|
|
733
|
-
const parts = r.id.split(",").map(Number);
|
|
734
|
-
const names = parts
|
|
735
|
-
.map((s, i) =>
|
|
736
|
-
s
|
|
737
|
-
? I18n.translate(
|
|
738
|
-
`prefViewer.viewCube.${
|
|
739
|
-
[
|
|
740
|
-
["right", "left"],
|
|
741
|
-
["bottom", "top"],
|
|
742
|
-
["back", "front"],
|
|
743
|
-
][i][s > 0 ? 1 : 0]
|
|
744
|
-
}`,
|
|
745
|
-
{ locale },
|
|
746
|
-
)
|
|
747
|
-
: null,
|
|
748
|
-
)
|
|
749
|
-
.filter(Boolean);
|
|
750
|
-
let visible = false;
|
|
751
|
-
const outlineSegments = [];
|
|
752
|
-
for (const surface of node.surfaces) {
|
|
753
|
-
const { shown, points } = geometryChanged ? BabylonJSViewCubeController.#drawSurface(surface, projection) : { shown: surface.polygon.style.display !== "none", points: [] };
|
|
754
|
-
visible ||= shown;
|
|
755
|
-
if (lockChanged || geometryChanged) {
|
|
756
|
-
surface.polygon.style.pointerEvents = locked ? "none" : "visiblePainted";
|
|
757
|
-
}
|
|
758
|
-
if (geometryChanged) {
|
|
759
|
-
surface.polygon.toggleAttribute("data-current", r.id === nearest.id);
|
|
760
|
-
}
|
|
761
|
-
if (localeChanged) {
|
|
762
|
-
surface.polygon.setAttribute("aria-label", names.join(" / "));
|
|
763
|
-
}
|
|
764
|
-
if (geometryChanged && shown) {
|
|
765
|
-
outlineSegments.push(`M${points.map((p) => `${p.x},${p.y}`).join(" L")} Z`);
|
|
766
|
-
}
|
|
767
|
-
if (geometryChanged && r.kind === "face") {
|
|
768
|
-
node.label.setAttribute("x", String(points.reduce((sum, v) => sum + v.x, 0) / points.length));
|
|
769
|
-
node.label.setAttribute("y", String(points.reduce((sum, v) => sum + v.y, 0) / points.length));
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
if (geometryChanged) {
|
|
773
|
-
node.group.style.display = visible ? "" : "none";
|
|
774
|
-
node.outline.setAttribute("d", outlineSegments.join(" "));
|
|
775
|
-
}
|
|
776
|
-
if (localeChanged) {
|
|
777
|
-
node.label.textContent = r.kind === "face" ? names[0] : "";
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
// Camera motion can move hit regions under a stationary mouse.
|
|
781
|
-
if (geometryChanged && this.#pointer && Number.isFinite(this.#pointer.x + this.#pointer.y)) {
|
|
782
|
-
const hit = this.#root.getRootNode().elementFromPoint?.(this.#pointer.x, this.#pointer.y);
|
|
783
|
-
if (hit) {
|
|
784
|
-
this.#setHover(this.#root.contains(hit) ? (hit.closest?.("polygon[data-region]")?.dataset.region ?? null) : null);
|
|
1133
|
+
this.#dirty = true;
|
|
1134
|
+
// Camera motion can move hit regions under a stationary pointer.
|
|
1135
|
+
if (this.#pointer) {
|
|
1136
|
+
this.#setHover(this.#pick(this.#pointer));
|
|
785
1137
|
}
|
|
786
1138
|
}
|
|
1139
|
+
this.#renderDirty();
|
|
787
1140
|
} finally {
|
|
788
1141
|
this.#syncing = false;
|
|
789
1142
|
}
|
|
790
1143
|
}
|
|
791
1144
|
|
|
792
1145
|
/**
|
|
793
|
-
* Idempotently cancels navigation and releases listeners, camera/resize observers and overlay DOM.
|
|
1146
|
+
* Idempotently cancels navigation and releases listeners, camera/resize observers, the renderer and overlay DOM.
|
|
794
1147
|
* Reports zero layout footprint without disposing the host camera, scene or model assets.
|
|
795
1148
|
* @public
|
|
796
1149
|
* @returns {void}
|
|
@@ -804,8 +1157,11 @@ export default class BabylonJSViewCubeController {
|
|
|
804
1157
|
this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
|
|
805
1158
|
this.#resizeObserver?.disconnect();
|
|
806
1159
|
this.#viewportResizeObserver?.disconnect();
|
|
1160
|
+
this.#stopPixelRatioWatch?.();
|
|
1161
|
+
this.#stopPixelRatioWatch = null;
|
|
807
1162
|
this.#listeners.forEach((remove) => remove());
|
|
808
1163
|
this.#listeners.length = 0;
|
|
1164
|
+
this.#disposeRenderer();
|
|
809
1165
|
this.#root.remove();
|
|
810
1166
|
this.#host.onLayout?.(0, 0);
|
|
811
1167
|
}
|