@preference-sl/pref-viewer 2.18.0 → 3.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +267 -31
- package/index.d.ts +75 -6
- package/package.json +8 -2
- package/src/babylonjs-animation-controller.js +18 -10
- package/src/babylonjs-animation-opening-menu.js +80 -14
- package/src/babylonjs-animation-opening.js +3 -3
- package/src/babylonjs-ar-floor-bounds.js +169 -0
- package/src/babylonjs-ar-glass.js +128 -0
- package/src/babylonjs-ar-placement-feedback.js +449 -0
- package/src/babylonjs-ar-placement.js +1038 -0
- package/src/babylonjs-camera-wheel-motion.js +35 -2
- package/src/babylonjs-controller.js +507 -125
- package/src/babylonjs-orbital-pointers-input.js +186 -0
- package/src/{render-math.js → babylonjs-render-math.js} +11 -11
- package/src/babylonjs-selection-controller.js +4 -4
- package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
- package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
- package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
- package/src/babylonjs-view-cube-controller.js +74 -1103
- package/src/babylonjs-view-cube-svg-controller.js +1012 -0
- package/src/file-storage.js +7 -4
- package/src/gltf-resolver.js +13 -8
- package/src/localization/translations.js +12 -0
- package/src/panzoom-controller.js +4 -4
- package/src/pref-viewer-2d.js +2 -2
- package/src/pref-viewer-3d.js +55 -9
- package/src/pref-viewer-controls-presentation.js +292 -0
- package/src/pref-viewer-dialog.js +86 -7
- package/src/pref-viewer-menu-3d.js +134 -17
- package/src/pref-viewer-style-controller.js +320 -0
- package/src/pref-viewer.js +133 -109
- package/src/quick-look-action.js +280 -0
- package/src/styles.js +702 -98
- package/src/babylonjs-view-cube-renderer.js +0 -548
- package/src/view-cube-orbit-math.js +0 -199
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?:
|
|
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
|
-
/**
|
|
236
|
-
|
|
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
|
-
/**
|
|
260
|
-
|
|
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": "
|
|
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
|
|
334
|
-
const
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
407
|
-
|
|
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
|
+
}
|