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