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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +507 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +7 -4
  22. package/src/gltf-resolver.js +13 -8
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +280 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
package/index.d.ts CHANGED
@@ -3,6 +3,9 @@ import type React from "react";
3
3
  /** Render mode. `loadConfig`/`loadDrawing` pick the matching one on their own. */
4
4
  export type PrefViewerMode = "2d" | "3d";
5
5
 
6
+ /** Selectable orientation widget; Canvas is the default and uses a dedicated Babylon scene; SVG remains explicitly selectable. */
7
+ export type PrefViewerViewCubeRenderer = "svg" | "canvas";
8
+
6
9
  /** Animation state as reported by `getAnimations()` and `prefviewer-animation-changed`. */
7
10
  export type PrefViewerAnimationState = 0 | 1 | 2 | 3 | 4;
8
11
 
@@ -27,14 +30,52 @@ export interface PrefViewerAsset {
27
30
  visible?: boolean;
28
31
  }
29
32
 
33
+ /** Partial style overrides. Null leaves/groups remove customizations; omitted leaves are preserved. */
34
+ export interface PrefViewerStyles {
35
+ colorPrimary?: string | null;
36
+ viewCube?: {
37
+ colors?: {
38
+ face?: string | null;
39
+ edge?: string | null;
40
+ corner?: string | null;
41
+ /** SVG outline only. */
42
+ border?: string | null;
43
+ text?: string | null;
44
+ hover?: string | null;
45
+ active?: string | null;
46
+ } | null;
47
+ hoverOpacity?: number | null;
48
+ activeOpacity?: number | null;
49
+ typography?: {
50
+ fontFamily?: string | null;
51
+ /** Positive reference pixels; scaled to Canvas texture resolution. */
52
+ fontSize?: number | null;
53
+ /** Integer from 1 to 1000. */
54
+ fontWeight?: number | null;
55
+ /** Nonnegative reference pixels. */
56
+ letterSpacing?: number | null;
57
+ textTransform?: "none" | "uppercase" | "lowercase" | null;
58
+ } | null;
59
+ } | null;
60
+ }
61
+
62
+ /** Optional origin-local persistence; configure before insertion to avoid storage reads when disabled. */
63
+ export interface PrefViewerStylesPersistence {
64
+ enabled?: boolean;
65
+ /** Exact localStorage key; defaults to pref-viewer/styles. */
66
+ key?: string;
67
+ }
68
+
30
69
  /** Payload for `loadConfig`. Pass the object; a JSON string is also accepted. */
