@relu-interactives/spatial-ecs 0.4.4 → 0.4.5

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.
@@ -7,9 +7,34 @@ With passthrough on, the experience composites over the user's real room instead
7
7
  of a virtual background. Turn it on in the inspector (XR Rig → Mixed Reality), or
8
8
  from a script by authoring the rig.
9
9
 
10
- Mixed reality is a **Quest and Android XR** capability. Safari on visionOS grants
11
- `immersive-ar` to no web page at all, so a passthrough project falls back to an
12
- ordinary VR session there rather than failing to start.
10
+ Mixed reality passthrough is a **Quest and Android XR** capability.
11
+
12
+ ## Apple Vision Pro
13
+
14
+ Safari on visionOS — checked through visionOS 27 — supports WebXR
15
+ `immersive-vr` only. It grants `immersive-ar` to no web page, and exposes no
16
+ plane detection, mesh detection or depth sensing. This is a platform policy,
17
+ not a missing feature on our side; every web XR tool works within it (the
18
+ industry-standard answer is a VR fallback mode).
19
+
20
+ A mixed-reality project still works there, three ways:
21
+
22
+ 1. **Automatic VR fallback.** The session probe falls back to `immersive-vr`,
23
+ the authored background applies, and hands/pinch interaction works. The
24
+ entry page says so honestly: "Passthrough isn't available in this browser —
25
+ this experience will run in VR."
26
+ 2. **A synthetic floor, no scan needed.** With `environmentPhysics` on and no
27
+ scene understanding granted at all, the engine synthesizes one floor
28
+ collider whose top face sits at y = 0 — which `local-floor` makes the
29
+ user's REAL floor. Dropped objects land at floor height instead of falling
30
+ forever. (This only happens when the detection features are entirely
31
+ absent; a Quest that merely has not scanned yet keeps its room-capture
32
+ prompt instead.)
33
+ 3. **"View in your room" via AR Quick Look.** On Apple devices the entry page
34
+ offers a native placement preview: the scene's content is exported to
35
+ `.usdz` on the fly and handed to AR Quick Look, which places it in the real
36
+ room with passthrough — Apple's sanctioned web-AR path (it also works on
37
+ iPhone and iPad). This is a static snapshot: no scripts, no physics.
13
38
 
14
39
  ## What the rig controls
15
40
 
@@ -64,6 +89,64 @@ If a user reports "it never asked to scan my room" and the geometry does not
64
89
  match, they are almost certainly running against a stale scan from another
65
90
  space; re-running Space Setup for the current room fixes it.
66
91
 
