@preference-sl/pref-viewer 2.18.0 → 3.0.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 +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 +542 -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 +22 -4
- package/src/gltf-resolver.js +83 -13
- 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 +291 -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
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
|
|
2
|
+
import { TransformNode } from "@babylonjs/core/Meshes/transformNode.js";
|
|
3
|
+
import { Ray } from "@babylonjs/core/Culling/ray.js";
|
|
4
|
+
import { WebXRState } from "@babylonjs/core/XR/webXRTypes.js";
|
|
5
|
+
import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
|
|
6
|
+
import BabylonJSARFloorBounds from "./babylonjs-ar-floor-bounds.js";
|
|
7
|
+
import BabylonJSARGlass from "./babylonjs-ar-glass.js";
|
|
8
|
+
import BabylonJSARPlacementFeedback from "./babylonjs-ar-placement-feedback.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owns metric model placement, gesture feedback, two-finger yaw and one-finger translation inside a Babylon native AR session.
|
|
12
|
+
* After native floor detection, touch rays translate on that retained plane without fresh hit subscriptions.
|
|
13
|
+
* Model drags preserve their initial offset; short off-model taps reposition the bottom anchor.
|
|
14
|
+
* Uses plane hit tests and a unit-scale parent to retain authored glTF dimensions, with Inner (+Z)
|
|
15
|
+
* initially snapped to the nearest detected-floor axis orientation from its phone-facing pose. Source units/orientation remain the asset producer's responsibility.
|
|
16
|
+
* Retains detected floor height within the same reference space while waiting for a usable phone heading.
|
|
17
|
+
* Prefers a full-footprint fit up to three metres on an observed contour; otherwise provisionally uses three metres.
|
|
18
|
+
* The initial floor position is chosen once; later contour growth cannot move a placed model.
|
|
19
|
+
* Without a qualifying floor, previews the model centre two metres along the first valid view ray at authored scale.
|
|
20
|
+
* Retains that world pose without following the camera; native tracking drift can still affect apparent size/position.
|
|
21
|
+
* The preview cannot be dragged or used for physical measurement; floor evidence enables spatial placement.
|
|
22
|
+
* Records bounded session and drag diagnostics, retaining gesture rejection reasons after release.
|
|
23
|
+
* Temporarily hides the environment, reparents model roots and adapts physical model glass to alpha blending.
|
|
24
|
+
* Exit/cancellation restores authored transforms and exact original material values; shared textures remain owned by the model.
|
|
25
|
+
* Contains native hit-source and world-scale cleanup errors so Babylon can restore its camera and render loop.
|
|
26
|
+
* Call dispose() before releasing the owning scene to remove observers and native input resources.
|
|
27
|
+
*/
|
|
28
|
+
export default class BabylonJSARPlacement {
|
|
29
|
+
#scene;
|
|
30
|
+
#manager;
|
|
31
|
+
#getModel;
|
|
32
|
+
#getEnvironment;
|
|
33
|
+
#observers = [];
|
|
34
|
+
#session = null;
|
|
35
|
+
#generation = 0;
|
|
36
|
+
#active = false;
|
|
37
|
+
#disposed = false;
|
|
38
|
+
#state = "inactive";
|
|
39
|
+
#floorSource = null;
|
|
40
|
+
#floorEvidence = null;
|
|
41
|
+
#previousWorldScale = null;
|
|
42
|
+
#asset = null;
|
|
43
|
+
#root = null;
|
|
44
|
+
#roots = [];
|
|
45
|
+
#environment = new Map();
|
|
46
|
+
#glass = new BabylonJSARGlass();
|
|
47
|
+
#pivot = null;
|
|
48
|
+
#centre = null;
|
|
49
|
+
#footprintCorners = [];
|
|
50
|
+
#previewSpace = null;
|
|
51
|
+
#target = null;
|
|
52
|
+
#rotation = null;
|
|
53
|
+
#touches = new Set();
|
|
54
|
+
#blockedGesture = false;
|
|
55
|
+
#drag = null;
|
|
56
|
+
#turn = null;
|
|
57
|
+
#feedback;
|
|
58
|
+
#diagnostics = this.#createDiagnostics();
|
|
59
|
+
static #INITIAL_DISTANCE = 3;
|
|
60
|
+
static #TAP_ANGLE_COS = Math.cos(Math.PI / 180);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates bounded placement, contour/pose-only floor-axis evidence and drag counters, retaining the latest gesture outcome until the next session.
|
|
64
|
+
* @private
|
|
65
|
+
* @returns {object} Fresh mutable state; no frame history or native resources are retained.
|
|
66
|
+
*/
|
|
67
|
+
#createDiagnostics() {
|
|
68
|
+
return { frames: 0, trackedFrames: 0, phase: "inactive", hitTestStatus: "not-requested",
|
|
69
|
+
lastHitCount: 0, maxHitCount: 0, lastSurface: null, lastError: null, ended: false,
|
|
70
|
+
floorAlignmentSource: "not-placed", floorAlignmentStatus: "not-placed", floorAlignmentYaw: null,
|
|
71
|
+
floorAlignmentHeightDifference: null, initialYaw: null,
|
|
72
|
+
placementDistance: null, placementStatus: "not-placed", detectedPlaneCount: 0, floorBoundsStatus: "not-queried",
|
|
73
|
+
drag: { selectStarts: 0, selectEnds: 0, lastInputMode: null, status: "no-input",
|
|
74
|
+
hitTestStatus: "not-requested", frames: 0, lastHitCount: 0, moves: 0,
|
|
75
|
+
pickedMesh: null, lastError: null } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Subscribes to XR lifecycle and owns lazy gesture feedback without starting a session or changing the scene yet.
|
|
80
|
+
* @param {import("@babylonjs/core").Scene} scene - Scene containing the authored assets.
|
|
81
|
+
* @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Native XR lifecycle owner.
|
|
82
|
+
* @param {function(): ?import("@babylonjs/core").AssetContainer} getModel - Current usable model, or null while unavailable.
|
|
83
|
+
* @param {function(): ?import("@babylonjs/core").AssetContainer} [getEnvironment] - Current environment; defaults to returning null.
|
|
84
|
+
*/
|
|
85
|
+
constructor(scene, experience, getModel, getEnvironment = () => null) {
|
|
86
|
+
this.#scene = scene;
|
|
87
|
+
this.#feedback = new BabylonJSARPlacementFeedback(scene);
|
|
88
|
+
this.#manager = experience.baseExperience.sessionManager;
|
|
89
|
+
this.#getModel = getModel;
|
|
90
|
+
this.#getEnvironment = getEnvironment;
|
|
91
|
+
this.#observe(experience.baseExperience.onStateChangedObservable, state => {
|
|
92
|
+
if (state === WebXRState.ENTERING_XR) {
|
|
93
|
+
this.#begin();
|
|
94
|
+
}
|
|
95
|
+
if (state === WebXRState.NOT_IN_XR) {
|
|
96
|
+
this.#end();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
this.#observe(this.#manager.onXRSessionInit, session => { void this.#start(session); });
|
|
100
|
+
this.#observe(this.#manager.onXRSessionEnded, () => this.#end());
|
|
101
|
+
this.#observe(this.#manager.onXRFrameObservable, frame => this.#frame(frame));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Registers a Babylon observer and retains its handle for disposal.
|
|
106
|
+
* @private
|
|
107
|
+
* @param {import("@babylonjs/core").Observable<*>} observable - Lifecycle or frame event stream.
|
|
108
|
+
* @param {function(*): void} callback - Handler invoked by that stream.
|
|
109
|
+
* @returns {void}
|
|
110
|
+
*/
|
|
111
|
+
#observe(observable, callback) {
|
|
112
|
+
this.#observers.push([observable, observable.add(callback)]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resets prior placement and hides content until a valid camera pose permits a metric preview or floor placement.
|
|
117
|
+
* Starts fresh diagnostic counters; the previous attempt remains inspectable only until this reset.
|
|
118
|
+
* @private
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
#begin() {
|
|
122
|
+
this.#end();
|
|
123
|
+
this.#diagnostics = this.#createDiagnostics();
|
|
124
|
+
this.#diagnostics.phase = "waiting-session";
|
|
125
|
+
this.#active = true;
|
|
126
|
+
this.#state = "detecting-floor";
|
|
127
|
+
this.#syncModel();
|
|
128
|
+
this.#hideEnvironment();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sets metre-scale XR, binds native input and requests a viewer-space plane hit-test source.
|
|
133
|
+
* Late sources are cancelled; rejected requests retain a stationary initial preview without floor dragging.
|
|
134
|
+
* Records request stages and native error name/message for remote troubleshooting.
|
|
135
|
+
* Source cancellation tolerates resources invalidated by session termination, including late results.
|
|
136
|
+
* Sets the manager's scale only when a change is needed, avoiding redundant native camera updates.
|
|
137
|
+
* @private
|
|
138
|
+
* @param {XRSession} session - Newly initialized native immersive-ar session.
|
|
139
|
+
* @returns {Promise<void>} Resolves after setup, cancellation or a logged native request failure.
|
|
140
|
+
*/
|
|
141
|
+
async #start(session) {
|
|
142
|
+
if (this.#disposed) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!this.#active) {
|
|
146
|
+
this.#begin();
|
|
147
|
+
}
|
|
148
|
+
const generation = ++this.#generation;
|
|
149
|
+
this.#session = session;
|
|
150
|
+
this.#previousWorldScale = this.#manager.worldScalingFactor;
|
|
151
|
+
if (this.#manager.worldScalingFactor !== 1) {
|
|
152
|
+
this.#manager.worldScalingFactor = 1;
|
|
153
|
+
}
|
|
154
|
+
session.addEventListener("selectstart", this.#selectStart);
|
|
155
|
+
session.addEventListener("selectend", this.#selectEnd);
|
|
156
|
+
session.addEventListener("inputsourceschange", this.#inputsChanged);
|
|
157
|
+
try {
|
|
158
|
+
this.#diagnostics.hitTestStatus = "requesting-viewer-space";
|
|
159
|
+
const space = await session.requestReferenceSpace("viewer");
|
|
160
|
+
if (this.#session !== session || generation !== this.#generation) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.#diagnostics.hitTestStatus = "requesting-hit-test";
|
|
164
|
+
const source = await session.requestHitTestSource({ space, entityTypes: ["plane"] });
|
|
165
|
+
if (this.#session !== session || generation !== this.#generation) {
|
|
166
|
+
this.#cancelHitTestSource(source);
|
|
167
|
+
} else {
|
|
168
|
+
this.#floorSource = source;
|
|
169
|
+
this.#diagnostics.hitTestStatus = "ready";
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (this.#session === session && generation === this.#generation) {
|
|
173
|
+
if (!this.#target) {
|
|
174
|
+
this.#state = "unavailable";
|
|
175
|
+
}
|
|
176
|
+
this.#diagnostics.hitTestStatus = "error";
|
|
177
|
+
this.#diagnostics.lastError = {
|
|
178
|
+
name: error?.name ?? "Error",
|
|
179
|
+
message: error?.message ?? String(error)
|
|
180
|
+
};
|
|
181
|
+
console.warn("PrefViewer: AR floor hit testing unavailable", error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Converts a native right-handed pose into the scene's handedness without scaling it.
|
|
188
|
+
* @private
|
|
189
|
+
* @param {XRPose} pose - Native pose relative to the manager's reference space.
|
|
190
|
+
* @returns {Matrix} A new scene-space transformation matrix.
|
|
191
|
+
*/
|
|
192
|
+
#matrix(pose) {
|
|
193
|
+
const matrix = Matrix.FromArray(pose.transform.matrix);
|
|
194
|
+
if (!this.#scene.useRightHandedSystem) {
|
|
195
|
+
matrix.toggleModelMatrixHandInPlace();
|
|
196
|
+
}
|
|
197
|
+
return matrix;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Finds a finite, upward horizontal hit below the phone; hit tests cannot identify semantic floor.
|
|
202
|
+
* Records the latest checked surface and the exact rejection reason without changing the acceptance filter.
|
|
203
|
+
* @private
|
|
204
|
+
* @param {XRHitTestResult[]} results - Native plane hits for the current frame.
|
|
205
|
+
* @param {number} viewerY - Tracked phone height in scene-space metres.
|
|
206
|
+
* @returns {?Vector3} First acceptable hit position, or null when no plane qualifies.
|
|
207
|
+
*/
|
|
208
|
+
#floorPoint(results, viewerY) {
|
|
209
|
+
for (const result of results) {
|
|
210
|
+
const pose = result.getPose(this.#manager.referenceSpace);
|
|
211
|
+
if (!pose) {
|
|
212
|
+
this.#diagnostics.lastSurface = {
|
|
213
|
+
reason: "pose-unavailable",
|
|
214
|
+
position: null,
|
|
215
|
+
normal: null,
|
|
216
|
+
viewerY
|
|
217
|
+
};
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const matrix = this.#matrix(pose);
|
|
221
|
+
const point = matrix.getTranslation();
|
|
222
|
+
const up = Vector3.TransformNormal(Vector3.Up(), matrix).normalize();
|
|
223
|
+
// Reject walls, ceilings and invalid tracking data. The runtime supplies the plane;
|
|
224
|
+
// it cannot identify semantic "floor" on every Android device.
|
|
225
|
+
const reason = ![point.x, point.y, point.z].every(Number.isFinite) ? "invalid-position" : !(up.y >= 0.95) ? "not-horizontal" : !(point.y < viewerY) ? "not-below-camera" : "accepted";
|
|
226
|
+
this.#diagnostics.lastSurface = {
|
|
227
|
+
reason,
|
|
228
|
+
position: {
|
|
229
|
+
x: point.x,
|
|
230
|
+
y: point.y,
|
|
231
|
+
z: point.z
|
|
232
|
+
},
|
|
233
|
+
normal: {
|
|
234
|
+
x: up.x,
|
|
235
|
+
y: up.y,
|
|
236
|
+
z: up.z
|
|
237
|
+
},
|
|
238
|
+
viewerY
|
|
239
|
+
};
|
|
240
|
+
if (reason === "accepted") {
|
|
241
|
+
return point;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Attaches the current model to an owned unit-scale pivot while preserving authored transforms.
|
|
249
|
+
* Retains centre/bottom pivots and the original world bounding corners for preview/floor footprint alignment.
|
|
250
|
+
* Replacement retains the current anchor; missing content stays hidden until usable again.
|
|
251
|
+
* Cancels input and feedback when content disappears or is replaced.
|
|
252
|
+
* Syncs reversible glass overrides on referenced mesh materials, including swaps during an active session.
|
|
253
|
+
* @private
|
|
254
|
+
* @returns {void}
|
|
255
|
+
*/
|
|
256
|
+
#syncModel() {
|
|
257
|
+
const asset = this.#getModel();
|
|
258
|
+
if (!asset) {
|
|
259
|
+
this.#cancelDrag();
|
|
260
|
+
this.#glass.restore();
|
|
261
|
+
this.#root?.setEnabled(false);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (asset !== this.#asset) {
|
|
265
|
+
this.#releaseModel();
|
|
266
|
+
const bounds = BabylonJSCameraFraming.getModelBounds(asset);
|
|
267
|
+
if (!bounds) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.#asset = asset;
|
|
271
|
+
this.#pivot = new Vector3(bounds.center.x, Math.min(...bounds.corners.map(v => v.y)), bounds.center.z);
|
|
272
|
+
this.#centre = bounds.center.clone();
|
|
273
|
+
this.#footprintCorners = bounds.corners.map(corner => corner.clone());
|
|
274
|
+
this.#root = new TransformNode("PrefViewerARPlacement", this.#scene);
|
|
275
|
+
const nodes = new Set([...(asset.meshes ?? []), ...(asset.transformNodes ?? [])]);
|
|
276
|
+
for (const node of nodes) {
|
|
277
|
+
if (node.parent && nodes.has(node.parent)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const snapshot = {
|
|
281
|
+
node,
|
|
282
|
+
parent: node.parent,
|
|
283
|
+
position: node.position.clone(),
|
|
284
|
+
rotation: node.rotation.clone(),
|
|
285
|
+
quaternion: node.rotationQuaternion?.clone(),
|
|
286
|
+
scaling: node.scaling.clone()
|
|
287
|
+
};
|
|
288
|
+
this.#roots.push(snapshot);
|
|
289
|
+
if (node.parent) {
|
|
290
|
+
node.setParent(this.#root, true);
|
|
291
|
+
} else {
|
|
292
|
+
node.parent = this.#root;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.#glass.sync(asset);
|
|
297
|
+
this.#applyPlacement();
|
|
298
|
+
this.#root.setEnabled(this.#target !== null);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hides current environment roots, remembering their original local enabled states for exit.
|
|
303
|
+
* @private
|
|
304
|
+
* @returns {void}
|
|
305
|
+
*/
|
|
306
|
+
#hideEnvironment() {
|
|
307
|
+
const asset = this.#getEnvironment();
|
|
308
|
+
const nodes = new Set([...(asset?.meshes ?? []), ...(asset?.transformNodes ?? [])]);
|
|
309
|
+
for (const node of nodes) {
|
|
310
|
+
if (node.parent && nodes.has(node.parent)) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!this.#environment.has(node)) {
|
|
314
|
+
this.#environment.set(node, node.isEnabled(false));
|
|
315
|
+
}
|
|
316
|
+
node.setEnabled(false);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Applies yaw and translation so the model centre meets the preview anchor, or its bottom meets a real floor anchor.
|
|
322
|
+
* Leaves all model and unit-conversion scales unchanged; does nothing before placement exists.
|
|
323
|
+
* @private
|
|
324
|
+
* @returns {void}
|
|
325
|
+
*/
|
|
326
|
+
#applyPlacement() {
|
|
327
|
+
if (!this.#root || !this.#target) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.#root.rotationQuaternion = this.#rotation.clone();
|
|
331
|
+
// Unit-scale parent: only yaw and translation, including source offsets and centre/bottom alignment.
|
|
332
|
+
const rotation = Matrix.Identity();
|
|
333
|
+
this.#rotation.toRotationMatrix(rotation);
|
|
334
|
+
const rotatedPivot = Vector3.TransformCoordinates(this.#state === "preview" ? this.#centre : this.#pivot, rotation);
|
|
335
|
+
this.#root.position.copyFrom(this.#target.subtract(rotatedPivot));
|
|
336
|
+
this.#root.computeWorldMatrix(true);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Places an upright, unit-scale preview once, centred two metres along the first valid camera ray.
|
|
341
|
+
* Retains its world position and yaw through camera movement/tracking loss within the same reference space.
|
|
342
|
+
* Does not compensate native drift, fabricate floor height or enable dragging; physical anchoring remains unverified.
|
|
343
|
+
* A missing model defers preview creation. The caller must supply a finite camera transform.
|
|
344
|
+
* @private
|
|
345
|
+
* @param {Matrix} view - Camera pose in scene coordinates, including pitch and roll.
|
|
346
|
+
* @returns {void}
|
|
347
|
+
*/
|
|
348
|
+
#preview(view) {
|
|
349
|
+
if (this.#target || !this.#root) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const forward = Vector3.TransformNormal(new Vector3(0, 0, this.#scene.useRightHandedSystem ? -1 : 1), view).normalize();
|
|
353
|
+
const heading = new Vector3(forward.x, 0, forward.z);
|
|
354
|
+
if (heading.lengthSquared() <= 1e-8) {
|
|
355
|
+
const right = Vector3.TransformNormal(Vector3.Right(), view);
|
|
356
|
+
heading.set(-right.z, 0, right.x);
|
|
357
|
+
if (this.#scene.useRightHandedSystem) {
|
|
358
|
+
heading.negateInPlace();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.#target = view.getTranslation().add(forward.scale(2));
|
|
362
|
+
this.#rotation = Quaternion.RotationYawPitchRoll(Math.atan2(-heading.x, -heading.z), 0, 0);
|
|
363
|
+
this.#previewSpace = this.#manager.referenceSpace;
|
|
364
|
+
this.#state = "preview";
|
|
365
|
+
this.#applyPlacement();
|
|
366
|
+
this.#root.setEnabled(true);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reads placement hits, retiring a failing native source so the camera preview can still render.
|
|
371
|
+
* Records the actual error once; does not synthesize a surface or retry a rejected source.
|
|
372
|
+
* An already-invalid source cannot interrupt retirement or the subsequent preview frame.
|
|
373
|
+
* @private
|
|
374
|
+
* @param {XRFrame} frame - Current active native frame.
|
|
375
|
+
* @returns {XRHitTestResult[]} Native plane results, or an empty list after a native read error.
|
|
376
|
+
*/
|
|
377
|
+
#readFloorHits(frame) {
|
|
378
|
+
try {
|
|
379
|
+
return frame.getHitTestResults(this.#floorSource);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
this.#cancelHitTestSource(this.#floorSource);
|
|
382
|
+
this.#floorSource = null;
|
|
383
|
+
this.#diagnostics.hitTestStatus = "error";
|
|
384
|
+
this.#diagnostics.lastError = { name: error?.name ?? "Error", message: error?.message ?? String(error) };
|
|
385
|
+
console.warn("PrefViewer: AR floor hit results unavailable", error);
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Places once up to three horizontal metres ahead where the model fits an observed floor contour.
|
|
392
|
+
* Missing/too-small contours use a provisional three-metre floor placement; later contours do not reposition it.
|
|
393
|
+
* Creates a stationary preview while floor evidence/setup is absent or rejected, without following the camera.
|
|
394
|
+
* Missing tracking retains the last pose; interaction remains on the originally detected floor height.
|
|
395
|
+
* Remembers actual floor evidence while the phone points vertically; a changed reference space discards it.
|
|
396
|
+
* Counts delivered/tracked frames and native placement hits, recording the current wait stage.
|
|
397
|
+
* Retires the floor source without allowing native cancellation errors to interrupt placement/rendering.
|
|
398
|
+
* Expires confirmation feedback and processes two-finger yaw; hides feedback on tracking loss.
|
|
399
|
+
* Updates the selected finger on the known floor plane, even without fresh hits or when other surfaces occlude it.
|
|
400
|
+
* @private
|
|
401
|
+
* @param {XRFrame} frame - Active native XR frame, valid only during its callback.
|
|
402
|
+
* @returns {void}
|
|
403
|
+
*/
|
|
404
|
+
#frame(frame) {
|
|
405
|
+
if (!this.#active) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.#feedback.update();
|
|
409
|
+
this.#diagnostics.frames++;
|
|
410
|
+
this.#diagnostics.phase = "waiting-session";
|
|
411
|
+
if (!this.#session) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.#diagnostics.phase = "waiting-reference-space";
|
|
415
|
+
if (!this.#manager.referenceSpace) {
|
|
416
|
+
this.#cancelDrag();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (this.#state === "placed" && this.#floorEvidence?.space !== this.#manager.referenceSpace) {
|
|
420
|
+
this.#cancelDrag();
|
|
421
|
+
}
|
|
422
|
+
if (this.#state === "preview" && this.#previewSpace !== this.#manager.referenceSpace) {
|
|
423
|
+
this.#target = this.#rotation = null;
|
|
424
|
+
this.#state = "detecting-floor";
|
|
425
|
+
}
|
|
426
|
+
this.#diagnostics.phase = "syncing-content";
|
|
427
|
+
this.#syncModel();
|
|
428
|
+
this.#hideEnvironment();
|
|
429
|
+
this.#diagnostics.phase = "waiting-tracking";
|
|
430
|
+
const viewer = frame.getViewerPose(this.#manager.referenceSpace);
|
|
431
|
+
if (!viewer) {
|
|
432
|
+
this.#feedback.hide();
|
|
433
|
+
if (this.#turn) {
|
|
434
|
+
this.#cancelDrag();
|
|
435
|
+
}
|
|
436
|
+
if (this.#drag) {
|
|
437
|
+
this.#drag.tracked = false;
|
|
438
|
+
if (this.#drag.mode === "tap") {
|
|
439
|
+
this.#drag.moved = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
} // Tracking loss must not cause a new placement or scale change.
|
|
444
|
+
const view = this.#matrix(viewer);
|
|
445
|
+
if (!Array.from(view.m).every(Number.isFinite)) {
|
|
446
|
+
this.#feedback.hide();
|
|
447
|
+
if (this.#turn) {
|
|
448
|
+
this.#cancelDrag();
|
|
449
|
+
}
|
|
450
|
+
if (this.#drag) {
|
|
451
|
+
this.#drag.tracked = false;
|
|
452
|
+
if (this.#drag.mode === "tap") {
|
|
453
|
+
this.#drag.moved = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.#diagnostics.trackedFrames++;
|
|
459
|
+
this.#diagnostics.phase = this.#state === "placed" ? "placed" : "waiting-hit-test";
|
|
460
|
+
const camera = view.getTranslation();
|
|
461
|
+
if (this.#state !== "placed" && this.#floorSource) {
|
|
462
|
+
if (this.#floorEvidence?.space !== this.#manager.referenceSpace) {
|
|
463
|
+
this.#floorEvidence = null;
|
|
464
|
+
}
|
|
465
|
+
this.#diagnostics.phase = "reading-hit-results";
|
|
466
|
+
const results = this.#readFloorHits(frame);
|
|
467
|
+
this.#diagnostics.lastHitCount = results.length;
|
|
468
|
+
this.#diagnostics.maxHitCount = Math.max(this.#diagnostics.maxHitCount, results.length);
|
|
469
|
+
this.#diagnostics.phase = "checking-surfaces";
|
|
470
|
+
const point = this.#floorPoint(results, camera.y);
|
|
471
|
+
if (point) {
|
|
472
|
+
this.#floorEvidence = {
|
|
473
|
+
height: point.y,
|
|
474
|
+
point: point.clone(),
|
|
475
|
+
space: this.#manager.referenceSpace
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
this.#diagnostics.phase = !this.#floorEvidence ? "waiting-floor" : !this.#root ? "waiting-model" : "waiting-heading";
|
|
479
|
+
const forward = Vector3.TransformNormal(new Vector3(0, 0, this.#scene.useRightHandedSystem ? -1 : 1), view);
|
|
480
|
+
forward.y = 0;
|
|
481
|
+
if (this.#root && this.#floorEvidence && this.#floorEvidence.height < camera.y && forward.lengthSquared() > 1e-8) {
|
|
482
|
+
forward.normalize();
|
|
483
|
+
this.#placeOnFloor(frame, camera, forward);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
this.#preview(view);
|
|
487
|
+
if (this.#turn) {
|
|
488
|
+
this.#updateTurn(frame);
|
|
489
|
+
} else {
|
|
490
|
+
this.#updateDrag(frame);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Chooses initial placement once, snapping phone-facing yaw by the minimum quarter-turn to matched floor axes.
|
|
496
|
+
* Native plane yaw is selected independently of footprint fitting; reference axes are used only without a usable pose.
|
|
497
|
+
* Matching contours take priority over the nearest horizontal pose below the camera; height/distance are not changed.
|
|
498
|
+
* Fits the original authored footprint after snapping, and freezes the same axes for feedback.
|
|
499
|
+
* Fits the entire horizontal bounding footprint on the camera's forward segment, preferring three metres.
|
|
500
|
+
* With no usable contour or fit, preserves visibility at three metres without claiming a bounded fit.
|
|
501
|
+
* @private
|
|
502
|
+
* @param {XRFrame} frame - Active frame supplying optional detected planes in the current reference space.
|
|
503
|
+
* @param {Vector3} camera - Finite tracked camera position in scene-space metres.
|
|
504
|
+
* @param {Vector3} forward - Normalized horizontal camera direction.
|
|
505
|
+
* @returns {void} Sets detached fit diagnostics, reveals the model and retires only the initial native hit source.
|
|
506
|
+
*/
|
|
507
|
+
#placeOnFloor(frame, camera, forward) {
|
|
508
|
+
const bounds = BabylonJSARFloorBounds.getFloorPolygons(frame, this.#manager.referenceSpace,
|
|
509
|
+
this.#floorEvidence.point, this.#scene.useRightHandedSystem, camera.y);
|
|
510
|
+
const floorYaw = bounds.alignment?.yaw ?? 0;
|
|
511
|
+
const facing = Math.atan2(-forward.x, -forward.z);
|
|
512
|
+
const difference = Math.atan2(Math.sin(facing - floorYaw), Math.cos(facing - floorYaw));
|
|
513
|
+
const yaw = floorYaw + Math.round(difference / (Math.PI / 2)) * (Math.PI / 2);
|
|
514
|
+
this.#rotation = Quaternion.RotationYawPitchRoll(yaw, 0, 0);
|
|
515
|
+
this.#feedback.setFloorYaw(floorYaw);
|
|
516
|
+
this.#diagnostics.floorAlignmentSource = bounds.alignment ? "detected-plane" : "reference-space";
|
|
517
|
+
this.#diagnostics.floorAlignmentStatus = bounds.alignment?.status ?? "reference-space";
|
|
518
|
+
this.#diagnostics.floorAlignmentHeightDifference = bounds.alignment?.heightDifference ?? null;
|
|
519
|
+
this.#diagnostics.floorAlignmentYaw = floorYaw;
|
|
520
|
+
this.#diagnostics.initialYaw = yaw;
|
|
521
|
+
const rotation = Matrix.Identity();
|
|
522
|
+
this.#rotation.toRotationMatrix(rotation);
|
|
523
|
+
const footprint = this.#footprintCorners.map(corner => Vector3.TransformCoordinates(corner.subtract(this.#pivot), rotation));
|
|
524
|
+
let fittedDistance = null;
|
|
525
|
+
for (const polygon of bounds.polygons) {
|
|
526
|
+
const candidate = BabylonJSARFloorBounds.getPlacementDistance(polygon, footprint, camera, forward, BabylonJSARPlacement.#INITIAL_DISTANCE);
|
|
527
|
+
if (candidate !== null && (fittedDistance === null || candidate > fittedDistance)) {
|
|
528
|
+
fittedDistance = candidate;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const distance = fittedDistance ?? BabylonJSARPlacement.#INITIAL_DISTANCE;
|
|
532
|
+
this.#diagnostics.placementDistance = distance;
|
|
533
|
+
this.#diagnostics.placementStatus = fittedDistance !== null ? "bounded-fit"
|
|
534
|
+
: bounds.polygons.length ? "provisional-no-fit" : "provisional-no-contour";
|
|
535
|
+
this.#diagnostics.floorBoundsStatus = bounds.status;
|
|
536
|
+
this.#diagnostics.detectedPlaneCount = bounds.planeCount;
|
|
537
|
+
this.#target = camera.add(forward.scale(distance));
|
|
538
|
+
this.#target.y = this.#floorEvidence.height;
|
|
539
|
+
this.#state = "placed";
|
|
540
|
+
this.#diagnostics.phase = "placed";
|
|
541
|
+
this.#cancelHitTestSource(this.#floorSource);
|
|
542
|
+
this.#floorSource = null;
|
|
543
|
+
this.#applyPlacement();
|
|
544
|
+
this.#root.setEnabled(true);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Builds a scene-space ray from the selected screen input in the current XR reference space.
|
|
549
|
+
* @private
|
|
550
|
+
* @param {XRFrame} frame - Active native animation or selection frame.
|
|
551
|
+
* @param {XRInputSource} input - Finger whose pose is queried; other fingers are ignored.
|
|
552
|
+
* @returns {?Ray} Finite normalized ray, or null when input tracking is unavailable.
|
|
553
|
+
*/
|
|
554
|
+
#inputRay(frame, input) {
|
|
555
|
+
const pose = frame.getPose(input.targetRaySpace, this.#manager.referenceSpace);
|
|
556
|
+
if (!pose) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
const matrix = this.#matrix(pose);
|
|
560
|
+
if (!Array.from(matrix.m).every(Number.isFinite)) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const direction = Vector3.TransformNormal(new Vector3(0, 0, this.#scene.useRightHandedSystem ? -1 : 1), matrix).normalize();
|
|
564
|
+
return new Ray(matrix.getTranslation(), direction);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Intersects a forward touch ray with the horizontal plane of the previously detected floor.
|
|
569
|
+
* Never invents floor height, follows another level, or projects horizon/behind-camera rays.
|
|
570
|
+
* @private
|
|
571
|
+
* @param {Ray} ray - Finite scene-space touch ray.
|
|
572
|
+
* @returns {?Vector3} Floor point in metres, or null for parallel, backward or invalid intersections.
|
|
573
|
+
*/
|
|
574
|
+
#floorIntersection(ray) {
|
|
575
|
+
if (!this.#target || this.#floorEvidence?.space !== this.#manager.referenceSpace
|
|
576
|
+
|| Math.abs(ray.direction.y) <= 1e-6) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const distance = (this.#target.y - ray.origin.y) / ray.direction.y;
|
|
580
|
+
if (!(distance >= 0) || !Number.isFinite(distance)) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const point = ray.origin.add(ray.direction.scale(distance));
|
|
584
|
+
point.y = this.#target.y;
|
|
585
|
+
return [point.x, point.y, point.z].every(Number.isFinite) ? point : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Applies horizontal translation only, preserving authored scale, placed height and yaw.
|
|
590
|
+
* @private
|
|
591
|
+
* @param {Vector3} point - Desired bottom-anchor coordinates on the retained plane.
|
|
592
|
+
* @param {string} status - Diagnostic outcome for drag or tap placement.
|
|
593
|
+
* @returns {void} Updates bounded movement counters and the owned placement pivot.
|
|
594
|
+
*/
|
|
595
|
+
#moveOnFloor(point, status) {
|
|
596
|
+
if (this.#target.x !== point.x || this.#target.z !== point.z) {
|
|
597
|
+
this.#diagnostics.drag.moves++;
|
|
598
|
+
}
|
|
599
|
+
this.#target.x = point.x;
|
|
600
|
+
this.#target.z = point.z;
|
|
601
|
+
this.#diagnostics.drag.status = status;
|
|
602
|
+
this.#applyPlacement();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Samples the owned finger on the detected floor without requesting additional native hit sources.
|
|
607
|
+
* Model drags retain their grab offset. Off-model taps are candidates until release; one degree of
|
|
608
|
+
* ray movement cancels tap placement, including a movement that later returns to its starting point.
|
|
609
|
+
* Shows a candidate reticle or directional drag arrows; invalid input hides them.
|
|
610
|
+
* Missing/invalid poses retain position and prevent stale tap commits; reference-space changes cancel ownership.
|
|
611
|
+
* @private
|
|
612
|
+
* @param {XRFrame} frame - Active frame supplying the selected finger pose.
|
|
613
|
+
* @returns {void} Contains pose failures so later XR observers remain usable.
|
|
614
|
+
*/
|
|
615
|
+
#updateDrag(frame) {
|
|
616
|
+
const drag = this.#drag;
|
|
617
|
+
if (!drag) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (drag.space !== this.#manager.referenceSpace) {
|
|
621
|
+
this.#diagnostics.drag.status = "reference-space-changed";
|
|
622
|
+
this.#cancelDrag();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const diagnostics = this.#diagnostics.drag;
|
|
626
|
+
diagnostics.frames++;
|
|
627
|
+
try {
|
|
628
|
+
const ray = this.#inputRay(frame, drag.input);
|
|
629
|
+
drag.tracked = !!ray;
|
|
630
|
+
if (!ray) {
|
|
631
|
+
this.#feedback.hide();
|
|
632
|
+
if (drag.mode === "tap") {
|
|
633
|
+
drag.moved = true;
|
|
634
|
+
}
|
|
635
|
+
diagnostics.status = "missing-input-pose";
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (Vector3.Dot(drag.direction, ray.direction) < BabylonJSARPlacement.#TAP_ANGLE_COS) {
|
|
639
|
+
drag.moved = true;
|
|
640
|
+
}
|
|
641
|
+
const point = this.#floorIntersection(ray);
|
|
642
|
+
drag.point = point;
|
|
643
|
+
if (!point) {
|
|
644
|
+
this.#feedback.hide();
|
|
645
|
+
if (drag.mode === "tap") {
|
|
646
|
+
drag.moved = true;
|
|
647
|
+
}
|
|
648
|
+
diagnostics.status = "no-floor-intersection";
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (drag.mode === "tap") {
|
|
652
|
+
diagnostics.status = drag.moved ? "tap-cancelled" : "tap-pending";
|
|
653
|
+
if (drag.moved) {
|
|
654
|
+
this.#feedback.hide();
|
|
655
|
+
} else {
|
|
656
|
+
this.#feedback.show("tap", point);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (!drag.offset) {
|
|
661
|
+
drag.offset = this.#target.subtract(point);
|
|
662
|
+
}
|
|
663
|
+
const previous = this.#target.clone();
|
|
664
|
+
this.#moveOnFloor(point.add(drag.offset), "dragging");
|
|
665
|
+
this.#feedback.show("drag", this.#target, this.#target.subtract(previous));
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.#failDrag(error, "read-input-pose");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Reads two native screen rays in viewer coordinates and applies only their wrapped angular delta as yaw.
|
|
673
|
+
* Common translation/pinch does not change scale or anchor. Coincident rays and lost poses cancel ownership.
|
|
674
|
+
* Inverts the former two-finger direction while preserving handedness. Passes the applied signed yaw to directional feedback. Rotation retains the model bottom pivot; all fingers must lift before a new translation is accepted.
|
|
675
|
+
* @private
|
|
676
|
+
* Only animation frames permit getViewerPose; selection events must defer sampling to the XR loop.
|
|
677
|
+
* @param {XRFrame} frame - Active XR animation frame; native poses are never retained.
|
|
678
|
+
* @returns {void} Contains pose errors and hides stale feedback on cancellation.
|
|
679
|
+
*/
|
|
680
|
+
#updateTurn(frame) {
|
|
681
|
+
const turn = this.#turn;
|
|
682
|
+
try {
|
|
683
|
+
if (turn.space !== this.#manager.referenceSpace || !this.#root?.isEnabled()) {
|
|
684
|
+
this.#cancelDrag();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const viewer = frame.getViewerPose(this.#manager.referenceSpace);
|
|
688
|
+
if (!viewer) {
|
|
689
|
+
this.#cancelDrag();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const inverse = this.#matrix(viewer).invert();
|
|
693
|
+
const points = turn.inputs.map(input => {
|
|
694
|
+
const ray = this.#inputRay(frame, input);
|
|
695
|
+
if (!ray) {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
const direction = Vector3.TransformNormal(ray.direction, inverse);
|
|
699
|
+
const depth = direction.z * (this.#scene.useRightHandedSystem ? -1 : 1);
|
|
700
|
+
return depth > 1e-6 ? { x: direction.x / depth, y: direction.y / depth } : null;
|
|
701
|
+
});
|
|
702
|
+
if (points.some(point => !point) || Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) < .005) {
|
|
703
|
+
this.#diagnostics.drag.status = "rotation-pose-unavailable";
|
|
704
|
+
this.#cancelDrag();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const angle = Math.atan2(points[1].y - points[0].y, points[1].x - points[0].x);
|
|
708
|
+
let yawDelta = 0;
|
|
709
|
+
if (turn.angle !== null) {
|
|
710
|
+
const delta = Math.atan2(Math.sin(angle - turn.angle), Math.cos(angle - turn.angle));
|
|
711
|
+
yawDelta = delta * (this.#scene.useRightHandedSystem ? 1 : -1);
|
|
712
|
+
const yaw = Quaternion.RotationYawPitchRoll(yawDelta, 0, 0);
|
|
713
|
+
this.#rotation = yaw.multiply(this.#rotation).normalize();
|
|
714
|
+
this.#applyPlacement();
|
|
715
|
+
}
|
|
716
|
+
turn.angle = angle;
|
|
717
|
+
this.#diagnostics.drag.status = "rotating";
|
|
718
|
+
this.#feedback.show("rotate", this.#target, null, yawDelta);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
this.#failDrag(error, "rotation-input-pose");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Starts a one-finger model drag, or a pending floor tap outside model geometry after real floor placement.
|
|
726
|
+
* Preserves model grab offset; a second finger schedules yaw for the next animation frame.
|
|
727
|
+
* Never reads viewer poses from a selection frame; further fingers cancel until all lift.
|
|
728
|
+
* No per-finger hit subscription is needed because floor height was already established natively.
|
|
729
|
+
* @private
|
|
730
|
+
* @param {XRInputSourceEvent} event - Native primary-action start with an active input pose.
|
|
731
|
+
* @returns {void} Owns the input until release/removal; pose and picking errors are contained.
|
|
732
|
+
*/
|
|
733
|
+
#selectStart = event => {
|
|
734
|
+
const input = event.inputSource;
|
|
735
|
+
const diagnostics = this.#diagnostics.drag;
|
|
736
|
+
diagnostics.selectStarts++;
|
|
737
|
+
diagnostics.lastInputMode = input.targetRayMode;
|
|
738
|
+
diagnostics.lastError = null;
|
|
739
|
+
diagnostics.pickedMesh = null;
|
|
740
|
+
diagnostics.hitTestStatus = "not-requested";
|
|
741
|
+
diagnostics.lastHitCount = 0;
|
|
742
|
+
if (this.#state !== "placed" || !this.#target) {
|
|
743
|
+
diagnostics.status = "not-placed";
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (input.targetRayMode !== "screen") {
|
|
747
|
+
diagnostics.status = "unsupported-input-mode";
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
this.#touches.add(input);
|
|
751
|
+
if (this.#touches.size === 2 && !this.#blockedGesture && this.#drag && event.frame) {
|
|
752
|
+
this.#cancelDrag();
|
|
753
|
+
this.#blockedGesture = true;
|
|
754
|
+
this.#turn = { inputs: [...this.#touches], space: this.#manager.referenceSpace, angle: null };
|
|
755
|
+
diagnostics.status = "rotation-pending-frame";
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (this.#touches.size > 1) {
|
|
759
|
+
this.#blockedGesture = true;
|
|
760
|
+
diagnostics.status = "multi-touch";
|
|
761
|
+
this.#cancelDrag();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (this.#blockedGesture || !event.frame || !this.#root?.isEnabled()
|
|
765
|
+
|| this.#floorEvidence?.space !== this.#manager.referenceSpace) {
|
|
766
|
+
diagnostics.status = this.#blockedGesture ? "gesture-blocked" : !event.frame ? "missing-frame"
|
|
767
|
+
: !this.#root?.isEnabled() ? "model-hidden" : "reference-space-changed";
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
const ray = this.#inputRay(event.frame, input);
|
|
772
|
+
if (!ray) {
|
|
773
|
+
diagnostics.status = "missing-input-pose";
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const hit = this.#scene.pickWithRay(ray, mesh => this.#asset.meshes.includes(mesh) && mesh.isEnabled() && mesh.isVisible);
|
|
777
|
+
const point = this.#floorIntersection(ray);
|
|
778
|
+
if (!hit?.hit && !point) {
|
|
779
|
+
diagnostics.status = "no-floor-intersection";
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
diagnostics.pickedMesh = hit?.hit ? hit.pickedMesh?.name ?? null : null;
|
|
783
|
+
diagnostics.hitTestStatus = "retained-floor";
|
|
784
|
+
diagnostics.status = hit?.hit ? "drag-ready" : "tap-pending";
|
|
785
|
+
this.#drag = { input, space: this.#manager.referenceSpace, mode: hit?.hit ? "drag" : "tap",
|
|
786
|
+
direction: ray.direction.clone(), moved: false, tracked: true, point,
|
|
787
|
+
offset: hit?.hit && point ? this.#target.subtract(point) : null };
|
|
788
|
+
if (point) {
|
|
789
|
+
this.#feedback.show(hit?.hit ? "drag" : "tap", hit?.hit ? this.#target : point);
|
|
790
|
+
}
|
|
791
|
+
} catch (error) {
|
|
792
|
+
this.#failDrag(error, "pick-model");
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Completes an unmoved off-model floor tap on native release, then forgets input ownership.
|
|
798
|
+
* Confirms valid taps briefly; rotation ends on either finger release, blocking translation until all lift.
|
|
799
|
+
* Removal/cancellation never commits a tap; multiple fingers block placement until every finger lifts.
|
|
800
|
+
* @private
|
|
801
|
+
* @param {{inputSource: XRInputSource, type?: string, frame?: XRFrame}} event - Native release or removed input.
|
|
802
|
+
* @returns {void} Retains the latest diagnostic outcome without holding native resources.
|
|
803
|
+
*/
|
|
804
|
+
#selectEnd = event => {
|
|
805
|
+
this.#diagnostics.drag.selectEnds++;
|
|
806
|
+
let confirmed = null;
|
|
807
|
+
if (this.#turn?.inputs.includes(event.inputSource)) {
|
|
808
|
+
this.#cancelDrag();
|
|
809
|
+
}
|
|
810
|
+
const drag = this.#drag;
|
|
811
|
+
if (drag?.input === event.inputSource) {
|
|
812
|
+
if (event.type === "selectend" && drag.mode === "tap" && !this.#blockedGesture
|
|
813
|
+
&& drag.space === this.#manager.referenceSpace && drag.tracked) {
|
|
814
|
+
if (event.frame) {
|
|
815
|
+
this.#updateDrag(event.frame);
|
|
816
|
+
}
|
|
817
|
+
if (this.#drag === drag && !drag.moved && drag.tracked && drag.point) {
|
|
818
|
+
this.#moveOnFloor(drag.point, "tap-placed");
|
|
819
|
+
confirmed = drag.point.clone();
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
this.#cancelDrag();
|
|
823
|
+
}
|
|
824
|
+
if (confirmed) {
|
|
825
|
+
this.#feedback.confirm(confirmed);
|
|
826
|
+
}
|
|
827
|
+
this.#touches.delete(event.inputSource);
|
|
828
|
+
if (this.#touches.size === 0) {
|
|
829
|
+
this.#blockedGesture = false;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Treats removed native input sources as lifted fingers so interrupted gestures cannot persist.
|
|
835
|
+
* @private
|
|
836
|
+
* @param {XRInputSourcesChangeEvent} event - Native input-source membership update.
|
|
837
|
+
* @returns {void}
|
|
838
|
+
*/
|
|
839
|
+
#inputsChanged = event => {
|
|
840
|
+
for (const inputSource of event.removed) {
|
|
841
|
+
this.#selectEnd({
|
|
842
|
+
inputSource
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Records a bounded input-pose/picking failure and forgets only the current gesture.
|
|
849
|
+
* Leaves the placed model and XR frame/lifecycle observers usable; retains no Error/native resource objects.
|
|
850
|
+
* @private
|
|
851
|
+
* @param {*} error - Native pose or model-picking failure.
|
|
852
|
+
* @param {string} stage - Failed operation for remote diagnostics.
|
|
853
|
+
* @returns {void}
|
|
854
|
+
*/
|
|
855
|
+
#failDrag(error, stage) {
|
|
856
|
+
const diagnostics = this.#diagnostics.drag;
|
|
857
|
+
diagnostics.status = "error";
|
|
858
|
+
diagnostics.hitTestStatus = "error";
|
|
859
|
+
diagnostics.lastError = { stage, name: error?.name ?? "Error", message: error?.message ?? String(error) };
|
|
860
|
+
this.#cancelDrag();
|
|
861
|
+
console.warn("PrefViewer: AR floor interaction unavailable", error);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Hides feedback and forgets rotation, the selected drag or pending tap without moving the model or committing a cancelled tap.
|
|
866
|
+
* @private
|
|
867
|
+
* @returns {void}
|
|
868
|
+
*/
|
|
869
|
+
#cancelDrag() {
|
|
870
|
+
this.#drag = null;
|
|
871
|
+
this.#turn = null;
|
|
872
|
+
this.#feedback.hide();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Releases a native hit subscription without interrupting Babylon's synchronous lifecycle observers.
|
|
877
|
+
* WebXR may already have invalidated it at session end; InvalidStateError means no active source remains.
|
|
878
|
+
* Reports other native cleanup failures, while allowing scene, camera and framebuffer restoration to continue.
|
|
879
|
+
* Callers must discard their owned reference after this attempt, including asynchronous late results.
|
|
880
|
+
* @private
|
|
881
|
+
* @param {?XRHitTestSource} [source] - Owned subscription; null/undefined are harmless no-ops.
|
|
882
|
+
* @returns {void} Native cancellation failures are contained and never escape into lifecycle callbacks.
|
|
883
|
+
*/
|
|
884
|
+
#cancelHitTestSource(source) {
|
|
885
|
+
try {
|
|
886
|
+
source?.cancel();
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (error?.name !== "InvalidStateError") {
|
|
889
|
+
console.warn("PrefViewer: AR hit-test source cleanup failed", error);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Restores the saved manager scale once without interrupting synchronous session-end observers.
|
|
896
|
+
* Skips unchanged values: Babylon notifies its XR camera even when the scale is identical, and that camera
|
|
897
|
+
* can call updateRenderState on an ended session because the manager retains its last native frame.
|
|
898
|
+
* Babylon assigns its scale before notifying observers, so a changed value is restored even if that native
|
|
899
|
+
* update throws InvalidStateError. Logs other failures and lets camera/framebuffer restoration continue.
|
|
900
|
+
* @private
|
|
901
|
+
* @returns {void} Clears saved ownership before restoration; repeated cleanup does not retry the setter.
|
|
902
|
+
*/
|
|
903
|
+
#restoreWorldScale() {
|
|
904
|
+
const previous = this.#previousWorldScale;
|
|
905
|
+
this.#previousWorldScale = null;
|
|
906
|
+
if (previous === null || this.#manager.worldScalingFactor === previous) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
this.#manager.worldScalingFactor = previous;
|
|
911
|
+
} catch (error) {
|
|
912
|
+
if (error?.name !== "InvalidStateError" || this.#manager.inXRSession) {
|
|
913
|
+
console.warn("PrefViewer: AR world-scale cleanup failed", error);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Restores surviving model roots to their original parents and removes only the owned pivot.
|
|
920
|
+
* Restores decomposed external-parent transforms while retaining ordinary glTF root animations.
|
|
921
|
+
* Discards cached footprint corners so replacement assets cannot reuse earlier bounds.
|
|
922
|
+
* Cancels gestures/feedback before replacing content.
|
|
923
|
+
* Restores original glass values and releases material observers before releasing model ownership.
|
|
924
|
+
* @private
|
|
925
|
+
* @returns {void}
|
|
926
|
+
*/
|
|
927
|
+
#releaseModel() {
|
|
928
|
+
this.#cancelDrag();
|
|
929
|
+
this.#glass.restore();
|
|
930
|
+
for (const saved of this.#roots) {
|
|
931
|
+
const {
|
|
932
|
+
node,
|
|
933
|
+
parent
|
|
934
|
+
} = saved;
|
|
935
|
+
if (node.isDisposed()) {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
node.parent = parent?.isDisposed() ? null : parent;
|
|
939
|
+
// Ordinary glTF roots kept their local transforms, including animated ones.
|
|
940
|
+
// Only externally parented roots needed setParent's world-preserving decomposition.
|
|
941
|
+
if (parent) {
|
|
942
|
+
node.position.copyFrom(saved.position);
|
|
943
|
+
node.rotation.copyFrom(saved.rotation);
|
|
944
|
+
node.rotationQuaternion = saved.quaternion?.clone() ?? null;
|
|
945
|
+
node.scaling.copyFrom(saved.scaling);
|
|
946
|
+
}
|
|
947
|
+
node.computeWorldMatrix(true);
|
|
948
|
+
}
|
|
949
|
+
this.#roots = [];
|
|
950
|
+
this.#root?.dispose(true);
|
|
951
|
+
this.#root = this.#asset = this.#pivot = this.#centre = null;
|
|
952
|
+
this.#footprintCorners = [];
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Invalidates pending native work, releases input/hit sources and restores scene/world-scale and glass state.
|
|
957
|
+
* May run repeatedly for exit, failed entry or disposal; leaves lifecycle observers subscribed.
|
|
958
|
+
* Discards detected floor evidence so a later session must scan its own physical surface.
|
|
959
|
+
* Resets frozen indicator floor axes and clears the initial preview reference space and centre along with the owned model pivot.
|
|
960
|
+
* Keeps bounded diagnostics marked as ended, allowing inspection after leaving native AR.
|
|
961
|
+
* Native hit-source cancellation failures cannot prevent Babylon's subsequent exit observers from running.
|
|
962
|
+
* Restores world scale without redundant notifications or propagating native camera updates after session end.
|
|
963
|
+
* @private
|
|
964
|
+
* @returns {void}
|
|
965
|
+
*/
|
|
966
|
+
#end() {
|
|
967
|
+
if (this.#active) {
|
|
968
|
+
this.#diagnostics.ended = true;
|
|
969
|
+
}
|
|
970
|
+
this.#generation++;
|
|
971
|
+
this.#session?.removeEventListener("selectstart", this.#selectStart);
|
|
972
|
+
this.#session?.removeEventListener("selectend", this.#selectEnd);
|
|
973
|
+
this.#session?.removeEventListener("inputsourceschange", this.#inputsChanged);
|
|
974
|
+
this.#session = null;
|
|
975
|
+
this.#cancelHitTestSource(this.#floorSource);
|
|
976
|
+
this.#floorSource = null;
|
|
977
|
+
this.#floorEvidence = null;
|
|
978
|
+
this.#feedback.setFloorYaw(0);
|
|
979
|
+
this.#previewSpace = null;
|
|
980
|
+
this.#cancelDrag();
|
|
981
|
+
this.#touches.clear();
|
|
982
|
+
this.#blockedGesture = false;
|
|
983
|
+
this.#releaseModel();
|
|
984
|
+
for (const [node, enabled] of this.#environment) {
|
|
985
|
+
if (!node.isDisposed()) {
|
|
986
|
+
node.setEnabled(enabled);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
this.#environment.clear();
|
|
990
|
+
this.#restoreWorldScale();
|
|
991
|
+
this.#target = this.#rotation = null;
|
|
992
|
+
this.#active = false;
|
|
993
|
+
this.#state = "inactive";
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Returns a detached metric-placement snapshot without mutating the scene.
|
|
998
|
+
* Nested drag counters, last outcome and errors remain inspectable after release and session exit.
|
|
999
|
+
* @public
|
|
1000
|
+
* @returns {{state: string, metresPerUnit: number, initialDistance: number, floorHeight: ?number, position: ?{x: number, y: number, z: number}, dragging: boolean, model: boolean, sessionActive: boolean, referenceSpaceReady: boolean, floorSourceActive: boolean, diagnostics: object}}
|
|
1001
|
+
* Placement, resource availability and detached frame/hit diagnostics. The last attempt's diagnostics survive
|
|
1002
|
+
* session exit until the next entry; lastSurface describes native placement hits. Drag diagnostics identify
|
|
1003
|
+
* retained-floor projection and input-pose failures; dragging excludes a pending off-model tap.
|
|
1004
|
+
* In state preview, position is the model-centre anchor without a confirmed floor; in placed it is the bottom anchor.
|
|
1005
|
+
* initialDistance is the preferred floor distance; diagnostics distinguish actual distance, bounded fit and provisional placement.
|
|
1006
|
+
*/
|
|
1007
|
+
getDebugInfo() {
|
|
1008
|
+
return { state: this.#state, metresPerUnit: 1, initialDistance: BabylonJSARPlacement.#INITIAL_DISTANCE,
|
|
1009
|
+
floorHeight: this.#floorEvidence?.height ?? null,
|
|
1010
|
+
position: this.#target ? { x: this.#target.x, y: this.#target.y, z: this.#target.z } : null,
|
|
1011
|
+
dragging: this.#drag?.mode === "drag", model: !!this.#asset,
|
|
1012
|
+
sessionActive: !!this.#session, referenceSpaceReady: !!this.#manager.referenceSpace,
|
|
1013
|
+
floorSourceActive: !!this.#floorSource,
|
|
1014
|
+
diagnostics: { ...this.#diagnostics,
|
|
1015
|
+
drag: { ...this.#diagnostics.drag,
|
|
1016
|
+
lastError: this.#diagnostics.drag.lastError ? { ...this.#diagnostics.drag.lastError } : null },
|
|
1017
|
+
lastError: this.#diagnostics.lastError ? { ...this.#diagnostics.lastError } : null,
|
|
1018
|
+
lastSurface: this.#diagnostics.lastSurface ? { ...this.#diagnostics.lastSurface,
|
|
1019
|
+
position: this.#diagnostics.lastSurface.position ? { ...this.#diagnostics.lastSurface.position } : null,
|
|
1020
|
+
normal: this.#diagnostics.lastSurface.normal ? { ...this.#diagnostics.lastSurface.normal } : null } : null } };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Restores owned scene state and removes all registered lifecycle/frame observers.
|
|
1025
|
+
* Disposes owned feedback geometry and materials. Pending source requests cannot publish new state after disposal.
|
|
1026
|
+
* @public
|
|
1027
|
+
* @returns {void}
|
|
1028
|
+
*/
|
|
1029
|
+
dispose() {
|
|
1030
|
+
this.#disposed = true;
|
|
1031
|
+
this.#end();
|
|
1032
|
+
this.#feedback.dispose();
|
|
1033
|
+
for (const [observable, observer] of this.#observers) {
|
|
1034
|
+
observable.remove(observer);
|
|
1035
|
+
}
|
|
1036
|
+
this.#observers = [];
|
|
1037
|
+
}
|
|
1038
|
+
}
|