31
70
  export interface PrefViewerConfig {
32
71
  /** Sets show-render-menu when supplied; omission preserves the current attribute. */
33
72
  showRenderMenu?: boolean;
34
73
  /** Sets show-view-cube when supplied; omission preserves the current attribute. */
35
74
  showViewCube?: boolean;
75
+ /** Selects the cube renderer when this queued configuration is processed; omission preserves it. */
76
+ viewCubeRenderer?: PrefViewerViewCubeRenderer;
36
77
  culture?: string;
37
- styles?: Record<string, unknown> | string;
78
+ styles?: PrefViewerStyles | string;
38
79
  model?: PrefViewerAsset;
39
80
  scene?: PrefViewerAsset;
40
81
  materials?: PrefViewerAsset;
@@ -222,6 +263,9 @@ export interface PrefViewerEventMap {
222
263
  }
223
264
 
224
265
  export declare class PrefViewer extends HTMLElement {
266
+ /** Reflected view-cube-renderer preference; invalid/missing values use canvas. */
267
+ get viewCubeRenderer(): PrefViewerViewCubeRenderer;
268
+ set viewCubeRenderer(value: PrefViewerViewCubeRenderer);
225
269
  // ── Property setters (each mirrors the attribute of the same name) ──
226
270
  set config(v: PrefViewerConfig | string | null);
227
271
  set culture(v: string | null);
@@ -232,8 +276,14 @@ export declare class PrefViewer extends HTMLElement {
232
276
  set model(v: PrefViewerAsset | string | null);
233
277
  set options(v: Record<string, unknown> | string | null);
234
278
  set scene(v: PrefViewerAsset | string | null);
235
- /** Supported style overrides; currently `{ colorPrimary }`. */
236
- set styles(v: Record<string, unknown> | string | null);
279
+ /** Detached merged overrides, including effective primary; omitted leaves use internal defaults. */
280
+ get styles(): PrefViewerStyles;
281
+ /** Same queued partial patch as setStyles. */
282
+ set styles(v: PrefViewerStyles | string);
283
+ get stylesPersistence(): Required<PrefViewerStylesPersistence>;
284
+ set stylesPersistence(v: PrefViewerStylesPersistence);
285
+ /** Clears only the configured saved entry, preserving appearance. False when disabled/unavailable. */
286
+ clearPersistedStyles(): boolean;
237
287
 
238
288
  // ── Read-only state ──
239
289
  get culture(): string;
@@ -256,9 +306,8 @@ export declare class PrefViewer extends HTMLElement {
256
306
  setOptions(options: Record<string, unknown> | string): void;
257
307
  /** Convenience wrapper for `setOptions({ ibl })`. False when the payload is unusable. */
258
308
  setIBL(ibl: Record<string, unknown> | string): boolean | void;
259
- /** Runtime style overrides; currently `{ colorPrimary }`. False when unusable. */
260
- /** Updates primary UI and animation-opening highlight color, including an active highlight. */
261
- setStyles(styles: Record<string, unknown> | string): boolean | void;
309
+ /** Queues validated primary/ViewCube overrides; false for a non-object payload, otherwise void. Malformed JSON throws. */
310
+ setStyles(styles: PrefViewerStyles | string): boolean | void;
262
311
  /** 3D runtime diagnostics, or null before the 3D component exists. */
263
312
  getDebugInfo(): Record<string, unknown> | null;
264
313
 
@@ -345,7 +394,13 @@ export declare class PrefViewer extends HTMLElement {
345
394
  * `materials`, `drawing`, `options`, `styles`) are JSON strings when written as attributes —
346
395
  * pass the objects through the matching method or property instead.
347
396
  */
397
+ /** Control sizing; auto follows primary coarse-pointer capability, independently of DPR. */
398
+ export type PrefViewerControlsDensity = "auto" | "compact" | "comfortable";
399
+
348
400
  export interface PrefViewerAttributes {
401
+ "view-cube-renderer"?: PrefViewerViewCubeRenderer;
402
+ /** Automatic or explicit control presentation; default auto. */
403
+ "controls-density"?: PrefViewerControlsDensity;
349
404
  config?: string;
350
405
  culture?: string;
351
406
  drawing?: string;
@@ -362,6 +417,17 @@ export interface PrefViewerAttributes {
362
417
  "show-view-cube"?: boolean | "true" | "false";
363
418
  }
364
419
 
420
+ /** Standalone 3D element renderer preference; the root owns nested instances. */
421
+ export interface PrefViewer3DElement extends HTMLElement {
422
+ viewCubeRenderer: PrefViewerViewCubeRenderer;
423
+ }
424
+
425
+ type PrefViewer3DJsxProps = React.DetailedHTMLProps<
426
+ React.HTMLAttributes<PrefViewer3DElement> & Pick<PrefViewerAttributes,
427
+ "view-cube-renderer" | "show-view-cube" | "show-model" | "show-scene" | "controls-density"> & { visible?: boolean | "true" | "false" },
428
+ PrefViewer3DElement
429
+ >;
430
+
365
431
  /** <pref-viewer mode="3d" style="display:block; width:100%; height:600px" /> */
366
432
  type PrefViewerJsxProps = React.DetailedHTMLProps<
367
433
  React.HTMLAttributes<PrefViewer> & PrefViewerAttributes,
@@ -371,12 +437,14 @@ type PrefViewerJsxProps = React.DetailedHTMLProps<
371
437
  declare global {
372
438
  interface HTMLElementTagNameMap {
373
439
  "pref-viewer": PrefViewer;
440
+ "pref-viewer-3d": PrefViewer3DElement;
374
441
  }
375
442
 
376
443
  // React <= 18 (@types/react 18) resolves intrinsics through the global JSX namespace.
377
444
  namespace JSX {
378
445
  interface IntrinsicElements {
379
446
  "pref-viewer": PrefViewerJsxProps;
447
+ "pref-viewer-3d": PrefViewer3DJsxProps;
380
448
  }
381
449
  }
382
450
  }
@@ -388,6 +456,7 @@ declare module "react" {
388
456
  namespace JSX {
389
457
  interface IntrinsicElements {
390
458
  "pref-viewer": PrefViewerJsxProps;
459
+ "pref-viewer-3d": PrefViewer3DJsxProps;
391
460
  }
392
461
  }
393
462
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.18.0-beta.1",
3
+ "version": "3.0.0-beta.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -10,7 +10,10 @@
10
10
  "release:beta": "standard-version --prerelease beta --releaseCommitMessageFormat \"chore(release): {{currentTag}} [skip ci]\"",
11
11
  "build": "esbuild src/index.js --bundle --platform=node --format=esm --outfile=dist/bundle.js --sourcemap",
12
12
  "start": "npm run build && http-server -c-1 . -p 8080",
13
- "test": "vitest"
13
+ "test": "vitest",
14
+ "test:e2e": "node tests/e2e/run.mjs test",
15
+ "test:e2e:install": "node tests/e2e/run.mjs install webkit",
16
+ "test:e2e:report": "node tests/e2e/run.mjs show-report"
14
17
  },
15
18
  "repository": {
16
19
  "type": "git",
@@ -51,9 +54,12 @@
51
54
  "devDependencies": {
52
55
  "@babel/core": "^7.22.0",
53
56
  "@babel/preset-env": "^7.22.0",
57
+ "@playwright/test": "1.65.0-alpha-2026-10-15",
54
58
  "babel-loader": "^9.2.1",
55
59
  "clean-webpack-plugin": "^4.0.0",
60
+ "draco3dgltf": "1.5.7",
56
61
  "esbuild": "^0.25.10",
62
+ "fflate": "0.8.3",
57
63
  "http-server": "^14.1.1",
58
64
  "jsdom": "^26.1.0",
59
65
  "standard-version": "^9.5.0",
@@ -15,6 +15,7 @@ import BabylonJSAnimationOverlayController from "./babylonjs-animation-overlay-c
15
15
  * - Tracks animated transformation nodes and their relationships to meshes.
16
16
  * - Exposes animation availability through `hasAnimations()`.
17
17
  * - Highlights animated nodes and their child meshes on pointer hover.
18
+ * - Can restrict touch-hold feedback to nodes belonging to opening/closing animation pairs.
18
19
  * - Avoids redundant highlight rebuilds using cached mesh/node IDs and order-insensitive node-id comparison.
19
20
  * - Reports highlight-state deltas from `highlightMeshes()` so callers can react efficiently.
20
21
  * - Displays and disposes the animation control menu for animated nodes.
@@ -311,10 +312,11 @@ export default class BabylonJSAnimationController {
311
312
  * highlighted node set has not changed.
312
313
  * @public
313
314
  * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
315
+ * @param {boolean} [openingOnly=false] - Restrict feedback to opening/closing pairs, preserving ordinary mouse hover otherwise.
314
316
  * @returns {{changed:boolean, highlighted:boolean}}
315
317
  * Returns whether highlight visuals changed in this call and whether any mesh remains highlighted.
316
318
  */
317
- highlightMeshes(pickingInfo) {
319
+ highlightMeshes(pickingInfo, openingOnly = false) {
318
320
  // If highlight is disabled globally, just remove any existing highlight and return
319
321
  if (!BabylonJSAnimationController.#globalHighlightEnabled) {
320
322
  if (this.#lastHighlightedMeshId !== null) {
@@ -330,10 +332,11 @@ export default class BabylonJSAnimationController {
330
332
  let highlighted = false;
331
333
 
332
334
  // Check if we're hovering the same mesh to avoid recreating overlays/highlights
333
- const pickedMeshId = !pickingInfo?.hit || !pickingInfo?.pickedMesh?.id ? null : pickingInfo.pickedMesh.id;
334
- const pickedMesh = pickingInfo?.pickedMesh ? pickingInfo.pickedMesh : null;
335
+ const pickedMesh = pickingInfo?.hit ? pickingInfo.pickedMesh : null;
336
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh).filter(nodeId => !openingOnly || this.#getOpeningAnimationByNode(nodeId));
337
+ const pickedMeshId = nodeIds.length ? pickedMesh?.id ?? null : null;
335
338
 
336
- if (this.#lastHighlightedMeshId === pickedMeshId) {
339
+ if (this.#lastHighlightedMeshId === pickedMeshId && this.#haveSameNodeIds(nodeIds, this.#lastHighlightedNodeIds)) {
337
340
  // No need to update if hovering the same mesh
338
341
  if (this.#lastHighlightedMeshId !== null) {
339
342
  changed = false;
@@ -343,7 +346,6 @@ export default class BabylonJSAnimationController {
343
346
  highlighted = false;
344
347
  }
345
348
  } else {
346
- const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
347
349
  if (!nodeIds.length) {
348
350
  if (this.#lastHighlightedMeshId !== null) {
349
351
  this.#removeHighlight();
@@ -372,9 +374,9 @@ export default class BabylonJSAnimationController {
372
374
  }
373
375
 
374
376
  /**
375
- * Configures the highlight settings for animated nodes on hover.
377
+ * Configures highlights for animated nodes during mouse hover or a pending touch hold.
376
378
  * @public
377
- * @param {{enabled?: boolean, color?: string}} config - Configuration object.
379
+ * @param {{enabled?: boolean, color?: string}} [config={}] - Configuration object.
378
380
  * @param {boolean} [config.enabled] - Whether to enable highlight on hover. If false, removes highlight.
379
381
  * @param {string} [config.color] - Hex color string for the highlight (e.g., "#FF0000"). Only used if enabled is not false.
380
382
  * @returns {void}
@@ -439,7 +441,9 @@ export default class BabylonJSAnimationController {
439
441
  */
440
442
  playAnimation(name, action) {
441
443
  const anim = this.#openingAnimations.find((a) => a.name === name);
442
- if (!anim) return false;
444
+ if (!anim) {
445
+ return false;
446
+ }
443
447
  switch (action) {
444
448
  case "open": anim.playOpen(); break;
445
449
  case "close": anim.playClose(); break;
@@ -460,7 +464,9 @@ export default class BabylonJSAnimationController {
460
464
  */
461
465
  setAnimationProgress(name, progress) {
462
466
  const anim = this.#openingAnimations.find((a) => a.name === name);
463
- if (!anim) return false;
467
+ if (!anim) {
468
+ return false;
469
+ }
464
470
  anim.setProgress(progress);
465
471
  return true;
466
472
  }
@@ -474,7 +480,9 @@ export default class BabylonJSAnimationController {
474
480
  */
475
481
  setAnimationLoop(name, loop) {
476
482
  const anim = this.#openingAnimations.find((a) => a.name === name);
477
- if (!anim) return false;
483
+ if (!anim) {
484
+ return false;
485
+ }
478
486
  anim.setLoop(loop);
479
487
  return true;
480
488
  }
@@ -16,7 +16,8 @@ import { PrefViewer3DAnimationMenuStyles } from "./styles.js";
16
16
  * - Handles user interactions and invokes provided callbacks for animation actions.
17
17
  * - Synchronizes the slider value with animation progress, avoiding callback loops.
18
18
  * - Provides setters to update animation state, progress, and loop mode from external controllers.
19
- * - Disposes and cleans up all DOM resources when no longer needed.
19
+ * - Owns temporary touch feedback for playback, animation selectors and progress input.
20
+ * - Disposes DOM resources and touch completion/blur subscriptions when no longer needed.
20
21
  * - Resets other animation menus when switching between animations.
21
22
  *
22
23
  * Public Setters:
@@ -74,6 +75,53 @@ export default class OpeningAnimationMenu {
74
75
  #containerButtons = null;
75
76
  #slider = null;
76
77
 
78
+ #touchPressedControl = null;
79
+ #touchPointerId = null;
80
+
81
+ /**
82
+ * Marks an enabled playback/selector button or range during touch-down, including SVG descendants.
83
+ * A second touch cancels feedback; native clicks, range dragging and scrolling are not prevented.
84
+ * @param {PointerEvent} event - Pointer down bubbling within this menu.
85
+ * @returns {void}
86
+ */
87
+ #onTouchPress = event => {
88
+ if (event.pointerType !== "touch") {
89
+ return;
90
+ }
91
+ if (this.#touchPressedControl) {
92
+ this.#clearTouchPress();
93
+ return;
94
+ }
95
+ const control = event.target.closest?.('button, input[type="range"]');
96
+ if (!control || !this.#containerMain?.contains(control) || control.matches(':disabled, .disabled, .hidden')) {
97
+ return;
98
+ }
99
+ this.#touchPressedControl = control;
100
+ this.#touchPointerId = event.pointerId;
101
+ control.setAttribute("data-touch-pressed", "");
102
+ };
103
+
104
+ /**
105
+ * Releases feedback when the owning finger ends or is cancelled, including outside the menu.
106
+ * @param {PointerEvent} event - Document-level pointer completion.
107
+ * @returns {void}
108
+ */
109
+ #onTouchRelease = event => {
110
+ if (event.pointerId === this.#touchPointerId) {
111
+ this.#clearTouchPress();
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Removes only this menu's temporary touch state, also on blur, disabling a control or disposal.
117
+ * @returns {void} Idempotent; does not change progress, focus or animation state.
118
+ */
119
+ #clearTouchPress = () => {
120
+ this.#touchPressedControl?.removeAttribute("data-touch-pressed");
121
+ this.#touchPressedControl = null;
122
+ this.#touchPointerId = null;
123
+ };
124
+
77
125
  #icon = {
78
126
  close: `<svg id="play-backwards" width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M16,18.86V4.86l-11,7,11,7Z"/></svg>`,
79
127
  closed: `<svg id="skip-backward" width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12"/></svg>`,
@@ -114,13 +162,13 @@ export default class OpeningAnimationMenu {
114
162
 
115
163
  /**
116
164
  * Internal helper to add a button to the menu.
117
- * Sets button appearance and attaches the callback for user interaction.
165
+ * Sets visual/native disabled state and attaches the callback for native click/tap interaction.
118
166
  * @private
119
167
  * @param {string} name - Button identifier.
120
168
  * @param {string} imageSVG - SVG markup for the button icon.
121
- * @param {boolean} enabled - Whether the button is enabled.
122
- * @param {boolean} active - Whether the button is visually active.
123
- * @param {boolean} visible - Whether the button is visible.
169
+ * @param {boolean} [enabled=true] - Whether the button is enabled when not active.
170
+ * @param {boolean} [active=false] - Whether the button is visually active and actionable.
171
+ * @param {boolean} [visible=true] - Whether the button is visible.
124
172
  * @param {function} callback - Callback to invoke on button click.
125
173
  * @returns {void}
126
174
  */
@@ -130,6 +178,7 @@ export default class OpeningAnimationMenu {
130
178
 
131
179
  const button = document.createElement("button");
132
180
  button.classList.add(stateClass, visibilityClass);
181
+ button.disabled = stateClass === "disabled";
133
182
  button.setAttribute("name", `button_animation_${name}`);
134
183
  button.innerHTML = imageSVG;
135
184
  button.addEventListener("click", (event) => {
@@ -217,7 +266,8 @@ export default class OpeningAnimationMenu {
217
266
  }
218
267
 
219
268
  /**
220
- * Initializes and renders the menu UI, including buttons and slider.
269
+ * Initializes the selector, buttons and slider and subscribes once to owned touch feedback.
270
+ * Document completion and window blur listeners are released by dispose().
221
271
  * @private
222
272
  * @returns {void}
223
273
  */
@@ -239,6 +289,10 @@ export default class OpeningAnimationMenu {
239
289
  this.#createSelector();
240
290
  this.#createButtons();
241
291
  this.#createSlider();
292
+ this.#containerMain.addEventListener("pointerdown", this.#onTouchPress);
293
+ this.#containerMain.ownerDocument.addEventListener("pointerup", this.#onTouchRelease);
294
+ this.#containerMain.ownerDocument.addEventListener("pointercancel", this.#onTouchRelease);
295
+ this.#containerMain.ownerDocument.defaultView?.addEventListener("blur", this.#clearTouchPress);
242
296
  }
243
297
 
244
298
  /**
@@ -332,12 +386,12 @@ export default class OpeningAnimationMenu {
332
386
  }
333
387
 
334
388
  /**
335
- * Updates the visual state of a button (enabled, active, visible).
389
+ * Updates visual/native disabled state and clears touch feedback if the control becomes disabled or hidden.
336
390
  * @private
337
391
  * @param {string} name - Button identifier.
338
- * @param {boolean} enabled
339
- * @param {boolean} active
340
- * @param {boolean} visible
392
+ * @param {boolean} enabled - Whether an inactive control can be activated.
393
+ * @param {boolean} active - Whether the control has the active appearance and remains actionable.
394
+ * @param {boolean} [visible=true] - Whether to display the control.
341
395
  * @returns {void}
342
396
  */
343
397
  #setButtonState(name, enabled, active, visible = true) {
@@ -350,6 +404,10 @@ export default class OpeningAnimationMenu {
350
404
  const stateClass = active ? "active" : enabled ? "enabled" : "disabled";
351
405
  const visibilityClass = visible ? "visible" : "hidden";
352
406
  button.classList.add(stateClass, visibilityClass);
407
+ button.disabled = stateClass === "disabled";
408
+ if (this.#touchPressedControl === button && (button.disabled || !visible)) {
409
+ this.#clearTouchPress();
410
+ }
353
411
  }
354
412
 
355
413
  /**
@@ -398,15 +456,23 @@ export default class OpeningAnimationMenu {
398
456
  */
399
457
 
400
458
  /**
401
- * Disposes the animation menu and releases all associated DOM resources.
459
+ * Releases touch/input listeners, owned press feedback and DOM resources.
460
+ * Document/window subscriptions are removed before detaching the menu; repeated disposal is safe.
402
461
  * @public
403
462
  * @returns {void}
404
463
  */
405
464
  dispose() {
406
- this.#slider.removeEventListener("input", this.#handler.onSliderInput);
407
- if (this.isVisible) {
408
- this.#containerMain.remove();
465
+ if (!this.#containerMain) {
466
+ return;
409
467
  }
468
+ const document = this.#containerMain.ownerDocument;
469
+ this.#containerMain.removeEventListener("pointerdown", this.#onTouchPress);
470
+ document.removeEventListener("pointerup", this.#onTouchRelease);
471
+ document.removeEventListener("pointercancel", this.#onTouchRelease);
472
+ document.defaultView?.removeEventListener("blur", this.#clearTouchPress);
473
+ this.#clearTouchPress();
474
+ this.#slider.removeEventListener("input", this.#handler.onSliderInput);
475
+ this.#containerMain.remove();
410
476
  this.#containerMain = null;
411
477
  this.#containerButtons = null;
412
478
  this.#slider = null;
@@ -156,7 +156,7 @@ export default class OpeningAnimation {
156
156
  /**
157
157
  * Moves the animation to the fully opened state, optionally looping to close.
158
158
  * @private
159
- * @param {boolean} useLoop - If true, starts closing animation after opening.
159
+ * @param {boolean} [useLoop=false] - If true, starts closing animation after opening.
160
160
  * @returns {void}
161
161
  */
162
162
  #goToOpened(useLoop = false) {
@@ -186,7 +186,7 @@ export default class OpeningAnimation {
186
186
  /**
187
187
  * Moves the animation to the fully closed state, optionally looping to open.
188
188
  * @private
189
- * @param {boolean} useLoop - If true, starts opening animation after closing.
189
+ * @param {boolean} [useLoop=false] - If true, starts opening animation after closing.
190
190
  * @returns {void}
191
191
  */
192
192
  #goToClosed(useLoop = false) {
@@ -515,7 +515,7 @@ export default class OpeningAnimation {
515
515
  * Synchronizes slider and button states with animation.
516
516
  * @public
517
517
  * @param {HTMLCanvasElement} canvas - The canvas element for rendering.
518
- * @param {Array<OpeningAnimation>} openingAnimations - Array of OpeningAnimation instances to manage.
518
+ * @param {Array<OpeningAnimation>} [openingAnimations=[]] - Array of OpeningAnimation instances to manage.
519
519
  * @returns {void}
520
520
  */
521
521
  showControls(canvas, openingAnimations = []) {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Pure geometry for fitting an authored model footprint on a convex detected horizontal floor.
3
+ * Selects native horizontal-plane axes independently of conservative footprint contours. All coordinates are scene-space metres. Owns no XR resources and never rescales geometry.
4
+ */
5
+ export default class BabylonJSARFloorBounds {
6
+ /**
7
+ * Validates a convex contour and returns consistently inward-facing edge constraints.
8
+ * @private
9
+ * @param {{x:number,z:number}[]} polygon - Ordered contour; either winding is accepted.
10
+ * @returns {?Array<{x:number,z:number,dx:number,dz:number,sign:number}>} Edges or null for invalid/concave contours.
11
+ */
12
+ static #edges(polygon) {
13
+ if (!Array.isArray(polygon) || polygon.length < 3
14
+ || polygon.some(p => !p || !Number.isFinite(p.x) || !Number.isFinite(p.z))) {
15
+ return null;
16
+ }
17
+ let area = 0;
18
+ for (let i = 0; i < polygon.length; i++) {
19
+ const a = polygon[i], b = polygon[(i + 1) % polygon.length];
20
+ area += a.x * b.z - a.z * b.x;
21
+ }
22
+ if (Math.abs(area) <= 1e-10) {
23
+ return null;
24
+ }
25
+ const sign = Math.sign(area), edges = [];
26
+ for (let i = 0; i < polygon.length; i++) {
27
+ const a = polygon[i], b = polygon[(i + 1) % polygon.length];
28
+ const dx = b.x - a.x, dz = b.z - a.z;
29
+ const length = Math.hypot(dx, dz);
30
+ if (length <= 1e-10) {
31
+ continue;
32
+ }
33
+ const edge = { x: a.x, z: a.z, dx: dx / length, dz: dz / length, sign };
34
+ if (polygon.some(p => sign * (edge.dx * (p.z - a.z) - edge.dz * (p.x - a.x)) < -1e-7)) {
35
+ return null;
36
+ }
37
+ edges.push(edge);
38
+ }
39
+ return edges.length >= 3 ? edges : null;
40
+ }
41
+
42
+ /**
43
+ * Reads native horizontal contours containing an accepted floor hit in the active reference space.
44
+ * Returns matching contour yaws plus a separately selected orientation when a finite camera height is supplied.
45
+ * Prefer a matching contour; otherwise use the horizontal pose below the camera closest in height to the hit.
46
+ * Pose-only alignment never relaxes contour height/containment/convexity checks or claims a physical footprint fit.
47
+ * Footprint contours require vertices at the observed height (five-centimetre tracking tolerance).
48
+ * @param {XRFrame} frame - Active frame; plane data may be unavailable when the optional feature is unsupported.
49
+ * @param {XRReferenceSpace} referenceSpace - Reference used for both the accepted hit and plane poses.
50
+ * @param {{x:number,y:number,z:number}} floorPoint - Accepted floor hit in scene-space metres.
51
+ * @param {boolean} [rightHanded=false] - Whether native negative-Z coordinates already match the scene.
52
+ * @param {?number} [viewerY=null] - Finite camera height enables independent native-pose alignment selection.
53
+ * @returns {{polygons:Array<Array<{x:number,z:number}>>,yaws:number[],alignment:?{yaw:number,planeHeight:number,heightDifference:number,status:string},status:string,planeCount:number}} Detached contours/alignment/status;
54
+ * unsupported access or failed plane queries never escape into the XR frame observer.
55
+ */
56
+ static getFloorPolygons(frame, referenceSpace, floorPoint, rightHanded = false, viewerY = null) {
57
+ let planes;
58
+ try {
59
+ planes = frame.detectedPlanes;
60
+ } catch {
61
+ return { polygons: [], yaws: [], alignment: null, status: "bounds-unavailable", planeCount: 0 };
62
+ }
63
+ if (!planes) {
64
+ return { polygons: [], yaws: [], alignment: null, status: "bounds-unavailable", planeCount: 0 };
65
+ }
66
+ const polygons = [];
67
+ const yaws = [];
68
+ let alignment = null;
69
+ let matchedAlignment = null;
70
+ let nearestDistance = Infinity;
71
+ let planeCount = 0;
72
+ for (const plane of planes) {
73
+ planeCount++;
74
+ try {
75
+ if (plane.orientation && plane.orientation !== "horizontal") {
76
+ continue;
77
+ }
78
+ const pose = frame.getPose(plane.planeSpace, referenceSpace);
79
+ const matrix = pose?.transform?.matrix;
80
+ if (!matrix || matrix.length !== 16 || !Array.from(matrix).every(Number.isFinite)
81
+ || Math.abs(matrix[5]) < 0.95 || Math.hypot(matrix[0], matrix[2]) <= 1e-6) {
82
+ continue;
83
+ }
84
+ const yaw = Math.atan2(rightHanded ? -matrix[2] : matrix[2], matrix[0]);
85
+ const heightDifference = Math.abs(matrix[13] - floorPoint.y);
86
+ const distance = Math.hypot(matrix[12] - floorPoint.x, (rightHanded ? matrix[14] : -matrix[14]) - floorPoint.z);
87
+ const eligible = Number.isFinite(viewerY) && matrix[13] < viewerY;
88
+ const candidate = { yaw, planeHeight: matrix[13], heightDifference, status: "nearest-horizontal-plane" };
89
+ if (eligible && (!alignment || heightDifference < alignment.heightDifference - 1e-6
90
+ || (Math.abs(heightDifference - alignment.heightDifference) <= 1e-6 && distance < nearestDistance))) {
91
+ alignment = candidate;
92
+ nearestDistance = distance;
93
+ }
94
+ const polygon = [];
95
+ for (const point of plane.polygon) {
96
+ const x = matrix[0] * point.x + matrix[4] * point.y + matrix[8] * point.z + matrix[12];
97
+ const y = matrix[1] * point.x + matrix[5] * point.y + matrix[9] * point.z + matrix[13];
98
+ const z = matrix[2] * point.x + matrix[6] * point.y + matrix[10] * point.z + matrix[14];
99
+ if (!Number.isFinite(y) || Math.abs(y - floorPoint.y) > 0.05) {
100
+ polygon.length = 0;
101
+ break;
102
+ }
103
+ polygon.push({ x, z: rightHanded ? z : -z });
104
+ }
105
+ if (this.contains(polygon, floorPoint)) {
106
+ polygons.push(polygon);
107
+ yaws.push(yaw);
108
+ if (eligible && !matchedAlignment) {
109
+ matchedAlignment = { ...candidate, status: "matched-contour" };
110
+ }
111
+ }
112
+ } catch {
113
+ // A disappearing/invalid plane must not interrupt the native frame pump.
114
+ continue;
115
+ }
116
+ }
117
+ return { polygons, yaws, alignment: matchedAlignment ?? alignment, status: polygons.length ? "bounds-ready" : "waiting-floor-contour", planeCount };
118
+ }
119
+
120
+ /**
121
+ * Checks whether a finite point lies on or inside a valid convex contour.
122
+ * @param {{x:number,z:number}[]} polygon - Ordered floor contour in scene coordinates.
123
+ * @param {{x:number,z:number}} point - Point to test against every edge.
124
+ * @returns {boolean} False for unavailable/invalid contours or points.
125
+ */
126
+ static contains(polygon, point) {
127
+ const edges = this.#edges(polygon);
128
+ return !!edges && !!point && Number.isFinite(point.x) && Number.isFinite(point.z)
129
+ && edges.every(e => e.sign * (e.dx * (point.z - e.z) - e.dz * (point.x - e.x)) >= -1e-7);
130
+ }
131
+
132
+ /**
133
+ * Clips a front-facing centre line against every contour edge for every footprint corner.
134
+ * Chooses the farthest feasible centre up to the preferred distance, including exactly that distance when it fits.
135
+ * @param {{x:number,z:number}[]} polygon - Convex detected floor contour, in either winding.
136
+ * @param {{x:number,z:number}[]} footprint - Rotated footprint offsets relative to the model bottom centre.
137
+ * @param {{x:number,z:number}} origin - Tracked camera horizontal position.
138
+ * @param {{x:number,z:number}} forward - Unit horizontal facing direction.
139
+ * @param {number} [preferredDistance=3] - Maximum requested initial centre distance in metres.
140
+ * @returns {?number} Feasible centre distance, or null when no full-footprint fit exists on that segment.
141
+ */
142
+ static getPlacementDistance(polygon, footprint, origin, forward, preferredDistance = 3) {
143
+ const edges = this.#edges(polygon);
144
+ if (!edges || !Array.isArray(footprint) || !footprint.length
145
+ || ![origin?.x, origin?.z, forward?.x, forward?.z, preferredDistance].every(Number.isFinite)
146
+ || preferredDistance < 0 || Math.abs(Math.hypot(forward.x, forward.z) - 1) > 1e-6
147
+ || footprint.some(p => !p || !Number.isFinite(p.x) || !Number.isFinite(p.z))) {
148
+ return null;
149
+ }
150
+ let near = 0, far = preferredDistance;
151
+ for (const edge of edges) {
152
+ const slope = edge.sign * (edge.dx * forward.z - edge.dz * forward.x);
153
+ for (const corner of footprint) {
154
+ const offset = edge.sign * (edge.dx * (origin.z + corner.z - edge.z)
155
+ - edge.dz * (origin.x + corner.x - edge.x));
156
+ if (Math.abs(slope) <= 1e-10) {
157
+ if (offset < -1e-7) {
158
+ return null;
159
+ }
160
+ } else if (slope > 0) {
161
+ near = Math.max(near, -offset / slope);
162
+ } else {
163
+ far = Math.min(far, -offset / slope);
164
+ }
165
+ }
166
+ }
167
+ return far >= near - 1e-7 && far >= 0 ? Math.max(0, far) : null;
168
+ }
169
+ }