@preference-sl/pref-viewer 3.0.1-beta.0 → 3.1.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 +27 -4
- package/index.d.ts +19 -0
- package/package.json +1 -1
- package/src/babylonjs-ar-placement.js +19 -5
- package/src/babylonjs-controller.js +125 -21
- package/src/babylonjs-vr-stage.js +191 -0
- package/src/localization/translations.js +6 -0
- package/src/pref-viewer-3d.js +18 -0
- package/src/pref-viewer.js +19 -0
- package/src/quick-look-action.js +51 -10
- package/src/xr-mode-buttons.js +146 -0
package/README.md
CHANGED
|
@@ -20,15 +20,21 @@
|
|
|
20
20
|
- Export helpers for model, scene, or combined model + scene downloads.
|
|
21
21
|
- Type declarations for TypeScript and JSX usage.
|
|
22
22
|
|
|
23
|
-
## AR support
|
|
23
|
+
## AR and VR support
|
|
24
24
|
|
|
25
25
|
| Browser/platform | Viewer route |
|
|
26
26
|
| --- | --- |
|
|
27
27
|
| Android Chrome with confirmed `immersive-ar` and native plane hit-test support | WebXR AR at authored metre scale |
|
|
28
28
|
| Eligible Safari on iOS/iPadOS without WebXR AR support | AR Quick Look using a client-generated USDZ of the current visible model |
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
29
|
+
| Headset browsers with `immersive-vr` (Meta Quest Browser, Pico, SteamVR-backed desktop browsers, visionOS Safari with WebXR enabled) | WebXR VR at authored metre scale, standing on the virtual floor in front of the model |
|
|
30
|
+
| Other browsers without a supported AR or VR route | No AR/VR action |
|
|
31
|
+
| iPhone VR (Cardboard, device-orientation stereo) | Not implemented |
|
|
32
|
+
|
|
33
|
+
The viewer renders one control per supported session mode (`.babylonVRicon.pref-viewer-xr-ar`, `.babylonVRicon.pref-viewer-xr-vr`) inside its `.xr-button-overlay`. Session support is queried through `navigator.xr.isSessionSupported` at viewer start; a device that supports both modes shows both controls, and the other control is hidden while a session is active.
|
|
34
|
+
|
|
35
|
+
### VR
|
|
36
|
+
|
|
37
|
+
VR uses the `local-floor` reference space without teleportation, controller selection, hand tracking or model interaction: the user looks around and walks within the tracked area. On entry the XR camera is placed at floor height (the model's lowest point), centred on the model's horizontal bounds and 2.5 m along the viewer's existing Inner (+Z) direction, facing the model. The world scale is forced to 1 so the glTF's authored metre dimensions apply, and restored on exit. The virtual environment stays visible (unlike AR, which hides it), model materials are not adapted, and the model keeps its current transforms. `getDebugInfo().vr` reports `phase`, `standingDistance`, `position` and `floorHeight`. Headset-specific rendering, comfort and visionOS WebXR availability still require physical-device validation.
|
|
32
38
|
|
|
33
39
|
Android WebXR preserves the glTF's authored metre dimensions, including existing unit-conversion transforms. It never fits the model to the screen or permits pinch scaling. Assets must encode their dimensions in metres and orient their interior toward the viewer's existing Inner (+Z) direction; the viewer cannot infer an interior face or correct arbitrary source units. The upstream exporter owns those asset conventions.
|
|
34
40
|
|
|
@@ -48,6 +54,22 @@ On eligible Safari, the AR control uses the existing XR appearance and position.
|
|
|
48
54
|
|
|
49
55
|
The link uses `rel="ar"`, `download="model.usdz"` and a `model/vnd.usdz+zip` Blob URL with the `#allowsContentScaling=0` fragment, so its native navigation runs directly on the user tap and Quick Look keeps the authored real-world size (no pinch scaling, as on Android WebXR). Only model meshes with supported materials and current visibility are exported. Environment and viewer overlays are excluded. Physical transmission glass is exported with an equivalent alpha opacity, because USDZ has no transmission. The viewer bundles the `fflate` helper the exporter needs, so no request to `unpkg.com` is made.
|
|
50
56
|
|
|
57
|
+
#### Host-supplied Quick Look asset
|
|
58
|
+
|
|
59
|
+
A host that produces the AR asset elsewhere (for example a server-generated USDZ or USD) can hand it to the Quick Look action with `setQuickLookAssetProvider(provider)`. The provider is a function returning `Promise<{ data: Uint8Array | ArrayBuffer | Blob, mimeType?: string, fileName?: string }>`; `mimeType` defaults to `model/vnd.usdz+zip` and `fileName` to `model.usdz`. Pass `null` to clear it. The viewer never learns how the asset is made.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
viewer.setQuickLookAssetProvider(async () => {
|
|
63
|
+
const response = await fetch("/my-server/ar-model");
|
|
64
|
+
return { data: await response.arrayBuffer(), mimeType: "model/vnd.usdz+zip", fileName: "model.usdz" };
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- While a provider is set, the Quick Look link uses its asset instead of the Babylon USDZ export. It is called each time the link is prepared (after model/material loading and changes), only on the Quick Look route (eligible iOS/iPadOS Safari without WebXR AR).
|
|
69
|
+
- Setting or clearing the provider re-prepares the link when Quick Look is active; a provider set earlier (even before the element is connected) is retained.
|
|
70
|
+
- The ZIP signature check applies only when `mimeType` is `model/vnd.usdz+zip`. Any other MIME type (for example `model/vnd.usd`, or `text/plain` for USDA) is passed through unchanged as the Blob type, with `fileName` as the link's `download` attribute. AR Quick Look officially opens only `.usdz`/`.reality`, so what iOS does with other types is the host's experiment.
|
|
71
|
+
- If the provider throws, rejects or returns empty data, the viewer logs `PrefViewer: Quick Look asset provider failed` with `console.warn` and falls back to its own USDZ export.
|
|
72
|
+
|
|
51
73
|
Manual iOS Quick Look validation has been reported: its AR button remains usable even when the Babylon 3D view is empty. The empty scene/model failure has also been reproduced on an iPhone and remains unresolved; Quick Look validation does not establish successful in-page 3D rendering. Device/browser versions and itemized scale, glass, texture, close/reopen, iPad and production-iframe results were not supplied, so those details remain unconfirmed. Safari remote inspection is unavailable in the current test setup because no macOS system is available. An iframe must permit the browser's native AR/navigation behavior; no automatic top-level redirect or parent-message protocol is added. Used Blob URLs are retained through model replacement/detach until an observed browser return; if Safari emits no return signal, their lifetime ends with the document.
|
|
52
74
|
|
|
53
75
|
## Installation
|
|
@@ -432,6 +454,7 @@ Dialogs opened with `openDialog()`, including the **Ctrl+Alt+D** download dialog
|
|
|
432
454
|
| `downloadModelGLB()` / `downloadModelGLTF()` / `downloadModelUSDZ()` | Exports the current model. |
|
|
433
455
|
| `downloadSceneGLB()` / `downloadSceneGLTF()` / `downloadSceneUSDZ()` | Exports the current scene/environment. |
|
|
434
456
|
| `downloadModelAndSceneGLB()` / `downloadModelAndSceneGLTF()` / `downloadModelAndSceneUSDZ()` | Exports the combined model and scene. |
|
|
457
|
+
| `setQuickLookAssetProvider(provider \| null)` | Lets the host supply the asset opened by the iOS AR Quick Look action instead of the viewer's client-generated USDZ (for example a file produced by a server). See [AR and VR support](#ar-and-vr-support). |
|
|
435
458
|
|
|
436
459
|
All viewer dialogs share this priority, including the model-download dialog opened with **Ctrl+Alt+D** while the 3D canvas receives keyboard input. Existing backdrop dismissal remains available: a click over a covered control may close the dialog, but does not also activate that control. Closing the dialog restores access to the underlying controls.
|
|
437
460
|
|
package/index.d.ts
CHANGED
|
@@ -139,6 +139,18 @@ export interface PrefViewerElementClickDetail {
|
|
|
139
139
|
ids: string[];
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Asset a host hands to the iOS AR Quick Look action. */
|
|
143
|
+
export interface PrefViewerQuickLookAsset {
|
|
144
|
+
data: Uint8Array | ArrayBuffer | Blob;
|
|
145
|
+
/** Defaults to `model/vnd.usdz+zip`; the ZIP signature is checked only for that type. */
|
|
146
|
+
mimeType?: string;
|
|
147
|
+
/** Defaults to `model.usdz`; used as the link's `download` attribute. */
|
|
148
|
+
fileName?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Async factory of the Quick Look asset, called each time the Quick Look link is prepared. */
|
|
152
|
+
export type PrefViewerQuickLookAssetProvider = () => Promise<PrefViewerQuickLookAsset>;
|
|
153
|
+
|
|
142
154
|
/** Optional speed/direction for `startTurnable` / `toggleTurnable`. */
|
|
143
155
|
export interface PrefViewerTurnableOptions {
|
|
144
156
|
speed?: number;
|
|
@@ -356,6 +368,13 @@ export declare class PrefViewer extends HTMLElement {
|
|
|
356
368
|
setAnimationProgress(name: string, progress: number): boolean;
|
|
357
369
|
setAnimationLoop(name: string, loop: boolean): boolean;
|
|
358
370
|
|
|
371
|
+
// ── AR Quick Look (iOS) ──
|
|
372
|
+
/**
|
|
373
|
+
* Supplies (or clears with `null`) the asset opened by the iOS AR Quick Look action instead of the
|
|
374
|
+
* viewer's client-generated USDZ. A failing provider falls back to the built-in export with a console warning.
|
|
375
|
+
*/
|
|
376
|
+
setQuickLookAssetProvider(provider: PrefViewerQuickLookAssetProvider | null): void;
|
|
377
|
+
|
|
359
378
|
// ── Downloads ──
|
|
360
379
|
downloadModelGLB(): void;
|
|
361
380
|
downloadModelGLTF(): void;
|
package/package.json
CHANGED
|
@@ -30,6 +30,7 @@ export default class BabylonJSARPlacement {
|
|
|
30
30
|
#manager;
|
|
31
31
|
#getModel;
|
|
32
32
|
#getEnvironment;
|
|
33
|
+
#isActive;
|
|
33
34
|
#observers = [];
|
|
34
35
|
#session = null;
|
|
35
36
|
#generation = 0;
|
|
@@ -81,23 +82,36 @@ export default class BabylonJSARPlacement {
|
|
|
81
82
|
* @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Native XR lifecycle owner.
|
|
82
83
|
* @param {function(): ?import("@babylonjs/core").AssetContainer} getModel - Current usable model, or null while unavailable.
|
|
83
84
|
* @param {function(): ?import("@babylonjs/core").AssetContainer} [getEnvironment] - Current environment; defaults to returning null.
|
|
85
|
+
* @param {function(): boolean} [isActive] - Whether the session being entered is an AR session; defaults to always true.
|
|
86
|
+
* While it returns false and no placement is active, entry and session-init events are ignored (frames already
|
|
87
|
+
* require an active placement) so other session modes such as VR leave the scene untouched.
|
|
88
|
+
* An already active placement still ends normally.
|
|
84
89
|
*/
|
|
85
|
-
constructor(scene, experience, getModel, getEnvironment = () => null) {
|
|
90
|
+
constructor(scene, experience, getModel, getEnvironment = () => null, isActive = () => true) {
|
|
86
91
|
this.#scene = scene;
|
|
87
92
|
this.#feedback = new BabylonJSARPlacementFeedback(scene);
|
|
88
93
|
this.#manager = experience.baseExperience.sessionManager;
|
|
89
94
|
this.#getModel = getModel;
|
|
90
95
|
this.#getEnvironment = getEnvironment;
|
|
96
|
+
this.#isActive = isActive;
|
|
91
97
|
this.#observe(experience.baseExperience.onStateChangedObservable, state => {
|
|
92
|
-
if (state === WebXRState.ENTERING_XR) {
|
|
98
|
+
if (state === WebXRState.ENTERING_XR && this.#isActive()) {
|
|
93
99
|
this.#begin();
|
|
94
100
|
}
|
|
95
|
-
if (state === WebXRState.NOT_IN_XR) {
|
|
101
|
+
if (state === WebXRState.NOT_IN_XR && this.#active) {
|
|
102
|
+
this.#end();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
this.#observe(this.#manager.onXRSessionInit, session => {
|
|
106
|
+
if (this.#active || this.#isActive()) {
|
|
107
|
+
void this.#start(session);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
this.#observe(this.#manager.onXRSessionEnded, () => {
|
|
111
|
+
if (this.#active) {
|
|
96
112
|
this.#end();
|
|
97
113
|
}
|
|
98
114
|
});
|
|
99
|
-
this.#observe(this.#manager.onXRSessionInit, session => { void this.#start(session); });
|
|
100
|
-
this.#observe(this.#manager.onXRSessionEnded, () => this.#end());
|
|
101
115
|
this.#observe(this.#manager.onXRFrameObservable, frame => this.#frame(frame));
|
|
102
116
|
}
|
|
103
117
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, CubeTexture, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, Matrix, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
|
|
2
2
|
import BabylonJSARPlacement from "./babylonjs-ar-placement.js";
|
|
3
|
+
import BabylonJSVRStage from "./babylonjs-vr-stage.js";
|
|
4
|
+
import XRModeButtons from "./xr-mode-buttons.js";
|
|
3
5
|
import BabylonJSARGlass from "./babylonjs-ar-glass.js";
|
|
4
6
|
import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
|
|
5
7
|
import "@babylonjs/loaders";
|
|
@@ -70,6 +72,7 @@ import QuickLookAction from "./quick-look-action.js";
|
|
|
70
72
|
* - enable() / disable()
|
|
71
73
|
* - load(force?) / reloadWithCurrentSettings()
|
|
72
74
|
* - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
|
|
75
|
+
* - setQuickLookAssetProvider(provider)
|
|
73
76
|
* - getRenderSettings() / scheduleRenderSettingsReload(settings)
|
|
74
77
|
* - getDebugInfo()
|
|
75
78
|
* - setContainerVisibility(name, show)
|
|
@@ -127,8 +130,11 @@ export default class BabylonJSController {
|
|
|
127
130
|
#shadowGen = [];
|
|
128
131
|
#XRExperience = null;
|
|
129
132
|
#arPlacement = null;
|
|
133
|
+
#vrStage = null;
|
|
134
|
+
#xrButtons = null;
|
|
130
135
|
#xrGeneration = 0;
|
|
131
136
|
#quickLookAction = null;
|
|
137
|
+
#quickLookAssetProvider = null;
|
|
132
138
|
#quickLookGlass = new BabylonJSARGlass();
|
|
133
139
|
#quickLookLoads = 0;
|
|
134
140
|
#quickLookValid = { model: false, materials: true };
|
|
@@ -1031,7 +1037,8 @@ export default class BabylonJSController {
|
|
|
1031
1037
|
}
|
|
1032
1038
|
|
|
1033
1039
|
/**
|
|
1034
|
-
* Adds the supplied cube-and-scan-frame SVG
|
|
1040
|
+
* Adds the supplied cube-and-scan-frame SVG, a headset icon for the VR button and shared interaction styles
|
|
1041
|
+
* to the XR mode buttons and the Quick Look action.
|
|
1035
1042
|
* Preserves the supplied 24-unit geometry and renders its filled paths white against the toggle background.
|
|
1036
1043
|
* @private
|
|
1037
1044
|
* @returns {void}
|
|
@@ -1047,7 +1054,8 @@ export default class BabylonJSController {
|
|
|
1047
1054
|
c-0.5,0.32-1,0.32-1.5,0l-5.48-3.18C5.27,16.23,5,15.8,5,15.23V8.91c0-0.57,0.27-1,0.77-1.32l5.48-3.18
|
|
1048
1055
|
c0.25-0.13,0.5-0.19,0.75-0.19s0.5,0.06,0.75,0.19L18.23,7.59z"/>
|
|
1049
1056
|
</svg>`;
|
|
1050
|
-
const
|
|
1057
|
+
const vrIcon = `<svg fill="white" xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><path fill="none" d="M0,0h24v24H0V0z"/><path d="M20.74 6H3.21C2.55 6 2 6.57 2 7.28v10.44c0 .7.55 1.28 1.23 1.28h4.79c.52 0 .96-.33 1.14-.79l1.4-3.48c.23-.59.79-1.01 1.44-1.01s1.21.42 1.45 1.01l1.39 3.48c.19.46.63.79 1.11.79h4.79c.71 0 1.26-.57 1.26-1.28V7.28c0-.7-.55-1.28-1.26-1.28zM7.5 14.62c-1.17 0-2.13-.95-2.13-2.12 0-1.17.96-2.13 2.13-2.13 1.18 0 2.12.96 2.12 2.13s-.95 2.12-2.12 2.12zm9 0c-1.17 0-2.13-.95-2.13-2.12 0-1.17.96-2.13 2.13-2.13s2.12.96 2.12 2.13-.95 2.12-2.12 2.12z"/></svg>`;
|
|
1058
|
+
const css = `.babylonVRicon { color: #868686; border-color: #868686; border-style: solid; margin-left: 10px; height: 50px; width: 80px; background-color: rgba(51,51,51,0.7); background-image: url(data:image/svg+xml;charset=UTF-8,${encodeURIComponent(icon)}); background-size: 80%; background-repeat:no-repeat; background-position: center; border: none; outline: none; transition: transform 0.125s ease-out } .babylonVRicon.pref-viewer-xr-vr { background-image: url(data:image/svg+xml;charset=UTF-8,${encodeURIComponent(vrIcon)}) } .babylonVRicon:hover { transform: scale(1.05) } .babylonVRicon:active {background-color: rgba(51,51,51,1) } .babylonVRicon:focus {background-color: rgba(51,51,51,1) }.babylonVRicon.vrdisplaypresenting { background-image: none;} .vrdisplaypresenting::after { content: "EXIT"} .xr-error::after { content: "ERROR"}`;
|
|
1051
1059
|
let style = this.#canvas.parentElement.parentElement.querySelector("style");
|
|
1052
1060
|
if (!style) {
|
|
1053
1061
|
style = document.createElement("style");
|
|
@@ -1083,11 +1091,14 @@ export default class BabylonJSController {
|
|
|
1083
1091
|
}
|
|
1084
1092
|
|
|
1085
1093
|
/**
|
|
1086
|
-
* Selects and initializes supported WebXR AR or an eligible Safari Quick Look action.
|
|
1094
|
+
* Selects and initializes supported WebXR AR and/or VR, or an eligible Safari Quick Look action.
|
|
1087
1095
|
* @private
|
|
1088
|
-
* @returns {Promise<boolean>} True when an
|
|
1096
|
+
* @returns {Promise<boolean>} True when an XR route is configured, false when unsupported, obsolete or setup fails.
|
|
1089
1097
|
* @description
|
|
1090
|
-
*
|
|
1098
|
+
* Queries `immersive-ar` and `immersive-vr` support; a failed VR query only disables VR.
|
|
1099
|
+
* Babylon's default enter/exit UI is disabled; an XRModeButtons overlay renders one button per supported mode,
|
|
1100
|
+
* each requesting its own reference space and features. VR is standing-scale (`local-floor`) and positioned by BabylonJSVRStage.
|
|
1101
|
+
* AR requires native floor hit testing at real-world metre scale.
|
|
1091
1102
|
* Requests optional plane detection so initial placement can use an observed floor contour when supplied.
|
|
1092
1103
|
* Placement follows the tracked XR camera rather than inheriting the showroom camera's fit.
|
|
1093
1104
|
* Adds custom styles to the AR button for consistent appearance.
|
|
@@ -1104,11 +1115,16 @@ export default class BabylonJSController {
|
|
|
1104
1115
|
const generation = this.#xrGeneration;
|
|
1105
1116
|
/** @returns {boolean} Whether this setup still owns the live scene generation. */
|
|
1106
1117
|
const isCurrent = () => this.#scene === scene && this.#xrGeneration === generation && !scene.isDisposed;
|
|
1107
|
-
const sessionMode = "immersive-ar";
|
|
1108
1118
|
let experience = null;
|
|
1109
1119
|
let placement = null;
|
|
1120
|
+
let stage = null;
|
|
1121
|
+
let buttons = null;
|
|
1110
1122
|
try {
|
|
1111
|
-
const sessionSupported = await
|
|
1123
|
+
const [sessionSupported, vrSupported] = await Promise.all([
|
|
1124
|
+
WebXRSessionManager.IsSessionSupportedAsync("immersive-ar"),
|
|
1125
|
+
// An unavailable VR query must not take AR down with it.
|
|
1126
|
+
Promise.resolve(WebXRSessionManager.IsSessionSupportedAsync("immersive-vr")).catch(() => null),
|
|
1127
|
+
]);
|
|
1112
1128
|
if (!isCurrent()) {
|
|
1113
1129
|
return false;
|
|
1114
1130
|
}
|
|
@@ -1127,10 +1143,19 @@ export default class BabylonJSController {
|
|
|
1127
1143
|
this.#refreshQuickLook();
|
|
1128
1144
|
return true;
|
|
1129
1145
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1146
|
+
const modes = [];
|
|
1147
|
+
if (route === "webxr") {
|
|
1148
|
+
modes.push({ mode: "immersive-ar", referenceSpaceType: "local", requiredFeatures: ["hit-test"], optionalFeatures: ["plane-detection"] });
|
|
1149
|
+
}
|
|
1150
|
+
if (vrSupported === true) {
|
|
1151
|
+
modes.push({ mode: "immersive-vr", referenceSpaceType: "local-floor", requiredFeatures: [], optionalFeatures: [] });
|
|
1152
|
+
}
|
|
1153
|
+
if (modes.length === 0) {
|
|
1154
|
+
console.info("PrefViewer: WebXR in mode AR/VR is not supported");
|
|
1132
1155
|
return false;
|
|
1133
1156
|
}
|
|
1157
|
+
const hasAR = modes.some((item) => item.mode === "immersive-ar");
|
|
1158
|
+
const hasVR = modes.some((item) => item.mode === "immersive-vr");
|
|
1134
1159
|
|
|
1135
1160
|
const options = {
|
|
1136
1161
|
disableTeleportation: true,
|
|
@@ -1138,13 +1163,7 @@ export default class BabylonJSController {
|
|
|
1138
1163
|
disableNearInteraction: true,
|
|
1139
1164
|
disableHandTracking: true,
|
|
1140
1165
|
ignoreNativeCameraTransformation: true,
|
|
1141
|
-
|
|
1142
|
-
sessionMode: sessionMode,
|
|
1143
|
-
renderTarget: "xrLayer",
|
|
1144
|
-
referenceSpaceType: "local",
|
|
1145
|
-
requiredFeatures: ["hit-test"],
|
|
1146
|
-
optionalFeatures: ["plane-detection"],
|
|
1147
|
-
},
|
|
1166
|
+
disableDefaultUI: true,
|
|
1148
1167
|
};
|
|
1149
1168
|
|
|
1150
1169
|
experience = await WebXRDefaultExperience.CreateAsync(scene, options);
|
|
@@ -1152,10 +1171,19 @@ export default class BabylonJSController {
|
|
|
1152
1171
|
return false;
|
|
1153
1172
|
}
|
|
1154
1173
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1174
|
+
/** @returns {?AssetContainer} Visible, settled model container, or null while unavailable. */
|
|
1175
|
+
const getModel = () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
|
|
1176
|
+
? this.#containers.model.assetContainer : null;
|
|
1177
|
+
buttons = new XRModeButtons(this.#canvas.parentElement, experience, modes,
|
|
1178
|
+
() => this.#viewCubeConfig.getCulture?.() ?? I18n.getLocale());
|
|
1179
|
+
if (hasAR) {
|
|
1180
|
+
placement = new BabylonJSARPlacement(scene, experience, getModel,
|
|
1181
|
+
() => this.#containers.environment?.assetContainer,
|
|
1182
|
+
() => buttons?.requestedMode === "immersive-ar");
|
|
1183
|
+
}
|
|
1184
|
+
if (hasVR) {
|
|
1185
|
+
stage = new BabylonJSVRStage(scene, experience, getModel, () => buttons?.requestedMode === "immersive-vr");
|
|
1186
|
+
}
|
|
1159
1187
|
|
|
1160
1188
|
experience.baseExperience.onStateChangedObservable.add(() => {
|
|
1161
1189
|
// Babylon schedules XR frames only while engine render callbacks are registered.
|
|
@@ -1170,6 +1198,8 @@ export default class BabylonJSController {
|
|
|
1170
1198
|
this.#addStylesToARButton();
|
|
1171
1199
|
this.#XRExperience = experience;
|
|
1172
1200
|
this.#arPlacement = placement;
|
|
1201
|
+
this.#vrStage = stage;
|
|
1202
|
+
this.#xrButtons = buttons;
|
|
1173
1203
|
return true;
|
|
1174
1204
|
} catch (error) {
|
|
1175
1205
|
console.warn("PrefViewer: failed to create WebXR experience", error);
|
|
@@ -1182,6 +1212,8 @@ export default class BabylonJSController {
|
|
|
1182
1212
|
console.warn("PrefViewer: failed to dispose obsolete WebXR experience", error);
|
|
1183
1213
|
} finally {
|
|
1184
1214
|
placement?.dispose();
|
|
1215
|
+
stage?.dispose();
|
|
1216
|
+
buttons?.dispose();
|
|
1185
1217
|
}
|
|
1186
1218
|
}
|
|
1187
1219
|
}
|
|
@@ -1191,6 +1223,7 @@ export default class BabylonJSController {
|
|
|
1191
1223
|
* Prepares Quick Look from completed, visible model/material state without changing download helpers.
|
|
1192
1224
|
* Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
|
|
1193
1225
|
* asynchronous export ownership, stale results and preparation errors are handled by QuickLookAction.
|
|
1226
|
+
* The prepared asset comes from the host provider when one is set (see setQuickLookAssetProvider), otherwise from the export.
|
|
1194
1227
|
* @private
|
|
1195
1228
|
* @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
|
|
1196
1229
|
*/
|
|
@@ -1218,7 +1251,66 @@ export default class BabylonJSController {
|
|
|
1218
1251
|
}
|
|
1219
1252
|
// AssetContainer contains only model meshes; applied materials and world transforms are retained.
|
|
1220
1253
|
// No environment, XR ground, ViewCube or utility-scene overlays enter this export.
|
|
1221
|
-
void this.#quickLookAction.update(() => this.#
|
|
1254
|
+
void this.#quickLookAction.update(() => this.#prepareQuickLookAsset(asset, eligible));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Installs or clears the host-supplied provider of the asset opened by the iOS Quick Look action.
|
|
1259
|
+
* The provider is retained even before the Quick Look route exists. While Quick Look is active, changing it
|
|
1260
|
+
* re-prepares the link at once, so clearing returns to the in-browser USDZ export.
|
|
1261
|
+
* @public
|
|
1262
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
1263
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to clear.
|
|
1264
|
+
* @returns {void} Ignores any value that is neither a function nor null by clearing the provider.
|
|
1265
|
+
*/
|
|
1266
|
+
setQuickLookAssetProvider(provider) {
|
|
1267
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
1268
|
+
this.#refreshQuickLook();
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Prepares the Quick Look asset: the host provider's asset when one is set, otherwise (or when the provider
|
|
1273
|
+
* fails or returns no data) the in-browser USDZ export of the current model.
|
|
1274
|
+
* @private
|
|
1275
|
+
* @param {import("@babylonjs/core").AssetContainer} asset - Model container used by the fallback export.
|
|
1276
|
+
* @param {function(import("@babylonjs/core").AbstractMesh): boolean} eligible - Mesh filter for the fallback export.
|
|
1277
|
+
* @returns {Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>} Asset for QuickLookAction.
|
|
1278
|
+
*/
|
|
1279
|
+
async #prepareQuickLookAsset(asset, eligible) {
|
|
1280
|
+
const provider = this.#quickLookAssetProvider;
|
|
1281
|
+
if (provider) {
|
|
1282
|
+
try {
|
|
1283
|
+
return await BabylonJSController.#readQuickLookAsset(provider);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
console.warn("PrefViewer: Quick Look asset provider failed", error);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return this.#exportQuickLookUSDZ(asset, eligible);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Invokes a host asset provider and converts its data to bytes.
|
|
1293
|
+
* @private
|
|
1294
|
+
* @static
|
|
1295
|
+
* @param {function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>} provider - Host asset factory.
|
|
1296
|
+
* @returns {Promise<{data: Uint8Array, mimeType?: string, fileName?: string}>} Nonempty asset with byte data.
|
|
1297
|
+
* @throws {Error} When the provider rejects, or returns no data or empty/unsupported data.
|
|
1298
|
+
*/
|
|
1299
|
+
static async #readQuickLookAsset(provider) {
|
|
1300
|
+
const result = await provider();
|
|
1301
|
+
const raw = result?.data;
|
|
1302
|
+
let data = null;
|
|
1303
|
+
if (raw instanceof Uint8Array) {
|
|
1304
|
+
data = raw;
|
|
1305
|
+
} else if (raw instanceof ArrayBuffer) {
|
|
1306
|
+
data = new Uint8Array(raw);
|
|
1307
|
+
} else if (typeof Blob !== "undefined" && raw instanceof Blob) {
|
|
1308
|
+
data = new Uint8Array(await raw.arrayBuffer());
|
|
1309
|
+
}
|
|
1310
|
+
if (!data || data.length === 0) {
|
|
1311
|
+
throw new Error("PrefViewer: Quick Look asset provider returned no data");
|
|
1312
|
+
}
|
|
1313
|
+
return { data, mimeType: result.mimeType, fileName: result.fileName };
|
|
1222
1314
|
}
|
|
1223
1315
|
|
|
1224
1316
|
/**
|
|
@@ -2281,9 +2373,15 @@ export default class BabylonJSController {
|
|
|
2281
2373
|
|
|
2282
2374
|
const xrExperience = this.#XRExperience;
|
|
2283
2375
|
const placement = this.#arPlacement;
|
|
2376
|
+
const stage = this.#vrStage;
|
|
2377
|
+
const buttons = this.#xrButtons;
|
|
2284
2378
|
if (!xrExperience) {
|
|
2285
2379
|
placement?.dispose();
|
|
2380
|
+
stage?.dispose();
|
|
2381
|
+
buttons?.dispose();
|
|
2286
2382
|
this.#arPlacement = null;
|
|
2383
|
+
this.#vrStage = null;
|
|
2384
|
+
this.#xrButtons = null;
|
|
2287
2385
|
return;
|
|
2288
2386
|
}
|
|
2289
2387
|
|
|
@@ -2296,6 +2394,8 @@ export default class BabylonJSController {
|
|
|
2296
2394
|
console.warn("PrefViewer: Error exiting XR experience:", error);
|
|
2297
2395
|
} finally {
|
|
2298
2396
|
placement?.dispose();
|
|
2397
|
+
stage?.dispose();
|
|
2398
|
+
buttons?.dispose();
|
|
2299
2399
|
try {
|
|
2300
2400
|
xrExperience.dispose();
|
|
2301
2401
|
} catch (error) {
|
|
@@ -2304,6 +2404,8 @@ export default class BabylonJSController {
|
|
|
2304
2404
|
if (this.#XRExperience === xrExperience) {
|
|
2305
2405
|
this.#XRExperience = null;
|
|
2306
2406
|
this.#arPlacement = null;
|
|
2407
|
+
this.#vrStage = null;
|
|
2408
|
+
this.#xrButtons = null;
|
|
2307
2409
|
}
|
|
2308
2410
|
this.#disablingPromises.xr = null;
|
|
2309
2411
|
}
|
|
@@ -3976,6 +4078,7 @@ export default class BabylonJSController {
|
|
|
3976
4078
|
*/
|
|
3977
4079
|
refreshViewCube() {
|
|
3978
4080
|
this.#quickLookAction?.refreshCulture();
|
|
4081
|
+
this.#xrButtons?.refreshCulture();
|
|
3979
4082
|
this.#viewCube?.resize();
|
|
3980
4083
|
}
|
|
3981
4084
|
|
|
@@ -4148,6 +4251,7 @@ export default class BabylonJSController {
|
|
|
4148
4251
|
|
|
4149
4252
|
return {
|
|
4150
4253
|
ar: this.#arPlacement?.getDebugInfo() ?? null,
|
|
4254
|
+
vr: this.#vrStage?.getDebugInfo() ?? null,
|
|
4151
4255
|
canvas: {
|
|
4152
4256
|
clientWidth: this.#canvas?.clientWidth ?? null,
|
|
4153
4257
|
clientHeight: this.#canvas?.clientHeight ?? null,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
|
|
2
|
+
import { FreeCamera } from "@babylonjs/core/Cameras/freeCamera.js";
|
|
3
|
+
import { WebXRState } from "@babylonjs/core/XR/webXRTypes.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Owns the standing pose of a native `immersive-vr` session.
|
|
7
|
+
* When Babylon announces the initial pose (after it copied the orbit camera into the XR camera) and the
|
|
8
|
+
* session was requested as VR, forces metre scale and moves the viewer to floor height, 2.5 m in front of
|
|
9
|
+
* the model's bottom-centre along +Z (the Inner face), looking at it. The model and environment are not moved or hidden.
|
|
10
|
+
* Exit or cancellation restores the previous world scale. Call dispose() before releasing the owning scene.
|
|
11
|
+
*/
|
|
12
|
+
export default class BabylonJSVRStage {
|
|
13
|
+
/**
|
|
14
|
+
* Distance, in metres, between the viewer and the model's bottom-centre along +Z.
|
|
15
|
+
* @public
|
|
16
|
+
* @static
|
|
17
|
+
* @type {number}
|
|
18
|
+
*/
|
|
19
|
+
static STANDING_DISTANCE_METRES = 2.5;
|
|
20
|
+
|
|
21
|
+
#scene;
|
|
22
|
+
#experience;
|
|
23
|
+
#manager;
|
|
24
|
+
#getModel;
|
|
25
|
+
#isActive;
|
|
26
|
+
#observers = [];
|
|
27
|
+
#previousWorldScale = null;
|
|
28
|
+
#diagnostics = BabylonJSVRStage.#createDiagnostics();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates fresh diagnostics for an inactive stage.
|
|
32
|
+
* @private
|
|
33
|
+
* @static
|
|
34
|
+
* @returns {{phase: string, position: ?{x: number, y: number, z: number}, floorHeight: ?number}} Mutable diagnostic state.
|
|
35
|
+
*/
|
|
36
|
+
static #createDiagnostics() {
|
|
37
|
+
return { phase: "inactive", position: null, floorHeight: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribes to the XR pose and lifecycle events without touching the scene or session yet.
|
|
42
|
+
* @param {import("@babylonjs/core").Scene} scene - Scene containing the model.
|
|
43
|
+
* @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Native XR lifecycle owner.
|
|
44
|
+
* @param {function(): ?import("@babylonjs/core").AssetContainer} getModel - Current usable model, or null while unavailable.
|
|
45
|
+
* @param {function(): boolean} isActive - Whether the session being entered is a VR session.
|
|
46
|
+
*/
|
|
47
|
+
constructor(scene, experience, getModel, isActive) {
|
|
48
|
+
this.#scene = scene;
|
|
49
|
+
this.#experience = experience;
|
|
50
|
+
this.#manager = experience.baseExperience.sessionManager;
|
|
51
|
+
this.#getModel = getModel;
|
|
52
|
+
this.#isActive = isActive;
|
|
53
|
+
this.#observe(experience.baseExperience.onInitialXRPoseSetObservable, () => this.#place());
|
|
54
|
+
this.#observe(experience.baseExperience.onStateChangedObservable, (state) => {
|
|
55
|
+
if (state === WebXRState.NOT_IN_XR) {
|
|
56
|
+
this.#end();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
this.#observe(this.#manager.onXRSessionEnded, () => this.#end());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Registers a Babylon observer and retains its handle for disposal.
|
|
64
|
+
* @private
|
|
65
|
+
* @param {import("@babylonjs/core").Observable<*>} observable - Event stream.
|
|
66
|
+
* @param {function(*): void} callback - Handler invoked by that stream.
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
#observe(observable, callback) {
|
|
70
|
+
this.#observers.push([observable, observable.add(callback)]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Computes the world bounds of the model's root meshes (and their descendants).
|
|
75
|
+
* @private
|
|
76
|
+
* @param {import("@babylonjs/core").AssetContainer} asset - Model container.
|
|
77
|
+
* @returns {?{min: Vector3, max: Vector3}} World-space bounds, or null when the model has no geometry.
|
|
78
|
+
*/
|
|
79
|
+
#getBounds(asset) {
|
|
80
|
+
let min = null;
|
|
81
|
+
let max = null;
|
|
82
|
+
for (const mesh of asset.meshes ?? []) {
|
|
83
|
+
if (mesh.parent) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
mesh.computeWorldMatrix(true);
|
|
87
|
+
const bounds = mesh.getHierarchyBoundingVectors(true);
|
|
88
|
+
if (![bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z].every(Number.isFinite)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
min = min ? Vector3.Minimize(min, bounds.min) : bounds.min.clone();
|
|
92
|
+
max = max ? Vector3.Maximize(max, bounds.max) : bounds.max.clone();
|
|
93
|
+
}
|
|
94
|
+
return min && max ? { min, max } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sets metre scale and places the XR camera at floor height in front of the model, facing it.
|
|
99
|
+
* Re-applies the floor height after the copy because Babylon resets it to zero (webXRCamera.js, "set the ground level").
|
|
100
|
+
* Keeps Babylon's pose when the model is unavailable. Ignores sessions that were not requested as VR.
|
|
101
|
+
* @private
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
104
|
+
#place() {
|
|
105
|
+
if (!this.#isActive()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.#previousWorldScale === null) {
|
|
109
|
+
this.#previousWorldScale = this.#manager.worldScalingFactor;
|
|
110
|
+
}
|
|
111
|
+
if (this.#manager.worldScalingFactor !== 1) {
|
|
112
|
+
this.#manager.worldScalingFactor = 1;
|
|
113
|
+
}
|
|
114
|
+
const asset = this.#getModel();
|
|
115
|
+
const bounds = asset ? this.#getBounds(asset) : null;
|
|
116
|
+
if (!bounds) {
|
|
117
|
+
this.#diagnostics = { phase: "no-model", position: null, floorHeight: null };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const floorY = bounds.min.y;
|
|
121
|
+
const centreX = (bounds.min.x + bounds.max.x) / 2;
|
|
122
|
+
const centreZ = (bounds.min.z + bounds.max.z) / 2;
|
|
123
|
+
const standing = new FreeCamera("PrefViewerVRStanding",
|
|
124
|
+
new Vector3(centreX, floorY, centreZ + BabylonJSVRStage.STANDING_DISTANCE_METRES), this.#scene);
|
|
125
|
+
const camera = this.#experience.baseExperience.camera;
|
|
126
|
+
try {
|
|
127
|
+
standing.setTarget(new Vector3(centreX, floorY, centreZ));
|
|
128
|
+
camera.setTransformationFromNonVRCamera(standing, true);
|
|
129
|
+
// Babylon's WebXRCamera forces the copied ground level to y = 0; the model floor is the real
|
|
130
|
+
// ground, and the native head height is added on the first tracked frame.
|
|
131
|
+
camera.position.y = floorY;
|
|
132
|
+
} finally {
|
|
133
|
+
standing.dispose();
|
|
134
|
+
}
|
|
135
|
+
this.#diagnostics = {
|
|
136
|
+
phase: "placed",
|
|
137
|
+
position: { x: centreX, y: floorY, z: centreZ + BabylonJSVRStage.STANDING_DISTANCE_METRES },
|
|
138
|
+
floorHeight: floorY,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Restores the world scale saved at entry and marks the stage inactive. Safe to call repeatedly.
|
|
144
|
+
* Native update failures are logged so Babylon can finish restoring its camera.
|
|
145
|
+
* @private
|
|
146
|
+
* @returns {void}
|
|
147
|
+
*/
|
|
148
|
+
#end() {
|
|
149
|
+
const previous = this.#previousWorldScale;
|
|
150
|
+
this.#previousWorldScale = null;
|
|
151
|
+
this.#diagnostics.phase = "inactive";
|
|
152
|
+
if (previous === null || this.#manager.worldScalingFactor === previous) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
this.#manager.worldScalingFactor = previous;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.name !== "InvalidStateError" || this.#manager.inXRSession) {
|
|
159
|
+
console.warn("PrefViewer: VR world-scale cleanup failed", error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns a detached snapshot of the last standing placement; it survives session exit as inactive.
|
|
166
|
+
* @public
|
|
167
|
+
* @returns {{phase: string, standingDistance: number, position: ?{x: number, y: number, z: number}, floorHeight: ?number}}
|
|
168
|
+
* Phase is "inactive", "placed" or "no-model"; position is where the XR camera was placed in scene metres.
|
|
169
|
+
*/
|
|
170
|
+
getDebugInfo() {
|
|
171
|
+
return {
|
|
172
|
+
phase: this.#diagnostics.phase,
|
|
173
|
+
standingDistance: BabylonJSVRStage.STANDING_DISTANCE_METRES,
|
|
174
|
+
position: this.#diagnostics.position ? { ...this.#diagnostics.position } : null,
|
|
175
|
+
floorHeight: this.#diagnostics.floorHeight,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Restores the world scale and removes every registered observer. Idempotent.
|
|
181
|
+
* @public
|
|
182
|
+
* @returns {void}
|
|
183
|
+
*/
|
|
184
|
+
dispose() {
|
|
185
|
+
this.#end();
|
|
186
|
+
for (const [observable, observer] of this.#observers) {
|
|
187
|
+
observable.remove(observer);
|
|
188
|
+
}
|
|
189
|
+
this.#observers = [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -6,6 +6,9 @@ export const translations = {
|
|
|
6
6
|
preparing: "Preparing AR…",
|
|
7
7
|
retry: "Retry preparing AR",
|
|
8
8
|
},
|
|
9
|
+
vr: {
|
|
10
|
+
open: "View in VR",
|
|
11
|
+
},
|
|
9
12
|
viewCube: {
|
|
10
13
|
title: "View orientation",
|
|
11
14
|
front: "Inner",
|
|
@@ -82,6 +85,9 @@ export const translations = {
|
|
|
82
85
|
preparing: "Preparando RA…",
|
|
83
86
|
retry: "Reintentar preparar RA",
|
|
84
87
|
},
|
|
88
|
+
vr: {
|
|
89
|
+
open: "Ver en VR",
|
|
90
|
+
},
|
|
85
91
|
viewCube: {
|
|
86
92
|
title: "Orientación de vista",
|
|
87
93
|
front: "Interior",
|
package/src/pref-viewer-3d.js
CHANGED
|
@@ -51,6 +51,7 @@ import FileStorage from "./file-storage.js";
|
|
|
51
51
|
* - downloadModelGLB(): Downloads the current 3D model as a GLB file.
|
|
52
52
|
* - downloadModelGLTF(): Downloads the current 3D model as a glTF ZIP file.
|
|
53
53
|
* - downloadModelUSDZ(): Downloads the current 3D model as a USDZ file.
|
|
54
|
+
* - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
|
|
54
55
|
* - downloadModelAndSceneGLB(): Downloads both the model and scene as a GLB file.
|
|
55
56
|
* - downloadModelAndSceneGLTF(): Downloads both the model and scene as a glTF ZIP file.
|
|
56
57
|
* - downloadModelAndSceneUSDZ(): Downloads both the model and scene as a USDZ file.
|
|
@@ -102,6 +103,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
102
103
|
#data = null; // Component data
|
|
103
104
|
|
|
104
105
|
#babylonJSController = null; // BabylonJSController instance
|
|
106
|
+
#quickLookAssetProvider = null; // Host asset provider, reapplied to every controller this element creates
|
|
105
107
|
|
|
106
108
|
/** @returns {"svg"|"canvas"} Per-instance normalized renderer; missing/invalid attributes use Canvas. */
|
|
107
109
|
get viewCubeRenderer() {
|
|
@@ -283,6 +285,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
283
285
|
* Initializes the Babylon controller with asset/option state and the selected visibility-aware cube contract.
|
|
284
286
|
* Supplies current host culture and dispatches measured view-cube-layout events for render-menu positioning.
|
|
285
287
|
* Starts asynchronous engine initialization without waiting for asset loading.
|
|
288
|
+
* Reapplies any retained Quick Look asset provider to the new controller before it starts.
|
|
286
289
|
* @private
|
|
287
290
|
* @returns {void}
|
|
288
291
|
*/
|
|
@@ -303,6 +306,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
303
306
|
*/
|
|
304
307
|
onLayout: (offset, height) => this.dispatchEvent(new CustomEvent("view-cube-layout", { detail: { offset, height } })),
|
|
305
308
|
});
|
|
309
|
+
this.#babylonJSController.setQuickLookAssetProvider(this.#quickLookAssetProvider);
|
|
306
310
|
this.#babylonJSController.enable();
|
|
307
311
|
|
|
308
312
|
}
|
|
@@ -875,6 +879,20 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
875
879
|
this.#babylonJSController.downloadGLTF(1);
|
|
876
880
|
}
|
|
877
881
|
|
|
882
|
+
/**
|
|
883
|
+
* Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
|
|
884
|
+
* The provider is retained across connection cycles, even when set before the element is connected.
|
|
885
|
+
* @public
|
|
886
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
887
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
|
|
888
|
+
* A failing provider falls back to the built-in export with a console warning.
|
|
889
|
+
* @returns {void}
|
|
890
|
+
*/
|
|
891
|
+
setQuickLookAssetProvider(provider) {
|
|
892
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
893
|
+
this.#babylonJSController?.setQuickLookAssetProvider(this.#quickLookAssetProvider);
|
|
894
|
+
}
|
|
895
|
+
|
|
878
896
|
/**
|
|
879
897
|
* Downloads the current 3D model as a USDZ file.
|
|
880
898
|
* @public
|
package/src/pref-viewer.js
CHANGED
|
@@ -43,6 +43,7 @@ import I18n from "./localization/i18n.js";
|
|
|
43
43
|
* - showModel / hideModel / showScene / hideScene: Toggle visibility using the internal task queue for consistent sequencing.
|
|
44
44
|
* - zoomCenter / zoomExtentsAll / zoomIn / zoomOut: Forward zoom commands to the 2D controller when active.
|
|
45
45
|
* - downloadModelGLB / downloadModelGLTF / downloadModelUSDZ: Export the loaded model in the requested format.
|
|
46
|
+
* - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
|
|
46
47
|
* - downloadSceneGLB / downloadSceneGLTF / downloadSceneUSDZ: Export the loaded environment in the requested format.
|
|
47
48
|
* - downloadModelAndSceneGLB / downloadModelAndSceneGLTF / downloadModelAndSceneUSDZ: Export the combined scene in the requested format.
|
|
48
49
|
* - openDialog / closeDialog: Manage PrefViewerDialog instances rendered inside the shadow tree.
|
|
@@ -115,6 +116,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
115
116
|
#wrapper = null;
|
|
116
117
|
#component2D = null;
|
|
117
118
|
#component3D = null;
|
|
119
|
+
#quickLookAssetProvider = null; // Host asset provider, reapplied to every 3D component this element creates
|
|
118
120
|
#dialog = null;
|
|
119
121
|
#menu3D = null;
|
|
120
122
|
|
|
@@ -394,6 +396,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
394
396
|
* Creates and appends the 3D viewer component to the shadow DOM, initially hidden.
|
|
395
397
|
* Forwards renderer selection and show-view-cube, stores its layout handler in #handlers and subscribes before activation
|
|
396
398
|
* so the first measured layout positions the render menu correctly.
|
|
399
|
+
* Hands any retained Quick Look asset provider to the new component.
|
|
397
400
|
* @private
|
|
398
401
|
* @returns {void}
|
|
399
402
|
*/
|
|
@@ -401,6 +404,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
401
404
|
this.#handlers.on3DElementClick = this.#on3DElementClick.bind(this);
|
|
402
405
|
this.#component3D = document.createElement("pref-viewer-3d");
|
|
403
406
|
this.#component3D.setAttribute("visible", "false");
|
|
407
|
+
this.#component3D.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
|
|
404
408
|
this.#component3D.setAttribute("view-cube-renderer", this.viewCubeRenderer);
|
|
405
409
|
this.#component3D.setAttribute("show-view-cube", this.getAttribute("show-view-cube") === "false" ? "false" : "true");
|
|
406
410
|
this.#handlers.onViewCubeLayout = (event) => {
|
|
@@ -1289,6 +1293,21 @@ export default class PrefViewer extends HTMLElement {
|
|
|
1289
1293
|
this.#component3D.downloadModelGLTF();
|
|
1290
1294
|
}
|
|
1291
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
|
|
1298
|
+
* The provider is retained across connection cycles, even when set before the element is connected.
|
|
1299
|
+
* The viewer knows nothing about how the host produces the asset.
|
|
1300
|
+
* @public
|
|
1301
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
1302
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
|
|
1303
|
+
* A failing provider falls back to the built-in export with a console warning.
|
|
1304
|
+
* @returns {void}
|
|
1305
|
+
*/
|
|
1306
|
+
setQuickLookAssetProvider(provider) {
|
|
1307
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
1308
|
+
this.#component3D?.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1292
1311
|
/**
|
|
1293
1312
|
* Initiates download of the current 3D model in USDZ format.
|
|
1294
1313
|
* @public
|
package/src/quick-look-action.js
CHANGED
|
@@ -17,6 +17,22 @@ export default class QuickLookAction {
|
|
|
17
17
|
*/
|
|
18
18
|
static QUICK_LOOK_FRAGMENT = "allowsContentScaling=0";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* MIME type of a USDZ archive; the only type whose bytes are validated as a ZIP before being exposed.
|
|
22
|
+
* @public
|
|
23
|
+
* @static
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
static USDZ_MIME_TYPE = "model/vnd.usdz+zip";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* File name given to the anchor's `download` attribute when the prepared asset does not name itself.
|
|
30
|
+
* @public
|
|
31
|
+
* @static
|
|
32
|
+
* @type {string}
|
|
33
|
+
*/
|
|
34
|
+
static DEFAULT_FILE_NAME = "model.usdz";
|
|
35
|
+
|
|
20
36
|
#anchor;
|
|
21
37
|
#style;
|
|
22
38
|
#window;
|
|
@@ -55,7 +71,7 @@ export default class QuickLookAction {
|
|
|
55
71
|
this.#anchor = document.createElement("a");
|
|
56
72
|
this.#anchor.className = "babylonVRicon pref-viewer-quick-look";
|
|
57
73
|
this.#anchor.rel = "ar";
|
|
58
|
-
this.#anchor.download =
|
|
74
|
+
this.#anchor.download = QuickLookAction.DEFAULT_FILE_NAME;
|
|
59
75
|
this.#anchor.setAttribute("role", "button");
|
|
60
76
|
this.#anchor.tabIndex = 0;
|
|
61
77
|
this.#anchor.hidden = true;
|
|
@@ -204,11 +220,38 @@ export default class QuickLookAction {
|
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
/**
|
|
207
|
-
*
|
|
223
|
+
* Normalizes an exporter result into the bytes, MIME type and file name of the asset to expose.
|
|
224
|
+
* A bare `Uint8Array` is a USDZ with default metadata; an object carries its own optional metadata.
|
|
225
|
+
* USDZ content must be a nonempty ZIP-signature archive; other MIME types are passed through as-is.
|
|
226
|
+
* @private
|
|
227
|
+
* @static
|
|
228
|
+
* @param {Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}} result - Exporter result.
|
|
229
|
+
* @returns {{data: Uint8Array, mimeType: string, fileName: string}} Validated asset description.
|
|
230
|
+
* @throws {Error} When the result is not byte content or a USDZ lacks the ZIP signature.
|
|
231
|
+
*/
|
|
232
|
+
static #resolveAsset(result) {
|
|
233
|
+
const isDescribed = !!result && typeof result === "object" && !(result instanceof Uint8Array);
|
|
234
|
+
const data = isDescribed ? result.data : result;
|
|
235
|
+
const mimeType = (isDescribed && result.mimeType) || QuickLookAction.USDZ_MIME_TYPE;
|
|
236
|
+
const fileName = (isDescribed && result.fileName) || QuickLookAction.DEFAULT_FILE_NAME;
|
|
237
|
+
if (!(data instanceof Uint8Array) || data.length === 0) {
|
|
238
|
+
throw new Error("PrefViewer: invalid Quick Look export");
|
|
239
|
+
}
|
|
240
|
+
// USDZ is a ZIP archive; never expose empty, JSON or glTF results as native AR.
|
|
241
|
+
if (mimeType === QuickLookAction.USDZ_MIME_TYPE
|
|
242
|
+
&& (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b || data[2] !== 3 || data[3] !== 4)) {
|
|
243
|
+
throw new Error("PrefViewer: invalid USDZ export");
|
|
244
|
+
}
|
|
245
|
+
return { data, mimeType, fileName };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Queues current-model export and exposes only a valid asset as a native link: a nonempty ZIP-signature USDZ,
|
|
250
|
+
* or, when the exporter describes another MIME type, that asset unchanged.
|
|
208
251
|
* Obsolete revisions are ignored. Export failures are logged and shown as a retry action;
|
|
209
252
|
* they do not reject the returned work promise or expose an older model's content.
|
|
210
253
|
* @public
|
|
211
|
-
* @param {?(function(): Promise<Uint8Array>)} exporter - Current-model serializer, or null to invalidate without preparation.
|
|
254
|
+
* @param {?(function(): Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>)} exporter - Current-model serializer returning USDZ bytes or a described asset (defaults: USDZ MIME type, `model.usdz`), or null to invalidate without preparation.
|
|
212
255
|
* @returns {Promise<void>} Resolves after this queued preparation settles or becomes obsolete.
|
|
213
256
|
*/
|
|
214
257
|
update(exporter) {
|
|
@@ -231,17 +274,15 @@ export default class QuickLookAction {
|
|
|
231
274
|
return;
|
|
232
275
|
}
|
|
233
276
|
try {
|
|
234
|
-
const
|
|
277
|
+
const result = await exporter();
|
|
235
278
|
if (this.#disposed || revision !== this.#revision) {
|
|
236
279
|
return;
|
|
237
280
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
const blob = new this.#window.Blob([bytes], {
|
|
243
|
-
type: "model/vnd.usdz+zip"
|
|
281
|
+
const asset = QuickLookAction.#resolveAsset(result);
|
|
282
|
+
const blob = new this.#window.Blob([asset.data], {
|
|
283
|
+
type: asset.mimeType
|
|
244
284
|
});
|
|
285
|
+
this.#anchor.download = asset.fileName;
|
|
245
286
|
this.#url = this.#urlAPI.createObjectURL(blob);
|
|
246
287
|
// Quick Look reads the fragment: content scaling off keeps the authored 1:1 metre scale,
|
|
247
288
|
// matching the Android WebXR path. The bare URL is kept for revocation.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { WebXRState } from "@babylonjs/core/XR/webXRTypes.js";
|
|
2
|
+
import I18n from "./localization/i18n.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Owns the viewer's WebXR enter/exit buttons, one per supported session mode (AR and/or VR).
|
|
6
|
+
* Replaces Babylon's default enter/exit UI so every mode requests its own reference space and features.
|
|
7
|
+
* Records the requested mode before calling `enterXRAsync`, because Babylon reports ENTERING_XR before the
|
|
8
|
+
* session manager knows the session mode; collaborators read it through `requestedMode`.
|
|
9
|
+
* Call dispose() when the owning viewer is torn down.
|
|
10
|
+
*/
|
|
11
|
+
export default class XRModeButtons {
|
|
12
|
+
static #CLASS_BY_MODE = { "immersive-ar": "pref-viewer-xr-ar", "immersive-vr": "pref-viewer-xr-vr" };
|
|
13
|
+
static #LABEL_BY_MODE = { "immersive-ar": "prefViewer.ar.open", "immersive-vr": "prefViewer.vr.open" };
|
|
14
|
+
|
|
15
|
+
#experience;
|
|
16
|
+
#getCulture;
|
|
17
|
+
#overlay;
|
|
18
|
+
#entries = [];
|
|
19
|
+
#requestedMode = null;
|
|
20
|
+
#stateObserver;
|
|
21
|
+
#unsubscribe;
|
|
22
|
+
#disposed = false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates the overlay and buttons, then subscribes to XR state and locale changes.
|
|
26
|
+
* @param {HTMLElement} container - Canvas wrapper receiving the overlay.
|
|
27
|
+
* @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Experience created with its default UI disabled.
|
|
28
|
+
* @param {Array<{mode: string, referenceSpaceType: string, requiredFeatures: string[], optionalFeatures: string[]}>} modes - Session modes to expose, one button each.
|
|
29
|
+
* @param {function(): string} [getCulture] - Host locale provider; defaults to the active I18n locale.
|
|
30
|
+
*/
|
|
31
|
+
constructor(container, experience, modes, getCulture = () => I18n.getLocale()) {
|
|
32
|
+
this.#experience = experience;
|
|
33
|
+
this.#getCulture = getCulture;
|
|
34
|
+
const document = container.ownerDocument;
|
|
35
|
+
this.#overlay = document.createElement("div");
|
|
36
|
+
this.#overlay.className = "xr-button-overlay";
|
|
37
|
+
this.#overlay.style.cssText = "z-index:11;position:absolute;right:20px;bottom:50px;display:flex;";
|
|
38
|
+
for (const config of modes) {
|
|
39
|
+
const button = document.createElement("button");
|
|
40
|
+
button.type = "button";
|
|
41
|
+
button.className = `babylonVRicon ${XRModeButtons.#CLASS_BY_MODE[config.mode] ?? ""}`.trim();
|
|
42
|
+
button.addEventListener("click", () => { void this.#onClick(config); });
|
|
43
|
+
this.#overlay.append(button);
|
|
44
|
+
this.#entries.push({ config, button });
|
|
45
|
+
}
|
|
46
|
+
// Subscribe before attaching so a failing subscription leaves nothing in the DOM.
|
|
47
|
+
this.#stateObserver = experience.baseExperience.onStateChangedObservable.add((state) => this.#onState(state));
|
|
48
|
+
this.#unsubscribe = I18n.onLocaleChange(() => this.refreshCulture());
|
|
49
|
+
container.append(this.#overlay);
|
|
50
|
+
this.refreshCulture();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mode of the session being entered or active, set before Babylon announces ENTERING_XR.
|
|
55
|
+
* @public
|
|
56
|
+
* @returns {?string} "immersive-ar", "immersive-vr", or null when no session was requested.
|
|
57
|
+
*/
|
|
58
|
+
get requestedMode() {
|
|
59
|
+
return this.#requestedMode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Enters the clicked mode when idle or exits the current session when active; ignores transitional states.
|
|
64
|
+
* Entry failures are logged, flagged on the button with the `xr-error` class and clear the requested mode.
|
|
65
|
+
* @private
|
|
66
|
+
* @param {{mode: string, referenceSpaceType: string, requiredFeatures: string[], optionalFeatures: string[]}} config - Mode of the clicked button.
|
|
67
|
+
* @returns {Promise<void>} Resolves after the enter/exit request settles; never rejects.
|
|
68
|
+
*/
|
|
69
|
+
async #onClick(config) {
|
|
70
|
+
const base = this.#experience.baseExperience;
|
|
71
|
+
const entry = this.#entries.find((item) => item.config === config);
|
|
72
|
+
try {
|
|
73
|
+
if (base.state === WebXRState.IN_XR) {
|
|
74
|
+
await base.exitXRAsync();
|
|
75
|
+
} else if (base.state === WebXRState.NOT_IN_XR) {
|
|
76
|
+
entry?.button.classList.remove("xr-error");
|
|
77
|
+
this.#requestedMode = config.mode;
|
|
78
|
+
await base.enterXRAsync(config.mode, config.referenceSpaceType, this.#experience.renderTarget, {
|
|
79
|
+
requiredFeatures: config.requiredFeatures,
|
|
80
|
+
optionalFeatures: config.optionalFeatures,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!this.#disposed && base.state !== WebXRState.IN_XR) {
|
|
85
|
+
this.#requestedMode = null;
|
|
86
|
+
entry?.button.classList.add("xr-error");
|
|
87
|
+
}
|
|
88
|
+
console.warn("PrefViewer: WebXR session request failed", error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Shows only the active mode's button as "exit" while in XR and restores all buttons on exit.
|
|
94
|
+
* @private
|
|
95
|
+
* @param {number} state - New WebXRState value.
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
#onState(state) {
|
|
99
|
+
if (state === WebXRState.IN_XR) {
|
|
100
|
+
for (const { config, button } of this.#entries) {
|
|
101
|
+
const active = config.mode === this.#requestedMode;
|
|
102
|
+
button.classList.toggle("vrdisplaypresenting", active);
|
|
103
|
+
button.style.display = active ? "" : "none";
|
|
104
|
+
}
|
|
105
|
+
} else if (state === WebXRState.NOT_IN_XR) {
|
|
106
|
+
this.#requestedMode = null;
|
|
107
|
+
for (const { button } of this.#entries) {
|
|
108
|
+
button.classList.remove("vrdisplaypresenting");
|
|
109
|
+
button.style.display = "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Re-applies the localized accessible label and tooltip of every button from the host culture.
|
|
116
|
+
* @public
|
|
117
|
+
* @returns {void}
|
|
118
|
+
*/
|
|
119
|
+
refreshCulture() {
|
|
120
|
+
if (this.#disposed) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const { config, button } of this.#entries) {
|
|
124
|
+
const text = I18n.translate(XRModeButtons.#LABEL_BY_MODE[config.mode], { locale: this.#getCulture() });
|
|
125
|
+
button.setAttribute("aria-label", text);
|
|
126
|
+
button.title = text;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Idempotently removes the overlay, the XR state observer and the locale subscription.
|
|
132
|
+
* @public
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
dispose() {
|
|
136
|
+
if (this.#disposed) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.#disposed = true;
|
|
140
|
+
this.#experience.baseExperience.onStateChangedObservable.remove(this.#stateObserver);
|
|
141
|
+
this.#unsubscribe();
|
|
142
|
+
this.#overlay.remove();
|
|
143
|
+
this.#entries = [];
|
|
144
|
+
this.#requestedMode = null;
|
|
145
|
+
}
|
|
146
|
+
}
|