92
+ ### Reading the `environmentDebug` overlay
93
+
94
+ The overlay colours surfaces by what the runtime says they are, so a glance
95
+ (or a screenshot) tells you which surface is wrong rather than just "wireframe
96
+ somewhere":
97
+
98
+ - **green** — the floor plane
99
+ - **blue** — walls
100
+ - **purple** — the ceiling
101
+ - **orange** — labelled things: tables, couches, shelves, doors, windows
102
+ - **faint gray** — the raw room scan (the "global mesh")
103
+
104
+ Two things about the global mesh are easy to misread. It is deliberately
105
+ COARSE — a couch becomes a melted lump, a wall gets bumps — because it is a
106
+ collision mesh, not a picture; those lumps are not phantom objects. And under
107
+ `environmentPhysics: "auto"` it is what the physics uses, with the flat planes
108
+ skipped entirely, so orange furniture boxes and gray scan lumps can legitimately
109
+ overlap the same real object.
110
+
111
+ The overlay is depth-tested like ordinary geometry — the near wall hides the
112
+ far one — but it never *writes* depth, so it cannot occlude actual content. (It
113
+ was briefly rendered X-ray; on device, the dense global scan at low opacity
114
+ fuses into translucent sheets, and without a depth test the walls' sheets drew
115
+ through everything and stacked in front of the user's face.)
116
+
117
+ For identifying a mystery shape, `XREnvironmentSystem.getDebugSnapshot(world)`
118
+ returns a census of every spawned surface — kind, semantic label, position and
119
+ approximate radius.
120
+
121
+ One artifact is handled for you: three.js's depth-occlusion pass draws a
122
+ head-locked mesh that writes only depth, but leaves its colour output
123
+ undefined — on Quest hardware that painted the depth camera's view over the
124
+ screen as faint contour bands that follow the head. `XRSessionSystem` forces
125
+ `colorWrite` off on that mesh every frame, so enabling `depthOcclusion` costs
126
+ no visual artifacts.
127
+
128
+ ### Lost tracking freezes a surface — it never follows the head
129
+
130
+ When the headset loses tracking of a surface, WebXR does not stop reporting a
131
+ pose — it reports an *emulated* one: the last known pose held relative to the
132
+ viewer, i.e. glued to the head. Applying that to room geometry would make the
133
+ surface — and its collider — follow the user around, shoving physics objects
134
+ out of the way. The engine instead freezes the surface at its last tracked
135
+ pose, resumes when tracking returns, and never creates a surface from an
136
+ emulated pose in the first place. The diagnostics census marks such surfaces
137
+ with `emulated: true`.
138
+
139
+ ### No artificial locomotion in passthrough
140
+
141
+ While a session composites over the real world, `XRLocomotionSystem` disables
142
+ ALL artificial locomotion — snap turn, teleport and smooth movement — whatever
143
+ the rig says. Virtual content in mixed reality is registered to the real room;
144
+ rotating or translating the rig swings every virtual object out of alignment
145
+ while the passthrough room stays put, which feels broken rather than like
146
+ movement. The user moves by walking. Note that `snapTurn` is a separate field
147
+ from `locomotion`, so `locomotion: "none"` alone would NOT have stopped the
148
+ stick from turning the rig — the gate is on the live blend mode.
149
+
67
150
  ## Finding the real room from a script
68
151
 
69
152
  Detected surfaces carry `XREnvironmentComponent`, so they are queryable like any
@@ -116,6 +199,36 @@ export default class RoomAware extends Behaviour {
116
199
  }
117
200
  ```
118
201
 
202
+ ## Hit-testing the real room from a script
203
+
204
+ With `hitTest` on the rig, `XRHitTestSystem` keeps a ray from the viewer (and
205
+ one per hand) cast against the real room. Systems are resolved by INSTANCE, so
206
+ pass a throwaway one to `getSystem`:
207
+
208
+ ```ts
209
+ import { Behaviour, XRHitTestSystem } from "@relu-interactives/spatial-ecs";
210
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
211
+
212
+ export default class PlaceOnRealSurface extends Behaviour {
213
+ onSelectStart() {
214
+ const hitTest = this.world.getSystem(new XRHitTestSystem());
215
+ // "viewer" = where the user is looking; "left"/"right" = hand rays.
216
+ const hit = hitTest.getHitResult("viewer");
217
+ if (!hit) return; // nothing granted, or no surface under the ray yet
218
+
219
+ const transform = this.world.getComponent(this.entityId, TransformComponent);
220
+ if (transform) {
221
+ transform.position.x = hit.position.x;
222
+ transform.position.y = hit.position.y;
223
+ transform.position.z = hit.position.z;
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ `getHitResult` returns null until the runtime grants hit-testing and the ray
230
+ actually meets a surface — always handle that case.
231
+
119
232
  ## Anchors
120
233
 
121
234
  A headset keeps revising where it thinks the room is. Content at fixed scene
@@ -129,6 +242,27 @@ on, and it stays put relative to the room.
129
242
  Because the anchor owns the transform, moving the entity by script or physics has
130
243
  no lasting effect — disable the component first, move it, then re-enable.
131
244
 
245
+ From a script the toggle is the whole API:
246
+
247
+ ```ts
248
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
249
+ import { XRAnchorComponent } from "@relu-interactives/spatial-ecs/components";
250
+
251
+ export default class ReleaseOnGrab extends Behaviour {
252
+ onGrab() {
253
+ // Let the hand move it: release the weld for the duration of the grab.
254
+ const anchor = this.world.getComponent(this.entityId, XRAnchorComponent);
255
+ if (anchor) anchor.enabled = false;
256
+ }
257
+
258
+ onRelease() {
259
+ // Re-anchor wherever the user left it.
260
+ const anchor = this.world.getComponent(this.entityId, XRAnchorComponent);
261
+ if (anchor) anchor.enabled = true;
262
+ }
263
+ }
264
+ ```
265
+
132
266
  Anchors last for the session only. Persisting one across launches needs
133
267
  `requestPersistentHandle`, which is a draft extension and not supported here.
134
268
 
@@ -185,6 +185,35 @@ onXRSessionStart(capabilities: XrCapabilities) {
185
185
 
186
186
  Two rules worth internalising: **move the rig, never the camera** (the headset owns the camera while presenting and overwrites it every frame), and give any destructive gesture a longer **Hold Time** so it cannot fire in passing.
187
187
 
188
+ ### Reading grab state from a script
189
+
190
+ `XRInteractionSystem.getGrabInfo(entityId)` reports who is holding an entity —
191
+ hand count, primary/secondary handedness, live hand separation (two-hand grabs)
192
+ and the attach-point ids in use. Systems resolve by instance:
193
+
194
+ ```ts
195
+ import {
196
+ Behaviour,
197
+ XRInputSystem,
198
+ XRInteractionSystem,
199
+ } from "@relu-interactives/spatial-ecs";
200
+
201
+ export default class TwoHandChargeUp extends Behaviour {
202
+ onUpdate() {
203
+ const interaction = this.world.getSystem(
204
+ new XRInteractionSystem(new XRInputSystem()),
205
+ );
206
+ const grab = interaction.getGrabInfo(this.entityId);
207
+ if (!grab || grab.hands < 2 || grab.handSeparation === null) return;
208
+
209
+ // Example: pull both hands apart to "charge" — separation is metres.
210
+ console.log("charge level", Math.min(1, grab.handSeparation / 0.8));
211
+ }
212
+ }
213
+ ```
214
+
215
+ Returns null while nothing holds the entity, so gate on it every frame.
216
+
188
217
  ## Hand poses
189
218
 
190
219
  Add an **XR Hand Pose** component and list the poses to watch for. Each is either a built-in preset or a custom set of finger curls.
@@ -102,7 +102,7 @@ Both methods accept `Vec3` (`THREE.Vector3` or `{ x, y, z }`) and return `Inters
102
102
 
103
103
  | Method | Purpose |
104
104
  | --- | --- |
105
- | `getSystem(SystemClass)` | Resolve a registered system instance. |
105
+ | `getSystem(instance)` | Resolve the REGISTERED system matching the argument's class — pass a throwaway instance (`world.getSystem(new XRHitTestSystem())`), never the class itself. |
106
106
  | `hasSystem(SystemClass)` | `true` if the system is registered. |
107
107
  | `getObjectManagementSystem()` | Shorthand for the built-in `ObjectManagementSystem`. Use this to spawn or destroy entities at runtime (`requestCreate`, `createNow`, `deleteEntity`). |
108
108
  | `getEnvironmentComponent()` | Returns the singleton [`Environment`](./scene/environment) component, or `undefined` if not yet initialized. Mutate `values` to change skybox, fog, or gravity at runtime. |
@@ -651,6 +651,25 @@ declare class XRSessionSystem implements EcsSystem {
651
651
  private session;
652
652
  constructor(inputSystem?: XRInputSystem);
653
653
  update(world: World): void;
654
+ /**
655
+ * Stop three's depth-occlusion pass from writing COLOR.
656
+ *
657
+ * With depth sensing granted, three draws a full-screen, head-locked mesh
658
+ * every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
659
+ * the whole occlusion mechanism — but its material never sets
660
+ * `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
661
+ * drivers happen to zero it, which is why this ships unnoticed; Quest's
662
+ * mobile GPU frequently leaves the last-computed temporary in the register —
663
+ * the sampled DEPTH value — so the pass paints the depth camera's view over
664
+ * the screen as faint head-locked contour bands. On device that reads as "a
665
+ * large wireframe dome that follows my head".
666
+ *
667
+ * The mesh is created lazily inside three's render, so this is re-checked
668
+ * every frame rather than hooked once; the write is idempotent and the
669
+ * check is two property reads. Depth-only is the mesh's entire purpose —
670
+ * occlusion is unaffected.
671
+ */
672
+ private muteDepthOcclusionColor;
654
673
  private handleSessionStart;
655
674
  /**
656
675
  * Apply the rig's authored tracking origin, and compensate when the device
@@ -718,6 +737,29 @@ declare class XREnvironmentSystem implements EcsSystem {
718
737
  planes: number;
719
738
  meshes: number;
720
739
  };
740
+ /**
741
+ * A census of every surface currently spawned: what it is, where it sits and
742
+ * roughly how big it is. Built for the template's post-session readout, so a
743
+ * mystery shape on the headset can be looked up by its label instead of
744
+ * guessed at — a small-radius entry at head height IS the mystery shape, and
745
+ * a clean list means whatever was seen did not come from this system.
746
+ */
747
+ getDebugSnapshot(world: World): {
748
+ kind: "plane" | "mesh";
749
+ label: string | null;
750
+ position: {
751
+ x: number;
752
+ y: number;
753
+ z: number;
754
+ };
755
+ radius: number;
756
+ /** Tracking currently lost — pose frozen at the last known place. */
757
+ emulated: boolean;
758
+ }[];
759
+ /** Create the no-scan floor once; it lives in the ordinary plane map. */
760
+ private ensureSyntheticFloor;
761
+ /** Real scene data exists (or arrived) — the stand-in must go. */
762
+ private removeSyntheticFloor;
721
763
  private findRig;
722
764
  private syncPlanes;
723
765
  private syncMeshes;
@@ -651,6 +651,25 @@ declare class XRSessionSystem implements EcsSystem {
651
651
  private session;
652
652
  constructor(inputSystem?: XRInputSystem);
653
653
  update(world: World): void;
654
+ /**
655
+ * Stop three's depth-occlusion pass from writing COLOR.
656
+ *
657
+ * With depth sensing granted, three draws a full-screen, head-locked mesh
658
+ * every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
659
+ * the whole occlusion mechanism — but its material never sets
660
+ * `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
661
+ * drivers happen to zero it, which is why this ships unnoticed; Quest's
662
+ * mobile GPU frequently leaves the last-computed temporary in the register —
663
+ * the sampled DEPTH value — so the pass paints the depth camera's view over
664
+ * the screen as faint head-locked contour bands. On device that reads as "a
665
+ * large wireframe dome that follows my head".
666
+ *
667
+ * The mesh is created lazily inside three's render, so this is re-checked
668
+ * every frame rather than hooked once; the write is idempotent and the
669
+ * check is two property reads. Depth-only is the mesh's entire purpose —
670
+ * occlusion is unaffected.
671
+ */
672
+ private muteDepthOcclusionColor;
654
673
  private handleSessionStart;
655
674
  /**
656
675
  * Apply the rig's authored tracking origin, and compensate when the device
@@ -718,6 +737,29 @@ declare class XREnvironmentSystem implements EcsSystem {
718
737
  planes: number;
719
738
  meshes: number;
720
739
  };
740
+ /**
741
+ * A census of every surface currently spawned: what it is, where it sits and
742
+ * roughly how big it is. Built for the template's post-session readout, so a
743
+ * mystery shape on the headset can be looked up by its label instead of
744
+ * guessed at — a small-radius entry at head height IS the mystery shape, and
745
+ * a clean list means whatever was seen did not come from this system.
746
+ */
747
+ getDebugSnapshot(world: World): {
748
+ kind: "plane" | "mesh";
749
+ label: string | null;
750
+ position: {
751
+ x: number;
752
+ y: number;
753
+ z: number;
754
+ };
755
+ radius: number;
756
+ /** Tracking currently lost — pose frozen at the last known place. */
757
+ emulated: boolean;
758
+ }[];
759
+ /** Create the no-scan floor once; it lives in the ordinary plane map. */
760
+ private ensureSyntheticFloor;
761
+ /** Real scene data exists (or arrived) — the stand-in must go. */
762
+ private removeSyntheticFloor;
721
763
  private findRig;
722
764
  private syncPlanes;
723
765
  private syncMeshes;
package/dist/index.cjs CHANGED
@@ -12059,6 +12059,7 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
12059
12059
  const rig = world.getComponent(rigEntityId, XRRigComponent);
12060
12060
  const rigTransform = world.getComponent(rigEntityId, TransformComponent);
12061
12061
  if (!rig || !rigTransform) return;
12062
+ if (world.isXrPassthrough()) return;
12062
12063
  const inputs = this.inputSystem.getInputs();
12063
12064
  if (inputs.length === 0) return;
12064
12065
  if (rig.snapTurn) {
@@ -12321,6 +12322,32 @@ var _XRSessionSystem = class _XRSessionSystem {
12321
12322
  }
12322
12323
  if (presenting) {
12323
12324
  this.dispatchInputEdges(world);
12325
+ this.muteDepthOcclusionColor(world);
12326
+ }
12327
+ }
12328
+ /**
12329
+ * Stop three's depth-occlusion pass from writing COLOR.
12330
+ *
12331
+ * With depth sensing granted, three draws a full-screen, head-locked mesh
12332
+ * every frame whose fragment shader assigns ONLY `gl_FragDepth` — that is
12333
+ * the whole occlusion mechanism — but its material never sets
12334
+ * `colorWrite: false`, so the colour output is UNDEFINED per GLSL. Desktop
12335
+ * drivers happen to zero it, which is why this ships unnoticed; Quest's
12336
+ * mobile GPU frequently leaves the last-computed temporary in the register —
12337
+ * the sampled DEPTH value — so the pass paints the depth camera's view over
12338
+ * the screen as faint head-locked contour bands. On device that reads as "a
12339
+ * large wireframe dome that follows my head".
12340
+ *
12341
+ * The mesh is created lazily inside three's render, so this is re-checked
12342
+ * every frame rather than hooked once; the write is idempotent and the
12343
+ * check is two property reads. Depth-only is the mesh's entire purpose —
12344
+ * occlusion is unaffected.
12345
+ */
12346
+ muteDepthOcclusionColor(world) {
12347
+ const xr = world.getRenderer().xr;
12348
+ const material = xr.getDepthSensingMesh?.()?.material;
12349
+ if (material && material.colorWrite !== false) {
12350
+ material.colorWrite = false;
12324
12351
  }
12325
12352
  }
12326
12353
  handleSessionStart(world) {
@@ -12474,8 +12501,12 @@ function planeColliderFromPolygon(polygon) {
12474
12501
  __name(planeColliderFromPolygon, "planeColliderFromPolygon");
12475
12502
  function writeXrMeshGeometry(vertices, indices, existing) {
12476
12503
  const geometry = existing ?? new THREE51__namespace.BufferGeometry();
12477
- geometry.setAttribute("position", new THREE51__namespace.BufferAttribute(vertices, 3));
12478
- geometry.setIndex(new THREE51__namespace.BufferAttribute(indices, 1));
12504
+ geometry.setAttribute(
12505
+ "position",
12506
+ new THREE51__namespace.BufferAttribute(vertices.slice(), 3)
12507
+ );
12508
+ geometry.setIndex(new THREE51__namespace.BufferAttribute(indices.slice(), 1));
12509
+ geometry.deleteAttribute("normal");
12479
12510
  geometry.computeBoundingSphere();
12480
12511
  geometry.computeVertexNormals();
12481
12512
  return geometry;
@@ -12501,7 +12532,42 @@ __name(decomposeXrPose, "decomposeXrPose");
12501
12532
 
12502
12533
  // core/systems/XREnvironmentSystem.ts
12503
12534
  var DEBUG_COLOR = 5088255;
12535
+ function debugColorFor(kind, semanticLabel) {
12536
+ switch (semanticLabel) {
12537
+ case "floor":
12538
+ return 5100666;
12539
+ // green — the surface everything should rest on
12540
+ case "ceiling":
12541
+ return 11693055;
12542
+ // purple
12543
+ case "wall":
12544
+ case "wall face":
12545
+ return DEBUG_COLOR;
12546
+ // blue, the family's base colour
12547
+ case "global mesh":
12548
+ return 9079442;
12549
+ // dim gray — raw scan, not an object
12550
+ default:
12551
+ return semanticLabel ? 16747594 : DEBUG_COLOR;
12552
+ }
12553
+ }
12554
+ __name(debugColorFor, "debugColorFor");
12555
+ var GLOBAL_MESH_DEBUG_OPACITY = 0.35;
12556
+ var DEBUG_RENDER_ORDER = 900;
12504
12557
  var ROOM_CAPTURE_DELAY_SECONDS = 3;
12558
+ var SYNTHETIC_FLOOR_EXTENT = 50;
12559
+ var SYNTHETIC_FLOOR_PLANE = {
12560
+ planeSpace: {},
12561
+ polygon: [
12562
+ { x: -SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: -SYNTHETIC_FLOOR_EXTENT / 2 },
12563
+ { x: SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: -SYNTHETIC_FLOOR_EXTENT / 2 },
12564
+ { x: SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: SYNTHETIC_FLOOR_EXTENT / 2 },
12565
+ { x: -SYNTHETIC_FLOOR_EXTENT / 2, y: 0, z: SYNTHETIC_FLOOR_EXTENT / 2 }
12566
+ ],
12567
+ orientation: "horizontal",
12568
+ semanticLabel: "floor",
12569
+ lastChangedTime: 0
12570
+ };
12505
12571
  var _XREnvironmentSystem = class _XREnvironmentSystem {
12506
12572
  constructor() {
12507
12573
  /** Keyed by the XRPlane/XRMesh object itself — the runtime's own identity. */
@@ -12542,6 +12608,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12542
12608
  const frame = xr.getFrame?.();
12543
12609
  const referenceSpace = xr.getReferenceSpace();
12544
12610
  if (!frame || !referenceSpace) return;
12611
+ if (frame.detectedPlanes === void 0 && frame.detectedMeshes === void 0) {
12612
+ this.ensureSyntheticFloor(world);
12613
+ return;
12614
+ }
12615
+ this.removeSyntheticFloor(world);
12545
12616
  const meshesAvailable = Boolean(frame.detectedMeshes);
12546
12617
  const useMesh = (mode === "mesh" || mode === "auto") && meshesAvailable;
12547
12618
  const usePlanes = mode === "planes" || mode === "auto" && !useMesh;
@@ -12588,6 +12659,51 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12588
12659
  getDetectedCount() {
12589
12660
  return { planes: this.planes.size, meshes: this.meshes.size };
12590
12661
  }
12662
+ /**
12663
+ * A census of every surface currently spawned: what it is, where it sits and
12664
+ * roughly how big it is. Built for the template's post-session readout, so a
12665
+ * mystery shape on the headset can be looked up by its label instead of
12666
+ * guessed at — a small-radius entry at head height IS the mystery shape, and
12667
+ * a clean list means whatever was seen did not come from this system.
12668
+ */
12669
+ getDebugSnapshot(world) {
12670
+ const snapshot = [];
12671
+ for (const [plane, entry] of this.planes) {
12672
+ const transform = world.getComponent(entry.entityId, TransformComponent);
12673
+ const collider = planeColliderFromPolygon(plane.polygon);
12674
+ snapshot.push({
12675
+ kind: "plane",
12676
+ label: plane.semanticLabel ?? null,
12677
+ position: transform ? { ...transform.position } : { x: 0, y: 0, z: 0 },
12678
+ radius: collider ? Math.hypot(collider.size.x, collider.size.z) / 2 : 0,
12679
+ emulated: entry.emulated
12680
+ });
12681
+ }
12682
+ for (const [mesh, entry] of this.meshes) {
12683
+ const transform = world.getComponent(entry.entityId, TransformComponent);
12684
+ snapshot.push({
12685
+ kind: "mesh",
12686
+ label: mesh.semanticLabel ?? null,
12687
+ position: transform ? { ...transform.position } : { x: 0, y: 0, z: 0 },
12688
+ radius: entry.geometry?.boundingSphere?.radius ?? 0,
12689
+ emulated: entry.emulated
12690
+ });
12691
+ }
12692
+ return snapshot;
12693
+ }
12694
+ /** Create the no-scan floor once; it lives in the ordinary plane map. */
12695
+ ensureSyntheticFloor(world) {
12696
+ if (this.planes.has(SYNTHETIC_FLOOR_PLANE)) return;
12697
+ const created = this.createPlaneEntity(world, SYNTHETIC_FLOOR_PLANE);
12698
+ if (created) this.planes.set(SYNTHETIC_FLOOR_PLANE, created);
12699
+ }
12700
+ /** Real scene data exists (or arrived) — the stand-in must go. */
12701
+ removeSyntheticFloor(world) {
12702
+ const entry = this.planes.get(SYNTHETIC_FLOOR_PLANE);
12703
+ if (!entry) return;
12704
+ this.destroyEntry(world, entry);
12705
+ this.planes.delete(SYNTHETIC_FLOOR_PLANE);
12706
+ }
12591
12707
  findRig(world) {
12592
12708
  const ids = world.query(XRRigComponent);
12593
12709
  if (ids.length === 0) return null;
@@ -12605,9 +12721,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12605
12721
  for (const plane of detected) {
12606
12722
  const pose = frame.getPose(plane.planeSpace, referenceSpace);
12607
12723
  if (!pose) continue;
12724
+ const emulated = pose.emulatedPosition === true;
12608
12725
  let entry = this.planes.get(plane);
12609
12726
  const changed = plane.lastChangedTime ?? 0;
12610
12727
  if (!entry) {
12728
+ if (emulated) continue;
12611
12729
  const created = this.createPlaneEntity(world, plane);
12612
12730
  if (!created) continue;
12613
12731
  entry = created;
@@ -12620,11 +12738,17 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12620
12738
  if (collider) {
12621
12739
  entry.geometry?.dispose();
12622
12740
  entry.geometry = null;
12623
- this.attachPlaneDebug(world, entry, collider);
12741
+ this.attachPlaneDebug(
12742
+ world,
12743
+ entry,
12744
+ collider,
12745
+ plane.semanticLabel ?? null
12746
+ );
12624
12747
  }
12625
12748
  }
12626
12749
  }
12627
- this.applyPose(world, entry.entityId, pose);
12750
+ entry.emulated = emulated;
12751
+ if (!emulated) this.applyPose(world, entry.entityId, pose);
12628
12752
  }
12629
12753
  }
12630
12754
  syncMeshes(world, frame, referenceSpace) {
@@ -12639,9 +12763,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12639
12763
  for (const mesh of detected) {
12640
12764
  const pose = frame.getPose(mesh.meshSpace, referenceSpace);
12641
12765
  if (!pose) continue;
12766
+ const emulated = pose.emulatedPosition === true;
12642
12767
  let entry = this.meshes.get(mesh);
12643
12768
  const changed = mesh.lastChangedTime ?? 0;
12644
12769
  if (!entry) {
12770
+ if (emulated) continue;
12645
12771
  const created = this.createMeshEntity(world, mesh);
12646
12772
  if (!created) continue;
12647
12773
  entry = created;
@@ -12650,7 +12776,8 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12650
12776
  entry.lastChangedTime = changed;
12651
12777
  writeXrMeshGeometry(mesh.vertices, mesh.indices, entry.geometry);
12652
12778
  }
12653
- this.applyPose(world, entry.entityId, pose);
12779
+ entry.emulated = emulated;
12780
+ if (!emulated) this.applyPose(world, entry.entityId, pose);
12654
12781
  }
12655
12782
  }
12656
12783
  createPlaneEntity(world, plane) {
@@ -12670,9 +12797,12 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12670
12797
  entityId,
12671
12798
  lastChangedTime: plane.lastChangedTime ?? 0,
12672
12799
  geometry: null,
12673
- material: null
12800
+ material: null,
12801
+ emulated: false
12674
12802
  };
12675
- if (this.debugVisible) this.attachPlaneDebug(world, entry, collider);
12803
+ if (this.debugVisible) {
12804
+ this.attachPlaneDebug(world, entry, collider, plane.semanticLabel ?? null);
12805
+ }
12676
12806
  return entry;
12677
12807
  }
12678
12808
  applyPlaneCollider(world, entityId, plane) {
@@ -12697,15 +12827,36 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12697
12827
  createMeshEntity(world, mesh) {
12698
12828
  if (!mesh.vertices?.length || !mesh.indices?.length) return null;
12699
12829
  const geometry = writeXrMeshGeometry(mesh.vertices, mesh.indices);
12830
+ const label = mesh.semanticLabel ?? null;
12831
+ const isGlobalMesh = label === "global mesh";
12700
12832
  const material = new THREE51__namespace.MeshBasicMaterial({
12701
- color: DEBUG_COLOR,
12702
- wireframe: true
12833
+ color: debugColorFor("mesh", label),
12834
+ wireframe: true,
12835
+ // The global scan is a coarse triangle soup of the whole room; at full
12836
+ // strength it reads as phantom objects rather than as context.
12837
+ transparent: isGlobalMesh,
12838
+ opacity: isGlobalMesh ? GLOBAL_MESH_DEBUG_OPACITY : 1,
12839
+ // Depth-TESTED, deliberately. The overlay was X-ray for one release —
12840
+ // introduced on the theory that depth occlusion was carving it into a
12841
+ // head-locked dome — but that theory was falsified (the dome was the
12842
+ // depth mesh's unset colour output, plus emulated poses; both fixed).
12843
+ // X-ray had its own on-device failure: the global scan is DENSE, and a
12844
+ // dense wireframe at 0.35 opacity fuses into a translucent sheet — with
12845
+ // no depth test the walls' sheets drew through everything, stacked in
12846
+ // front of the user's eyes ("planes covering my face"). With the depth
12847
+ // buffer now holding CORRECT real-world depth, normal testing gives the
12848
+ // right picture: the near wall hides the far one. depthWrite stays off
12849
+ // so the overlay never occludes actual content.
12850
+ depthTest: true,
12851
+ depthWrite: false
12703
12852
  });
12704
12853
  const root = new THREE51__namespace.Group();
12705
12854
  const child = new THREE51__namespace.Mesh(geometry, material);
12706
12855
  child.visible = this.debugVisible;
12856
+ child.renderOrder = DEBUG_RENDER_ORDER;
12707
12857
  root.add(child);
12708
12858
  const entityId = world.createEntity();
12859
+ root.userData.entityId = entityId;
12709
12860
  world.addComponents(entityId, [
12710
12861
  new TransformComponent(),
12711
12862
  new Object3DRef(root),
@@ -12719,29 +12870,37 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12719
12870
  entityId,
12720
12871
  lastChangedTime: mesh.lastChangedTime ?? 0,
12721
12872
  geometry,
12722
- material
12873
+ material,
12874
+ emulated: false
12723
12875
  };
12724
12876
  }
12725
12877
  /** A thin box so a detected plane can be SEEN, not just collided with. */
12726
- attachPlaneDebug(world, entry, collider) {
12727
- const geometry = new THREE51__namespace.BoxGeometry(
12878
+ attachPlaneDebug(world, entry, collider, semanticLabel) {
12879
+ const box = new THREE51__namespace.BoxGeometry(
12728
12880
  collider.size.x,
12729
12881
  collider.size.y,
12730
12882
  collider.size.z
12731
12883
  );
12732
- const material = new THREE51__namespace.MeshBasicMaterial({
12733
- color: DEBUG_COLOR,
12734
- wireframe: true
12884
+ const geometry = new THREE51__namespace.EdgesGeometry(box);
12885
+ box.dispose();
12886
+ const material = new THREE51__namespace.LineBasicMaterial({
12887
+ color: debugColorFor("plane", semanticLabel),
12888
+ // Same depth policy as the mesh overlay — see createMeshEntity.
12889
+ depthTest: true,
12890
+ depthWrite: false
12735
12891
  });
12736
12892
  const root = new THREE51__namespace.Group();
12737
- const child = new THREE51__namespace.Mesh(geometry, material);
12893
+ const child = new THREE51__namespace.LineSegments(geometry, material);
12894
+ child.renderOrder = DEBUG_RENDER_ORDER;
12738
12895
  child.position.set(collider.offset.x, collider.offset.y, collider.offset.z);
12739
12896
  child.visible = this.debugVisible;
12740
12897
  root.add(child);
12741
12898
  entry.geometry = geometry;
12742
12899
  entry.material = material;
12900
+ root.userData.entityId = entry.entityId;
12743
12901
  const existing = world.getComponent(entry.entityId, Object3DRef);
12744
12902
  if (existing) {
12903
+ existing.object.removeFromParent();
12745
12904
  existing.object = root;
12746
12905
  } else {
12747
12906
  world.addComponent(entry.entityId, new Object3DRef(root));
@@ -12757,19 +12916,31 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12757
12916
  for (const [plane, entry] of this.planes) {
12758
12917
  if (entry.geometry) continue;
12759
12918
  const collider = planeColliderFromPolygon(plane.polygon);
12760
- if (collider) this.attachPlaneDebug(world, entry, collider);
12919
+ if (collider) {
12920
+ this.attachPlaneDebug(
12921
+ world,
12922
+ entry,
12923
+ collider,
12924
+ plane.semanticLabel ?? null
12925
+ );
12926
+ }
12761
12927
  }
12762
12928
  }
12763
12929
  for (const map of [this.planes, this.meshes]) {
12764
12930
  for (const entry of map.values()) {
12765
12931
  const ref = world.getComponent(entry.entityId, Object3DRef);
12766
12932
  ref?.object.traverse((child) => {
12767
- if (child.isMesh) child.visible = this.debugVisible;
12933
+ const drawable = child;
12934
+ if (drawable.isMesh || drawable.isLine) {
12935
+ child.visible = this.debugVisible;
12936
+ }
12768
12937
  });
12769
12938
  }
12770
12939
  }
12771
12940
  }
12772
12941
  destroyEntry(world, entry) {
12942
+ const ref = world.getComponent(entry.entityId, Object3DRef);
12943
+ ref?.object.removeFromParent();
12773
12944
  entry.geometry?.dispose();
12774
12945
  entry.material?.dispose();
12775
12946
  world.destroyEntity(entry.entityId);
@@ -12946,7 +13117,9 @@ var _XRAnchorSystem = class _XRAnchorSystem {
12946
13117
  continue;
12947
13118
  }
12948
13119
  const pose = frame.getPose(anchor.anchorSpace, referenceSpace);
12949
- if (pose) decomposeXrPose(pose.transform.matrix, transform);
13120
+ if (pose && pose.emulatedPosition !== true) {
13121
+ decomposeXrPose(pose.transform.matrix, transform);
13122
+ }
12950
13123
  }
12951
13124
  }
12952
13125
  /** Whether this entity's pose is currently being driven by an anchor. */