@preference-sl/pref-viewer 2.16.2 → 2.17.0-beta.1
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 +184 -22
- package/index.d.ts +11 -1
- package/package.json +1 -1
- package/src/babylonjs-animation-controller.js +12 -163
- package/src/babylonjs-animation-opening-menu.js +8 -1
- package/src/babylonjs-animation-opening.js +21 -1
- package/src/babylonjs-animation-overlay-controller.js +145 -0
- package/src/babylonjs-camera-framing.js +171 -0
- package/src/babylonjs-camera-zoom-controller.js +272 -0
- package/src/babylonjs-controller.js +640 -378
- package/src/babylonjs-selection-controller.js +215 -0
- package/src/babylonjs-view-cube-controller.js +810 -0
- package/src/file-storage.js +13 -0
- package/src/gltf-storage.js +97 -0
- package/src/localization/i18n.js +147 -74
- package/src/localization/translations.js +18 -0
- package/src/material-utils.js +67 -60
- package/src/panzoom-controller.js +14 -1
- package/src/pref-viewer-2d.js +16 -4
- package/src/pref-viewer-3d-data.js +166 -0
- package/src/pref-viewer-3d.js +40 -5
- package/src/pref-viewer-dialog.js +7 -1
- package/src/pref-viewer-menu-3d.js +57 -37
- package/src/pref-viewer.js +155 -44
- package/src/render-math.js +130 -117
- package/src/styles.js +94 -3
- package/src/svg-resolver.js +5 -4
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
|
|
2
|
+
import { PrefViewerViewCubeStyles } from "./styles.js";
|
|
3
|
+
import I18n from "./localization/i18n.js";
|
|
4
|
+
import BabylonJSController from "./babylonjs-controller.js";
|
|
5
|
+
import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {import("./babylonjs-camera-framing.js").CameraFramingBounds} ViewCubeBounds
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One planar surface of a sharp-cube interaction region.
|
|
17
|
+
* @typedef {Object} ViewCubeSurface
|
|
18
|
+
* @property {Vector3} normal - Outward cube-local normal.
|
|
19
|
+
* @property {Vector3[]} vertices - Four cube-local polygon vertices.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Face, edge or corner orientation preset and its clickable geometry.
|
|
24
|
+
* @typedef {Object} ViewCubeRegion
|
|
25
|
+
* @property {string} id - Comma-separated signed axes identifying the preset.
|
|
26
|
+
* @property {Vector3} direction - Normalized direction from target toward observer.
|
|
27
|
+
* @property {"face"|"edge"|"corner"} kind - Interaction region category.
|
|
28
|
+
* @property {ViewCubeSurface[]} surfaces - One, two or three clickable planar surfaces.
|
|
29
|
+
* @property {ViewCubeSurface} [outline] - Full shell face, supplied only for face presets.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {import("./babylonjs-camera-framing.js").CameraFramingFit} ViewCubeFit
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* World-space pose snapshot for transition interpolation.
|
|
38
|
+
* @typedef {Object} ViewCubePose
|
|
39
|
+
* @property {Vector3} position - World-space camera position.
|
|
40
|
+
* @property {Quaternion} rotation - World-space camera orientation.
|
|
41
|
+
* @property {Vector3} target - Effective world-space target.
|
|
42
|
+
* @property {number} distance - Positive camera-to-target world distance.
|
|
43
|
+
* @property {number|null} left - Current orthographic left extent.
|
|
44
|
+
* @property {number|null} right - Current orthographic right extent.
|
|
45
|
+
* @property {number|null} bottom - Current orthographic bottom extent.
|
|
46
|
+
* @property {number|null} top - Current orthographic top extent.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Virtual camera inputs used to project cube surfaces into SVG.
|
|
51
|
+
* @typedef {Object} ViewCubeProjection
|
|
52
|
+
* @property {Matrix} view - Cube-local to virtual camera rotation matrix.
|
|
53
|
+
* @property {boolean} perspective - Whether to apply perspective scaling.
|
|
54
|
+
* @property {number} depthSign - Scene handedness depth direction.
|
|
55
|
+
* @property {number} eyeDistance - Virtual camera distance independent of model zoom.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* BabylonJSViewCubeController - Owns an SVG orientation indicator and model-framing camera navigation.
|
|
60
|
+
*
|
|
61
|
+
* Displays 26 face/edge/corner presets with primary-color hover and nearest-view feedback.
|
|
62
|
+
* Synchronizes camera projection and orientation while locked cameras remain display-only.
|
|
63
|
+
* Accepted clicks preserve projection, fit only model bounds and retain world +Y as camera up.
|
|
64
|
+
*
|
|
65
|
+
* The host owns the camera, scene, assets and render loop. It calls tick() to advance transitions
|
|
66
|
+
* and sync() to refresh the indicator. The cube requests rendering but allocates no Engine,
|
|
67
|
+
* Scene or asset meshes. dispose() releases only the overlay and its listeners/observers.
|
|
68
|
+
*
|
|
69
|
+
* Members are ordered as properties, constructor, static methods, private methods and public methods.
|
|
70
|
+
* @class
|
|
71
|
+
*/
|
|
72
|
+
export default class BabylonJSViewCubeController {
|
|
73
|
+
static #SVG_NS = "http://www.w3.org/2000/svg";
|
|
74
|
+
|
|
75
|
+
#cameraDistances = new WeakMap();
|
|
76
|
+
#host;
|
|
77
|
+
#root;
|
|
78
|
+
#svg;
|
|
79
|
+
#regions = BabylonJSViewCubeController.createViewCubeRegions();
|
|
80
|
+
#nodes = new Map();
|
|
81
|
+
#shell = [];
|
|
82
|
+
#geometryKey = null;
|
|
83
|
+
#locale;
|
|
84
|
+
#locked;
|
|
85
|
+
#enabled = false;
|
|
86
|
+
#transition = null;
|
|
87
|
+
#camera = null;
|
|
88
|
+
#observer = null;
|
|
89
|
+
#syncing = false;
|
|
90
|
+
#disposed = false;
|
|
91
|
+
#hover = null;
|
|
92
|
+
#pointer = null;
|
|
93
|
+
#listeners = [];
|
|
94
|
+
#resizeObserver;
|
|
95
|
+
#viewportResizeObserver;
|
|
96
|
+
#layoutKey = null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates the SVG overlay, attaches input/resize observers and enables the cube.
|
|
100
|
+
* The host retains ownership of the camera, scene, assets and render loop. Callbacks resolve
|
|
101
|
+
* current state so camera/model replacement does not leave the cube with stale references.
|
|
102
|
+
*
|
|
103
|
+
* @public
|
|
104
|
+
* @param {Object} host - Internal integration contract supplied by the owning viewer.
|
|
105
|
+
* @param {HTMLCanvasElement} host.canvas - Connected scene canvas; its parent receives the cube overlay.
|
|
106
|
+
* @param {function(): (import("@babylonjs/core").Camera|null|undefined)} host.getCamera - Returns the effective active camera, or no camera while unavailable.
|
|
107
|
+
* @param {function(import("@babylonjs/core").Camera|null|undefined): boolean} host.isLocked - Reports whether navigation is forbidden for the supplied camera, including unavailable cameras or active XR.
|
|
108
|
+
* @param {function(): (ViewCubeBounds|null)} host.getBounds - Returns current model-only world bounds and their eight corners; null when geometry is unusable. Excludes the environment.
|
|
109
|
+
* @param {function(): {width: number, height: number}} host.getViewport - Returns the current scene viewport dimensions in CSS pixels.
|
|
110
|
+
* @param {function(): void} host.requestRender - Requests rendering through the host scheduler after accepted navigation.
|
|
111
|
+
* @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 SVG viewport height and scene viewport height, in CSS pixels; zero cube height clears the menu offset, and disposal reports (0, 0).
|
|
113
|
+
*/
|
|
114
|
+
constructor(host) {
|
|
115
|
+
this.#host = host;
|
|
116
|
+
const doc = host.canvas.ownerDocument;
|
|
117
|
+
this.#root = doc.createElement("div");
|
|
118
|
+
this.#root.className = "pref-viewer-view-cube";
|
|
119
|
+
this.#root.style.cssText = "position:absolute;right:0px;top:0px;width:100px;height:100px;z-index:900;pointer-events:none";
|
|
120
|
+
const style = doc.createElement("style");
|
|
121
|
+
style.textContent = PrefViewerViewCubeStyles;
|
|
122
|
+
this.#root.append(style);
|
|
123
|
+
this.#svg = doc.createElementNS(BabylonJSViewCubeController.#SVG_NS, "svg");
|
|
124
|
+
this.#svg.setAttribute("viewBox", "0 0 120 120");
|
|
125
|
+
this.#svg.setAttribute("role", "group");
|
|
126
|
+
this.#root.append(this.#svg);
|
|
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
|
+
}
|
|
155
|
+
host.canvas.parentElement.append(this.#root);
|
|
156
|
+
this.#addListener(this.#svg, "pointermove", (event) => {
|
|
157
|
+
this.#pointer = { x: event.clientX, y: event.clientY };
|
|
158
|
+
this.#setHover(BabylonJSViewCubeController.#getRegionAt(event));
|
|
159
|
+
});
|
|
160
|
+
this.#addListener(this.#svg, "pointerleave", () => {
|
|
161
|
+
this.#pointer = null;
|
|
162
|
+
this.#setHover(null);
|
|
163
|
+
});
|
|
164
|
+
this.#addListener(this.#svg, "pointerdown", (event) => {
|
|
165
|
+
if (BabylonJSViewCubeController.#getRegionAt(event)) {
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
this.#addListener(this.#svg, "click", (event) => {
|
|
170
|
+
const id = BabylonJSViewCubeController.#getRegionAt(event);
|
|
171
|
+
if (!id) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
event.stopPropagation();
|
|
175
|
+
if (event.button === 0) {
|
|
176
|
+
this.select(id);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
for (const event of ["pointerdown", "wheel", "keydown"]) {
|
|
180
|
+
this.#addListener(host.canvas, event, () => this.cancel());
|
|
181
|
+
}
|
|
182
|
+
this.#resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.resize());
|
|
183
|
+
this.#resizeObserver?.observe(host.canvas);
|
|
184
|
+
this.#viewportResizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.#publishLayout());
|
|
185
|
+
this.#viewportResizeObserver?.observe(this.#svg);
|
|
186
|
+
this.setEnabled(true);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates the 26 signed-axis presets and their sharp-cube interaction surfaces.
|
|
191
|
+
* Edges share two prism surfaces; corners share three surfaces. Face outlines form the shell.
|
|
192
|
+
* @public
|
|
193
|
+
* @static
|
|
194
|
+
* @returns {ViewCubeRegion[]} Six face, twelve edge and eight corner regions with normalized directions.
|
|
195
|
+
*/
|
|
196
|
+
static createViewCubeRegions() {
|
|
197
|
+
const regions = [];
|
|
198
|
+
const inset = 0.78;
|
|
199
|
+
for (let i = 0; i < 3; i++) {
|
|
200
|
+
for (const s of [-1, 1]) {
|
|
201
|
+
const axis = [0, 0, 0];
|
|
202
|
+
axis[i] = s;
|
|
203
|
+
BabylonJSViewCubeController.#addRegion(regions, axis, [
|
|
204
|
+
BabylonJSViewCubeController.#createSurface(i, s, [
|
|
205
|
+
[-inset, inset],
|
|
206
|
+
[-inset, inset],
|
|
207
|
+
[-inset, inset],
|
|
208
|
+
]),
|
|
209
|
+
]);
|
|
210
|
+
regions.at(-1).outline = BabylonJSViewCubeController.#createSurface(i, s, [
|
|
211
|
+
[-1, 1],
|
|
212
|
+
[-1, 1],
|
|
213
|
+
[-1, 1],
|
|
214
|
+
]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (let i = 0; i < 3; i++) {
|
|
218
|
+
for (let j = i + 1; j < 3; j++) {
|
|
219
|
+
for (const s of [-1, 1]) {
|
|
220
|
+
for (const t of [-1, 1]) {
|
|
221
|
+
const axis = [0, 0, 0];
|
|
222
|
+
axis[i] = s;
|
|
223
|
+
axis[j] = t;
|
|
224
|
+
const ranges = [
|
|
225
|
+
[-inset, inset],
|
|
226
|
+
[-inset, inset],
|
|
227
|
+
[-inset, inset],
|
|
228
|
+
];
|
|
229
|
+
ranges[i] = [s * inset, s];
|
|
230
|
+
ranges[j] = [t * inset, t];
|
|
231
|
+
BabylonJSViewCubeController.#addRegion(regions, axis, [BabylonJSViewCubeController.#createSurface(i, s, ranges), BabylonJSViewCubeController.#createSurface(j, t, ranges)]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const x of [-1, 1]) {
|
|
237
|
+
for (const y of [-1, 1]) {
|
|
238
|
+
for (const z of [-1, 1]) {
|
|
239
|
+
const signs = [x, y, z];
|
|
240
|
+
const ranges = signs.map((s) => [s * inset, s]);
|
|
241
|
+
BabylonJSViewCubeController.#addRegion(regions,
|
|
242
|
+
signs,
|
|
243
|
+
signs.map((s, i) => BabylonJSViewCubeController.#createSurface(i, s, ranges)),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return regions;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Creates a signed-axis quad with its normal and four cube-space vertices.
|
|
253
|
+
* @private
|
|
254
|
+
* @static
|
|
255
|
+
* @param {number} axis - Fixed coordinate axis index.
|
|
256
|
+
* @param {number} sign - Fixed axis direction, -1 or 1.
|
|
257
|
+
* @param {Array<number[]>} ranges - Lower and upper coordinates for each axis.
|
|
258
|
+
* @returns {{normal:Vector3,vertices:Vector3[]}}
|
|
259
|
+
*/
|
|
260
|
+
static #createSurface(axis, sign, ranges) {
|
|
261
|
+
const other = [0, 1, 2].filter((i) => i !== axis);
|
|
262
|
+
const normalComponents = [0, 0, 0];
|
|
263
|
+
normalComponents[axis] = sign;
|
|
264
|
+
const normal = new Vector3(...normalComponents);
|
|
265
|
+
const vertices = [
|
|
266
|
+
[0, 0],
|
|
267
|
+
[1, 0],
|
|
268
|
+
[1, 1],
|
|
269
|
+
[0, 1],
|
|
270
|
+
].map(([u, v]) => {
|
|
271
|
+
const p = [0, 0, 0];
|
|
272
|
+
p[axis] = sign;
|
|
273
|
+
p[other[0]] = ranges[other[0]][u];
|
|
274
|
+
p[other[1]] = ranges[other[1]][v];
|
|
275
|
+
return new Vector3(...p);
|
|
276
|
+
});
|
|
277
|
+
return { normal, vertices };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Adds a face, edge or corner preset from its signed-axis vector and clickable surfaces.
|
|
282
|
+
* @private
|
|
283
|
+
* @static
|
|
284
|
+
* @param {ViewCubeRegion[]} regions - Destination region collection.
|
|
285
|
+
* @param {number[]} axis - Signed direction components.
|
|
286
|
+
* @param {Array<{normal:Vector3,vertices:Vector3[]}>} surfaces - Surfaces sharing that direction.
|
|
287
|
+
* @returns {void}
|
|
288
|
+
*/
|
|
289
|
+
static #addRegion(regions, axis, surfaces) {
|
|
290
|
+
const direction = new Vector3(...axis).normalize();
|
|
291
|
+
regions.push({ id: axis.join(","), direction, surfaces, kind: ["", "face", "edge", "corner"][axis.filter(Boolean).length] });
|
|
292
|
+
}
|
|
293
|
+
|
|
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
|
+
/**
|
|
306
|
+
* Interpolates an orthographic extent from its start or fallback using the current eased transition.
|
|
307
|
+
* @private
|
|
308
|
+
* @static
|
|
309
|
+
* @param {number|null} start - Initial extent.
|
|
310
|
+
* @param {number} end - Destination extent.
|
|
311
|
+
* @param {number} fallback - Initial extent when absent.
|
|
312
|
+
* @param {number} eased - Eased transition fraction from 0 to 1.
|
|
313
|
+
* @returns {number}
|
|
314
|
+
*/
|
|
315
|
+
static #interpolateExtent(start, end, fallback, eased) {
|
|
316
|
+
return (start ?? fallback) + (end - (start ?? fallback)) * eased;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Projects a cube-space vertex into the overlay SVG using the effective camera projection.
|
|
321
|
+
* @private
|
|
322
|
+
* @static
|
|
323
|
+
* @param {Vector3} vertex - Cube-space vertex.
|
|
324
|
+
* @param {ViewCubeProjection} projection - Current virtual cube projection.
|
|
325
|
+
* @returns {{x:number,y:number}}
|
|
326
|
+
*/
|
|
327
|
+
static #projectVertex(vertex, projection) {
|
|
328
|
+
const { view, perspective, depthSign, eyeDistance } = projection;
|
|
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 };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Updates surface visibility and projected polygon points for the current camera orientation.
|
|
336
|
+
* @private
|
|
337
|
+
* @static
|
|
338
|
+
* @param {object} surface - Surface containing its normal, vertices and SVG polygon.
|
|
339
|
+
* @param {ViewCubeProjection} projection - Current virtual cube projection.
|
|
340
|
+
* @returns {{shown:boolean,points:Array<{x:number,y:number}>}}
|
|
341
|
+
*/
|
|
342
|
+
static #drawSurface(surface, projection) {
|
|
343
|
+
const { view, perspective, depthSign, eyeDistance } = projection;
|
|
344
|
+
const normal = Vector3.TransformNormal(surface.normal, view);
|
|
345
|
+
const shown = perspective ? -normal.z * depthSign * eyeDistance > 1.001 : normal.z * depthSign < -0.001;
|
|
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 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Converts a quaternion into a new rotation matrix without changing the quaternion.
|
|
354
|
+
* @private
|
|
355
|
+
* @param {Quaternion} q - Camera orientation.
|
|
356
|
+
* @returns {Matrix} Rotation matrix.
|
|
357
|
+
*/
|
|
358
|
+
#rotationMatrix(q) {
|
|
359
|
+
const matrix = Matrix.Identity();
|
|
360
|
+
q.toRotationMatrix(matrix);
|
|
361
|
+
return matrix;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Identifies the ArcRotateCamera adapter by its Babylon class name.
|
|
366
|
+
* @private
|
|
367
|
+
* @param {ViewCubeCamera|null|undefined} camera - Camera to inspect.
|
|
368
|
+
* @returns {boolean} Whether the camera uses orbital controls.
|
|
369
|
+
*/
|
|
370
|
+
#isOrbit(camera) {
|
|
371
|
+
return camera?.getClassName?.() === "ArcRotateCamera";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Reads the effective world-space camera pose and current orthographic extents.
|
|
376
|
+
* Transforms orbit targets through the parent; other cameras use the remembered distance or one unit.
|
|
377
|
+
* @private
|
|
378
|
+
* @param {ViewCubeCamera} camera - Camera whose view matrix will be refreshed.
|
|
379
|
+
* @returns {ViewCubePose} Snapshot used for synchronization and transition interpolation.
|
|
380
|
+
*/
|
|
381
|
+
#readPose(camera) {
|
|
382
|
+
const world = camera.getViewMatrix(true).clone().invert();
|
|
383
|
+
const position = Vector3.Zero();
|
|
384
|
+
const rotation = Quaternion.Identity();
|
|
385
|
+
world.decompose(undefined, rotation, position);
|
|
386
|
+
const orbitTarget = this.#isOrbit(camera) ? camera.getTarget().clone() : null;
|
|
387
|
+
const target = orbitTarget ? (camera.parent ? Vector3.TransformCoordinates(orbitTarget, camera.parent.getWorldMatrix()) : orbitTarget) : position.add(camera.getForwardRay().direction.scale(this.#cameraDistances.get(camera) ?? 1));
|
|
388
|
+
return { position, rotation, target, distance: Math.max(Vector3.Distance(position, target), 0.0001), left: camera.orthoLeft, right: camera.orthoRight, bottom: camera.orthoBottom, top: camera.orthoTop };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Clears orbital, translational and rotational inertia before explicit cube navigation.
|
|
393
|
+
* @private
|
|
394
|
+
* @param {ViewCubeCamera} camera - Camera whose pending movement is cleared.
|
|
395
|
+
* @returns {void}
|
|
396
|
+
*/
|
|
397
|
+
#clearInertia(camera) {
|
|
398
|
+
for (const key of ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"]) {
|
|
399
|
+
if (key in camera) {
|
|
400
|
+
camera[key] = 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
camera.cameraDirection?.setAll(0);
|
|
404
|
+
camera.cameraRotation?.setAll(0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Applies a world-space position and target while retaining world +Y as camera up.
|
|
409
|
+
* Converts parented poses to local coordinates, clears inertia and adjusts clipping/navigation limits.
|
|
410
|
+
* Reads ViewCube radius factors from BabylonJSController.config.camera.
|
|
411
|
+
* Free/Universal cameras use target/Euler rotation to prevent quaternion-driven up-vector changes.
|
|
412
|
+
* The transition caller checks camera identity and lock before invoking this method.
|
|
413
|
+
* @private
|
|
414
|
+
* @param {ViewCubeCamera} camera - Camera receiving the accepted navigation pose.
|
|
415
|
+
* @param {Vector3} position - Interpolated world-space position.
|
|
416
|
+
* @param {Vector3} target - Interpolated world-space model target.
|
|
417
|
+
* @param {number} distance - Interpolated camera-to-target distance.
|
|
418
|
+
* @param {ViewCubeFit} fit - Destination bounds/framing used to adjust clipping.
|
|
419
|
+
* @returns {void}
|
|
420
|
+
*/
|
|
421
|
+
#writePose(camera, position, target, distance, fit) {
|
|
422
|
+
const parentWorld = camera.parent?.getWorldMatrix?.();
|
|
423
|
+
const parentInverse = parentWorld?.clone().invert();
|
|
424
|
+
const localPosition = parentInverse ? Vector3.TransformCoordinates(position, parentInverse) : position;
|
|
425
|
+
const upWorld = Vector3.Up();
|
|
426
|
+
camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
|
|
427
|
+
this.#clearInertia(camera);
|
|
428
|
+
if (this.#isOrbit(camera)) {
|
|
429
|
+
// Cube selection explicitly overrides incompatible orbit constraints on this camera only.
|
|
430
|
+
camera.lowerAlphaLimit = null;
|
|
431
|
+
camera.upperAlphaLimit = null;
|
|
432
|
+
camera.lowerBetaLimit = null;
|
|
433
|
+
camera.upperBetaLimit = null;
|
|
434
|
+
camera.allowUpsideDown = false;
|
|
435
|
+
camera.lowerRadiusLimit = Math.min(distance * BabylonJSController.config.camera.minRadiusFactor, camera.lowerRadiusLimit ?? Infinity);
|
|
436
|
+
camera.upperRadiusLimit = Math.max(distance * BabylonJSController.config.camera.maxRadiusFactor, camera.upperRadiusLimit ?? 0);
|
|
437
|
+
camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
|
|
438
|
+
camera.setPosition(localPosition);
|
|
439
|
+
} else {
|
|
440
|
+
camera.position.copyFrom(localPosition);
|
|
441
|
+
// Quaternion changes make TargetCamera rotate its upVector. Its target/Euler API keeps roll zero.
|
|
442
|
+
camera.rotationQuaternion = null;
|
|
443
|
+
camera.updateUpVectorFromRotation = false;
|
|
444
|
+
camera.setTarget(parentInverse ? Vector3.TransformCoordinates(target, parentInverse) : target);
|
|
445
|
+
// Flush Babylon's initial roll cache before restoring the fixed world-up vector.
|
|
446
|
+
camera.getViewMatrix(true);
|
|
447
|
+
camera.upVector = parentInverse ? Vector3.TransformNormal(upWorld, parentInverse).normalize() : upWorld;
|
|
448
|
+
this.#cameraDistances.set(camera, distance);
|
|
449
|
+
}
|
|
450
|
+
camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
|
|
451
|
+
camera.maxZ = Math.max(camera.maxZ, distance + fit.radius * 2);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Publishes measured cube viewport height and scene height when either changes.
|
|
456
|
+
* Disabled cubes report zero height; an inline-size fallback supports DOMs without layout measurements.
|
|
457
|
+
* @private
|
|
458
|
+
* @returns {void}
|
|
459
|
+
*/
|
|
460
|
+
#publishLayout() {
|
|
461
|
+
if (this.#disposed) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const viewportHeight = this.#svg.getBoundingClientRect().height || parseFloat(this.#root.style.height) || 0;
|
|
465
|
+
const cubeHeight = this.#enabled ? viewportHeight : 0;
|
|
466
|
+
const sceneHeight = this.#host.getViewport().height;
|
|
467
|
+
const key = `${cubeHeight},${sceneHeight}`;
|
|
468
|
+
if (key === this.#layoutKey) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.#layoutKey = key;
|
|
472
|
+
this.#host.onLayout?.(cubeHeight, sceneHeight);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Registers an overlay or canvas listener and retains its removal callback for disposal.
|
|
477
|
+
* @private
|
|
478
|
+
* @param {EventTarget} element - Event source.
|
|
479
|
+
* @param {string} name - DOM event name.
|
|
480
|
+
* @param {EventListener} handler - Handler to register.
|
|
481
|
+
* @returns {void}
|
|
482
|
+
*/
|
|
483
|
+
#addListener(element, name, handler) {
|
|
484
|
+
element.addEventListener(name, handler);
|
|
485
|
+
this.#listeners.push(() => element.removeEventListener(name, handler));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Updates hover feedback for every surface and the outline of one region.
|
|
490
|
+
* @private
|
|
491
|
+
* @param {string|null} key - Cube region id whose hover feedback is updated.
|
|
492
|
+
* @param {boolean} active - Whether hover feedback is active.
|
|
493
|
+
* @returns {void}
|
|
494
|
+
*/
|
|
495
|
+
#setRegionHover(key, active) {
|
|
496
|
+
const node = this.#nodes.get(key);
|
|
497
|
+
if (!node) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
for (const surface of node.surfaces) {
|
|
501
|
+
surface.polygon.toggleAttribute("data-hover", active);
|
|
502
|
+
}
|
|
503
|
+
node.outline.toggleAttribute("data-hover", active);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Updates only the old/new region feedback attributes without rebuilding geometry.
|
|
508
|
+
* Clears hover when disabled or locked; all surfaces of an edge or corner share the same state.
|
|
509
|
+
* @private
|
|
510
|
+
* @param {string|null} id - Signed-axis region identifier, or null to remove hover.
|
|
511
|
+
* @returns {void}
|
|
512
|
+
*/
|
|
513
|
+
#setHover(id) {
|
|
514
|
+
if (!this.#enabled || this.#host.isLocked(this.#host.getCamera())) {
|
|
515
|
+
id = null;
|
|
516
|
+
}
|
|
517
|
+
if (id === this.#hover) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
this.#setRegionHover(this.#hover, false);
|
|
522
|
+
this.#hover = id;
|
|
523
|
+
this.#setRegionHover(id, true);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Reports whether an accepted navigation transition is pending.
|
|
528
|
+
* @public
|
|
529
|
+
* @returns {boolean} Whether the owning renderer should continue advancing the transition.
|
|
530
|
+
*/
|
|
531
|
+
get isAnimating() {
|
|
532
|
+
return this.#transition !== null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Returns the overlay root for host integration or inspection.
|
|
537
|
+
* @public
|
|
538
|
+
* @returns {HTMLDivElement} Root element containing styles and the SVG.
|
|
539
|
+
*/
|
|
540
|
+
get element() {
|
|
541
|
+
return this.#root;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Changes cube visibility without reloading assets or replacing the camera.
|
|
546
|
+
* Disabling cancels navigation and clears hover; either state refreshes dimensions and synchronization.
|
|
547
|
+
* @public
|
|
548
|
+
* @param {boolean} enabled - Whether the cube should be visible and interactive when unlocked.
|
|
549
|
+
* @returns {void}
|
|
550
|
+
*/
|
|
551
|
+
setEnabled(enabled) {
|
|
552
|
+
if (this.#disposed) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
this.#enabled = Boolean(enabled);
|
|
556
|
+
this.#root.hidden = !this.#enabled;
|
|
557
|
+
if (!this.#enabled) {
|
|
558
|
+
this.cancel();
|
|
559
|
+
this.#pointer = null;
|
|
560
|
+
this.#setHover(null);
|
|
561
|
+
}
|
|
562
|
+
this.resize();
|
|
563
|
+
this.sync();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Fits the widget to a maximum of 100 x 100 CSS pixels inside the scene viewport and publishes its layout.
|
|
568
|
+
* Cancels obsolete navigation and synchronizes the indicator without changing the camera pose.
|
|
569
|
+
* @public
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
resize() {
|
|
573
|
+
if (this.#disposed) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
this.cancel();
|
|
577
|
+
const viewport = this.#host.getViewport();
|
|
578
|
+
const size = Math.max(0, Math.min(100, viewport.width, viewport.height));
|
|
579
|
+
this.#root.style.width = `${size}px`;
|
|
580
|
+
this.#root.style.height = `${size}px`;
|
|
581
|
+
this.#publishLayout();
|
|
582
|
+
this.sync();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Stops the pending transition at the displayed pose without reverting camera changes.
|
|
587
|
+
* @public
|
|
588
|
+
* @returns {void}
|
|
589
|
+
*/
|
|
590
|
+
cancel() {
|
|
591
|
+
this.#transition = null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Starts or retargets smooth navigation to a region using fresh model-only framing.
|
|
596
|
+
* Rejects disabled/disposed, locked, unsupported or unusable inputs without starting navigation.
|
|
597
|
+
* An accepted action clears inertia and asks the owning renderer to advance the transition.
|
|
598
|
+
* @public
|
|
599
|
+
* @param {string} id - Signed-axis preset identifier, for example "0,0,1" for Interior.
|
|
600
|
+
* @param {number} [now=performance.now()] - Start timestamp in milliseconds on the performance clock.
|
|
601
|
+
* @returns {boolean} Whether navigation was accepted.
|
|
602
|
+
*/
|
|
603
|
+
select(id, now = performance.now()) {
|
|
604
|
+
const camera = this.#host.getCamera();
|
|
605
|
+
const region = this.#regions.find((r) => r.id === id);
|
|
606
|
+
if (this.#disposed || !this.#enabled || !region || this.#host.isLocked(camera) || !BabylonJSCameraFraming.isCameraSupported(camera)) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const fit = BabylonJSCameraFraming.calculateCameraFit(camera, this.#host.getBounds(), region.direction, this.#host.getViewport());
|
|
610
|
+
if (!fit) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
this.#clearInertia(camera);
|
|
614
|
+
this.#transition = { camera, mode: camera.mode, start: this.#readPose(camera), fit, at: now };
|
|
615
|
+
this.#host.requestRender();
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Advances an accepted transition with smooth easing and preserved projection mode.
|
|
621
|
+
* Uses BabylonJSController.config.camera.transitionDuration as the duration in milliseconds.
|
|
622
|
+
* Rechecks active-camera identity, lock and projection before each write; invalidated transitions stop.
|
|
623
|
+
* The owning render loop calls this method; it does not start an independent animation loop.
|
|
624
|
+
* @public
|
|
625
|
+
* @param {number} [now=performance.now()] - Current timestamp in milliseconds on the performance clock.
|
|
626
|
+
* @returns {void}
|
|
627
|
+
*/
|
|
628
|
+
tick(now = performance.now()) {
|
|
629
|
+
const t = this.#transition;
|
|
630
|
+
if (!t) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (this.#host.getCamera() !== t.camera || this.#host.isLocked(t.camera) || t.camera.mode !== t.mode) {
|
|
634
|
+
this.cancel();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const p = Math.max(0, Math.min(1, (now - t.at) / BabylonJSController.config.camera.transitionDuration));
|
|
638
|
+
const eased = p * p * (3 - 2 * p);
|
|
639
|
+
const rotation = Quaternion.Slerp(t.start.rotation, t.fit.rotation, eased);
|
|
640
|
+
const target = Vector3.Lerp(t.start.target, t.fit.target, eased);
|
|
641
|
+
const distance = t.start.distance + (t.fit.distance - t.start.distance) * eased;
|
|
642
|
+
const forward = Vector3.TransformNormal(new Vector3(0, 0, t.camera.getScene().useRightHandedSystem ? -1 : 1), this.#rotationMatrix(rotation));
|
|
643
|
+
const position = target.subtract(forward.scale(distance));
|
|
644
|
+
this.#writePose(t.camera, position, target, distance, t.fit);
|
|
645
|
+
if (t.mode === 1) {
|
|
646
|
+
|
|
647
|
+
const viewport = this.#host.getViewport();
|
|
648
|
+
t.camera.orthoLeft = BabylonJSViewCubeController.#interpolateExtent(t.start.left, -t.fit.halfWidth, -viewport.width / 2, eased);
|
|
649
|
+
t.camera.orthoRight = BabylonJSViewCubeController.#interpolateExtent(t.start.right, t.fit.halfWidth, viewport.width / 2, eased);
|
|
650
|
+
t.camera.orthoBottom = BabylonJSViewCubeController.#interpolateExtent(t.start.bottom, -t.fit.halfHeight, -viewport.height / 2, eased);
|
|
651
|
+
t.camera.orthoTop = BabylonJSViewCubeController.#interpolateExtent(t.start.top, t.fit.halfHeight, viewport.height / 2, eased);
|
|
652
|
+
}
|
|
653
|
+
if (p === 1) {
|
|
654
|
+
this.cancel();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Synchronizes projection, nearest-orientation feedback, labels and lock state from the active camera.
|
|
660
|
+
* Rebinds the view-matrix observer on replacement and guards against recursive notifications.
|
|
661
|
+
* Reprojects SVG geometry only for camera pose/projection changes; hover and localization reuse it.
|
|
662
|
+
* The virtual cube camera follows orientation/FOV independently of the model zoom.
|
|
663
|
+
* @public
|
|
664
|
+
* @returns {void}
|
|
665
|
+
*/
|
|
666
|
+
sync() {
|
|
667
|
+
if (this.#disposed || !this.#enabled || this.#syncing) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.#syncing = true;
|
|
671
|
+
try {
|
|
672
|
+
const camera = this.#host.getCamera();
|
|
673
|
+
if (camera !== this.#camera) {
|
|
674
|
+
this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
|
|
675
|
+
this.#camera = camera;
|
|
676
|
+
this.#geometryKey = null;
|
|
677
|
+
this.#observer = camera?.onViewMatrixChangedObservable?.add(() => this.sync());
|
|
678
|
+
}
|
|
679
|
+
const locked = !BabylonJSCameraFraming.isCameraSupported(camera) || this.#host.isLocked(camera);
|
|
680
|
+
const lockChanged = locked !== this.#locked;
|
|
681
|
+
this.#locked = locked;
|
|
682
|
+
if (lockChanged) {
|
|
683
|
+
this.#root.dataset.locked = String(locked);
|
|
684
|
+
}
|
|
685
|
+
if (locked) {
|
|
686
|
+
this.#setHover(null);
|
|
687
|
+
}
|
|
688
|
+
if (!camera?.getViewMatrix) {
|
|
689
|
+
this.#svg.style.visibility = "hidden";
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (this.#svg.style.visibility !== "visible") {
|
|
693
|
+
this.#svg.style.visibility = "visible";
|
|
694
|
+
}
|
|
695
|
+
const locale = this.#host.getCulture?.();
|
|
696
|
+
const localeChanged = locale !== this.#locale || this.#geometryKey === null;
|
|
697
|
+
const geometryKey = `${camera.getViewMatrix().m.join(",")}|${camera.mode}|${camera.fov}|${camera.getScene().useRightHandedSystem}`;
|
|
698
|
+
const geometryChanged = geometryKey !== this.#geometryKey;
|
|
699
|
+
if (!geometryChanged && !localeChanged && !lockChanged) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.#geometryKey = geometryKey;
|
|
703
|
+
this.#locale = locale;
|
|
704
|
+
const pose = this.#readPose(camera);
|
|
705
|
+
const view = this.#rotationMatrix(pose.rotation).invert();
|
|
706
|
+
const rightHanded = camera.getScene().useRightHandedSystem;
|
|
707
|
+
const depthSign = rightHanded ? -1 : 1;
|
|
708
|
+
const perspective = camera.mode !== 1;
|
|
709
|
+
const fov = Number.isFinite(camera.fov) && camera.fov > 0 && camera.fov < Math.PI ? camera.fov : 0.8;
|
|
710
|
+
// An isolated virtual eye follows the scene camera's orientation/FOV, not its model zoom.
|
|
711
|
+
const eyeDistance = 1.8 + 1.8 / Math.tan(fov / 2);
|
|
712
|
+
const projection = { view, perspective, depthSign, eyeDistance };
|
|
713
|
+
if (geometryChanged) {
|
|
714
|
+
for (const surface of this.#shell) {
|
|
715
|
+
BabylonJSViewCubeController.#drawSurface(surface, projection);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, -depthSign), this.#rotationMatrix(pose.rotation)).normalize();
|
|
719
|
+
const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
|
|
720
|
+
if (geometryChanged) {
|
|
721
|
+
this.#root.dataset.projection = perspective ? "perspective" : "orthographic";
|
|
722
|
+
this.#root.dataset.currentRegion = nearest.id;
|
|
723
|
+
}
|
|
724
|
+
// Draw full faces first, then the coplanar edge prisms and corner cubes.
|
|
725
|
+
// Visibility belongs to each physical face, not the semantic preset direction.
|
|
726
|
+
if (localeChanged) {
|
|
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);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
} finally {
|
|
786
|
+
this.#syncing = false;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Idempotently cancels navigation and releases listeners, camera/resize observers and overlay DOM.
|
|
792
|
+
* Reports zero layout footprint without disposing the host camera, scene or model assets.
|
|
793
|
+
* @public
|
|
794
|
+
* @returns {void}
|
|
795
|
+
*/
|
|
796
|
+
dispose() {
|
|
797
|
+
if (this.#disposed) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
this.cancel();
|
|
801
|
+
this.#disposed = true;
|
|
802
|
+
this.#camera?.onViewMatrixChangedObservable?.remove(this.#observer);
|
|
803
|
+
this.#resizeObserver?.disconnect();
|
|
804
|
+
this.#viewportResizeObserver?.disconnect();
|
|
805
|
+
this.#listeners.forEach((remove) => remove());
|
|
806
|
+
this.#listeners.length = 0;
|
|
807
|
+
this.#root.remove();
|
|
808
|
+
this.#host.onLayout?.(0, 0);
|
|
809
|
+
}
|
|
810
|
+
}
|