@preference-sl/pref-viewer 3.0.1-beta.0 → 3.1.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 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
- | Other browsers without a supported AR route | No AR action |
30
- | iPhone VR | Not implemented |
31
- | visionOS WebXR VR | Platform capability; this viewer does not implement `immersive-vr` |
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "3.0.1-beta.0",
3
+ "version": "3.1.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": {
@@ -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";
@@ -127,6 +129,8 @@ export default class BabylonJSController {
127
129
  #shadowGen = [];
128
130
  #XRExperience = null;
129
131
  #arPlacement = null;
132
+ #vrStage = null;
133
+ #xrButtons = null;
130
134
  #xrGeneration = 0;
131
135
  #quickLookAction = null;
132
136
  #quickLookGlass = new BabylonJSARGlass();
@@ -1031,7 +1035,8 @@ export default class BabylonJSController {
1031
1035
  }
1032
1036
 
1033
1037
  /**
1034
- * Adds the supplied cube-and-scan-frame SVG and shared interaction styles to native XR and Quick Look buttons.
1038
+ * Adds the supplied cube-and-scan-frame SVG, a headset icon for the VR button and shared interaction styles
1039
+ * to the XR mode buttons and the Quick Look action.
1035
1040
  * Preserves the supplied 24-unit geometry and renders its filled paths white against the toggle background.
1036
1041
  * @private
1037
1042
  * @returns {void}
@@ -1047,7 +1052,8 @@ export default class BabylonJSController {
1047
1052
  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
1053
  c0.25-0.13,0.5-0.19,0.75-0.19s0.5,0.06,0.75,0.19L18.23,7.59z"/>
1049
1054
  </svg>`;
1050
- 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: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"}`;
1055
+ 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>`;
1056
+ 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
1057
  let style = this.#canvas.parentElement.parentElement.querySelector("style");
1052
1058
  if (!style) {
1053
1059
  style = document.createElement("style");
@@ -1083,11 +1089,14 @@ export default class BabylonJSController {
1083
1089
  }
1084
1090
 
1085
1091
  /**
1086
- * Selects and initializes supported WebXR AR or an eligible Safari Quick Look action.
1092
+ * Selects and initializes supported WebXR AR and/or VR, or an eligible Safari Quick Look action.
1087
1093
  * @private
1088
- * @returns {Promise<boolean>} True when an AR route is configured, false when unsupported, obsolete or setup fails.
1094
+ * @returns {Promise<boolean>} True when an XR route is configured, false when unsupported, obsolete or setup fails.
1089
1095
  * @description
1090
- * Checks AR support and requires native floor hit testing at real-world metre scale.
1096
+ * Queries `immersive-ar` and `immersive-vr` support; a failed VR query only disables VR.
1097
+ * Babylon's default enter/exit UI is disabled; an XRModeButtons overlay renders one button per supported mode,
1098
+ * each requesting its own reference space and features. VR is standing-scale (`local-floor`) and positioned by BabylonJSVRStage.
1099
+ * AR requires native floor hit testing at real-world metre scale.
1091
1100
  * Requests optional plane detection so initial placement can use an observed floor contour when supplied.
1092
1101
  * Placement follows the tracked XR camera rather than inheriting the showroom camera's fit.
1093
1102
  * Adds custom styles to the AR button for consistent appearance.
@@ -1104,11 +1113,16 @@ export default class BabylonJSController {
1104
1113
  const generation = this.#xrGeneration;
1105
1114
  /** @returns {boolean} Whether this setup still owns the live scene generation. */
1106
1115
  const isCurrent = () => this.#scene === scene && this.#xrGeneration === generation && !scene.isDisposed;
1107
- const sessionMode = "immersive-ar";
1108
1116
  let experience = null;
1109
1117
  let placement = null;
1118
+ let stage = null;
1119
+ let buttons = null;
1110
1120
  try {
1111
- const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
1121
+ const [sessionSupported, vrSupported] = await Promise.all([
1122
+ WebXRSessionManager.IsSessionSupportedAsync("immersive-ar"),
1123
+ // An unavailable VR query must not take AR down with it.
1124
+ Promise.resolve(WebXRSessionManager.IsSessionSupportedAsync("immersive-vr")).catch(() => null),
1125
+ ]);
1112
1126
  if (!isCurrent()) {
1113
1127
  return false;
1114
1128
  }
@@ -1127,10 +1141,19 @@ export default class BabylonJSController {
1127
1141
  this.#refreshQuickLook();
1128
1142
  return true;
1129
1143
  }
1130
- if (route !== "webxr") {
1131
- console.info("PrefViewer: WebXR in mode AR is not supported");
1144
+ const modes = [];
1145
+ if (route === "webxr") {
1146
+ modes.push({ mode: "immersive-ar", referenceSpaceType: "local", requiredFeatures: ["hit-test"], optionalFeatures: ["plane-detection"] });
1147
+ }
1148
+ if (vrSupported === true) {
1149
+ modes.push({ mode: "immersive-vr", referenceSpaceType: "local-floor", requiredFeatures: [], optionalFeatures: [] });
1150
+ }
1151
+ if (modes.length === 0) {
1152
+ console.info("PrefViewer: WebXR in mode AR/VR is not supported");
1132
1153
  return false;
1133
1154
  }
1155
+ const hasAR = modes.some((item) => item.mode === "immersive-ar");
1156
+ const hasVR = modes.some((item) => item.mode === "immersive-vr");
1134
1157
 
1135
1158
  const options = {
1136
1159
  disableTeleportation: true,
@@ -1138,13 +1161,7 @@ export default class BabylonJSController {
1138
1161
  disableNearInteraction: true,
1139
1162
  disableHandTracking: true,
1140
1163
  ignoreNativeCameraTransformation: true,
1141
- uiOptions: {
1142
- sessionMode: sessionMode,
1143
- renderTarget: "xrLayer",
1144
- referenceSpaceType: "local",
1145
- requiredFeatures: ["hit-test"],
1146
- optionalFeatures: ["plane-detection"],
1147
- },
1164
+ disableDefaultUI: true,
1148
1165
  };
1149
1166
 
1150
1167
  experience = await WebXRDefaultExperience.CreateAsync(scene, options);
@@ -1152,10 +1169,19 @@ export default class BabylonJSController {
1152
1169
  return false;
1153
1170
  }
1154
1171
 
1155
- placement = new BabylonJSARPlacement(scene, experience,
1156
- () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
1157
- ? this.#containers.model.assetContainer : null,
1158
- () => this.#containers.environment?.assetContainer);
1172
+ /** @returns {?AssetContainer} Visible, settled model container, or null while unavailable. */
1173
+ const getModel = () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
1174
+ ? this.#containers.model.assetContainer : null;
1175
+ buttons = new XRModeButtons(this.#canvas.parentElement, experience, modes,
1176
+ () => this.#viewCubeConfig.getCulture?.() ?? I18n.getLocale());
1177
+ if (hasAR) {
1178
+ placement = new BabylonJSARPlacement(scene, experience, getModel,
1179
+ () => this.#containers.environment?.assetContainer,
1180
+ () => buttons?.requestedMode === "immersive-ar");
1181
+ }
1182
+ if (hasVR) {
1183
+ stage = new BabylonJSVRStage(scene, experience, getModel, () => buttons?.requestedMode === "immersive-vr");
1184
+ }
1159
1185
 
1160
1186
  experience.baseExperience.onStateChangedObservable.add(() => {
1161
1187
  // Babylon schedules XR frames only while engine render callbacks are registered.
@@ -1170,6 +1196,8 @@ export default class BabylonJSController {
1170
1196
  this.#addStylesToARButton();
1171
1197
  this.#XRExperience = experience;
1172
1198
  this.#arPlacement = placement;
1199
+ this.#vrStage = stage;
1200
+ this.#xrButtons = buttons;
1173
1201
  return true;
1174
1202
  } catch (error) {
1175
1203
  console.warn("PrefViewer: failed to create WebXR experience", error);
@@ -1182,6 +1210,8 @@ export default class BabylonJSController {
1182
1210
  console.warn("PrefViewer: failed to dispose obsolete WebXR experience", error);
1183
1211
  } finally {
1184
1212
  placement?.dispose();
1213
+ stage?.dispose();
1214
+ buttons?.dispose();
1185
1215
  }
1186
1216
  }
1187
1217
  }
@@ -2281,9 +2311,15 @@ export default class BabylonJSController {
2281
2311
 
2282
2312
  const xrExperience = this.#XRExperience;
2283
2313
  const placement = this.#arPlacement;
2314
+ const stage = this.#vrStage;
2315
+ const buttons = this.#xrButtons;
2284
2316
  if (!xrExperience) {
2285
2317
  placement?.dispose();
2318
+ stage?.dispose();
2319
+ buttons?.dispose();
2286
2320
  this.#arPlacement = null;
2321
+ this.#vrStage = null;
2322
+ this.#xrButtons = null;
2287
2323
  return;
2288
2324
  }
2289
2325
 
@@ -2296,6 +2332,8 @@ export default class BabylonJSController {
2296
2332
  console.warn("PrefViewer: Error exiting XR experience:", error);
2297
2333
  } finally {
2298
2334
  placement?.dispose();
2335
+ stage?.dispose();
2336
+ buttons?.dispose();
2299
2337
  try {
2300
2338
  xrExperience.dispose();
2301
2339
  } catch (error) {
@@ -2304,6 +2342,8 @@ export default class BabylonJSController {
2304
2342
  if (this.#XRExperience === xrExperience) {
2305
2343
  this.#XRExperience = null;
2306
2344
  this.#arPlacement = null;
2345
+ this.#vrStage = null;
2346
+ this.#xrButtons = null;
2307
2347
  }
2308
2348
  this.#disablingPromises.xr = null;
2309
2349
  }
@@ -3976,6 +4016,7 @@ export default class BabylonJSController {
3976
4016
  */
3977
4017
  refreshViewCube() {
3978
4018
  this.#quickLookAction?.refreshCulture();
4019
+ this.#xrButtons?.refreshCulture();
3979
4020
  this.#viewCube?.resize();
3980
4021
  }
3981
4022
 
@@ -4148,6 +4189,7 @@ export default class BabylonJSController {
4148
4189
 
4149
4190
  return {
4150
4191
  ar: this.#arPlacement?.getDebugInfo() ?? null,
4192
+ vr: this.#vrStage?.getDebugInfo() ?? null,
4151
4193
  canvas: {
4152
4194
  clientWidth: this.#canvas?.clientWidth ?? null,
4153
4195
  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",
@@ -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
+ }