@relu-interactives/spatial-ecs 0.4.3 → 0.4.4

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.
@@ -41,6 +41,29 @@ Each detected surface becomes an ordinary entity, so it goes through the same
41
41
  physics as everything else: a dynamic rigidbody dropped in a mixed-reality scene
42
42
  lands on the real floor with no extra code.
43
43
 
44
+ ### The room has to have been scanned first
45
+
46
+ This is the single most common reason a mixed-reality scene comes up empty, and
47
+ it is not a bug in the experience.
48
+
49
+ **Plane and mesh detection do not scan anything.** They replay a scan the user
50
+ captured earlier through the headset's own room setup — on Quest, *Settings >
51
+ Guardian > Mixed Reality* (Space Setup). If the user has never run it,
52
+ `detectedPlanes` and `detectedMeshes` simply stay empty, with no prompt and no
53
+ error. If they ran it in a **different room**, you get that room's furniture
54
+ overlaid on this one, which looks like the tracking is broken.
55
+
56
+ `XREnvironmentSystem` handles this: when `environmentPhysics` is on and nothing
57
+ at all has been detected three seconds into the session, it calls
58
+ `XRSession.initiateRoomCapture()`, which launches room setup from inside the
59
+ session. It is a Meta extension, so it is feature-detected and silently skipped
60
+ elsewhere, and — per Meta's contract — it is only ever called **once per
61
+ session**.
62
+
63
+ If a user reports "it never asked to scan my room" and the geometry does not
64
+ match, they are almost certainly running against a stale scan from another
65
+ space; re-running Space Setup for the current room fixes it.
66
+
44
67
  ## Finding the real room from a script
45
68
 
46
69
  Detected surfaces carry `XREnvironmentComponent`, so they are queryable like any
@@ -647,6 +647,8 @@ declare class XRSessionSystem implements EcsSystem {
647
647
  private readonly inputSystem;
648
648
  /** Y compensation applied when a floor-relative space was requested but not granted. */
649
649
  private appliedOriginOffset;
650
+ /** The live session, so an async floor probe can tell if it has been replaced. */
651
+ private session;
650
652
  constructor(inputSystem?: XRInputSystem);
651
653
  update(world: World): void;
652
654
  private handleSessionStart;
@@ -690,7 +692,27 @@ declare class XREnvironmentSystem implements EcsSystem {
690
692
  private readonly meshes;
691
693
  private presenting;
692
694
  private debugVisible;
693
- update(world: World): void;
695
+ /** Seconds presenting, used to decide the room has genuinely nothing in it. */
696
+ private elapsed;
697
+ /** `initiateRoomCapture` may be called only ONCE per session. */
698
+ private roomCaptureRequested;
699
+ update(world: World, deltaTime?: number): void;
700
+ /**
701
+ * Ask the runtime to scan the room when it turns out we have no room.
702
+ *
703
+ * Plane and mesh detection do NOT scan anything — they replay a scan the user
704
+ * captured earlier through the system's own room setup. Most people have
705
+ * never run it, and those who have may have run it in a DIFFERENT room, so a
706
+ * mixed-reality experience opens against either nothing at all or someone
707
+ * else's furniture, with no prompt and no error.
708
+ *
709
+ * `XRSession.initiateRoomCapture()` (Meta's extension) launches that setup
710
+ * from inside the session, and exists precisely for this case. Contract, from
711
+ * Meta's docs: callable ONCE per session, and only worth calling once we are
712
+ * confident the room really is empty — the arrays populate a beat after the
713
+ * session starts, so a check on frame one would fire for everyone.
714
+ */
715
+ private maybeCaptureRoom;
694
716
  /** How many pieces of real geometry are currently colliding. Script-facing. */
695
717
  getDetectedCount(): {
696
718
  planes: number;
@@ -647,6 +647,8 @@ declare class XRSessionSystem implements EcsSystem {
647
647
  private readonly inputSystem;
648
648
  /** Y compensation applied when a floor-relative space was requested but not granted. */
649
649
  private appliedOriginOffset;
650
+ /** The live session, so an async floor probe can tell if it has been replaced. */
651
+ private session;
650
652
  constructor(inputSystem?: XRInputSystem);
651
653
  update(world: World): void;
652
654
  private handleSessionStart;
@@ -690,7 +692,27 @@ declare class XREnvironmentSystem implements EcsSystem {
690
692
  private readonly meshes;
691
693
  private presenting;
692
694
  private debugVisible;
693
- update(world: World): void;
695
+ /** Seconds presenting, used to decide the room has genuinely nothing in it. */
696
+ private elapsed;
697
+ /** `initiateRoomCapture` may be called only ONCE per session. */
698
+ private roomCaptureRequested;
699
+ update(world: World, deltaTime?: number): void;
700
+ /**
701
+ * Ask the runtime to scan the room when it turns out we have no room.
702
+ *
703
+ * Plane and mesh detection do NOT scan anything — they replay a scan the user
704
+ * captured earlier through the system's own room setup. Most people have
705
+ * never run it, and those who have may have run it in a DIFFERENT room, so a
706
+ * mixed-reality experience opens against either nothing at all or someone
707
+ * else's furniture, with no prompt and no error.
708
+ *
709
+ * `XRSession.initiateRoomCapture()` (Meta's extension) launches that setup
710
+ * from inside the session, and exists precisely for this case. Contract, from
711
+ * Meta's docs: callable ONCE per session, and only worth calling once we are
712
+ * confident the room really is empty — the arrays populate a beat after the
713
+ * session starts, so a check on frame one would fire for everyone.
714
+ */
715
+ private maybeCaptureRoom;
694
716
  /** How many pieces of real geometry are currently colliding. Script-facing. */
695
717
  getDetectedCount(): {
696
718
  planes: number;
package/dist/index.cjs CHANGED
@@ -12305,6 +12305,8 @@ var _XRSessionSystem = class _XRSessionSystem {
12305
12305
  this.capabilities = NO_XR_CAPABILITIES;
12306
12306
  /** Y compensation applied when a floor-relative space was requested but not granted. */
12307
12307
  this.appliedOriginOffset = 0;
12308
+ /** The live session, so an async floor probe can tell if it has been replaced. */
12309
+ this.session = null;
12308
12310
  this.inputSystem = inputSystem ?? null;
12309
12311
  }
12310
12312
  update(world) {
@@ -12323,6 +12325,7 @@ var _XRSessionSystem = class _XRSessionSystem {
12323
12325
  }
12324
12326
  handleSessionStart(world) {
12325
12327
  const session = world.getRenderer().xr.getSession();
12328
+ this.session = session ?? null;
12326
12329
  const mode = session && session.environmentBlendMode !== "opaque" ? "immersive-ar" : "immersive-vr";
12327
12330
  this.capabilities = readXrCapabilities(session ?? null, mode);
12328
12331
  if (session && this.inputSystem) {
@@ -12361,16 +12364,16 @@ var _XRSessionSystem = class _XRSessionSystem {
12361
12364
  renderer.xr.setReferenceSpaceType(wantsFloor ? "local-floor" : "local");
12362
12365
  } catch {
12363
12366
  }
12364
- const granted = session?.enabledFeatures;
12365
- const floorGranted = Array.isArray(granted) ? granted.includes("local-floor") : (
12366
- // No report at all: trust the request, since a wrong offset is worse
12367
- // than none on devices that did honour it.
12368
- true
12369
- );
12370
- this.appliedOriginOffset = wantsFloor && !floorGranted ? rig.fallbackEyeHeight : 0;
12371
- if (this.appliedOriginOffset !== 0) {
12367
+ this.appliedOriginOffset = 0;
12368
+ if (!wantsFloor || !session) return;
12369
+ const sessionAtRequest = session;
12370
+ session.requestReferenceSpace("local-floor").then(() => {
12371
+ }).catch(() => {
12372
+ if (this.session !== sessionAtRequest) return;
12373
+ if (this.appliedOriginOffset !== 0) return;
12374
+ this.appliedOriginOffset = rig.fallbackEyeHeight;
12372
12375
  transform.position.y -= this.appliedOriginOffset;
12373
- }
12376
+ });
12374
12377
  }
12375
12378
  handleSessionEnd(world) {
12376
12379
  if (this.appliedOriginOffset !== 0) {
@@ -12381,6 +12384,7 @@ var _XRSessionSystem = class _XRSessionSystem {
12381
12384
  }
12382
12385
  this.appliedOriginOffset = 0;
12383
12386
  }
12387
+ this.session = null;
12384
12388
  this.capabilities = NO_XR_CAPABILITIES;
12385
12389
  this.inputSystem?.detachSession();
12386
12390
  this.forEachBehaviour(world, (behaviour) => {
@@ -12497,6 +12501,7 @@ __name(decomposeXrPose, "decomposeXrPose");
12497
12501
 
12498
12502
  // core/systems/XREnvironmentSystem.ts
12499
12503
  var DEBUG_COLOR = 5088255;
12504
+ var ROOM_CAPTURE_DELAY_SECONDS = 3;
12500
12505
  var _XREnvironmentSystem = class _XREnvironmentSystem {
12501
12506
  constructor() {
12502
12507
  /** Keyed by the XRPlane/XRMesh object itself — the runtime's own identity. */
@@ -12504,17 +12509,24 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12504
12509
  this.meshes = /* @__PURE__ */ new Map();
12505
12510
  this.presenting = false;
12506
12511
  this.debugVisible = false;
12512
+ /** Seconds presenting, used to decide the room has genuinely nothing in it. */
12513
+ this.elapsed = 0;
12514
+ /** `initiateRoomCapture` may be called only ONCE per session. */
12515
+ this.roomCaptureRequested = false;
12507
12516
  }
12508
- update(world) {
12517
+ update(world, deltaTime = 0) {
12509
12518
  const presenting = world.isXrPresenting();
12510
12519
  if (!presenting) {
12511
12520
  if (this.presenting) {
12512
12521
  this.presenting = false;
12522
+ this.elapsed = 0;
12523
+ this.roomCaptureRequested = false;
12513
12524
  this.clearAll(world);
12514
12525
  }
12515
12526
  return;
12516
12527
  }
12517
12528
  this.presenting = true;
12529
+ this.elapsed += deltaTime;
12518
12530
  const rig = this.findRig(world);
12519
12531
  const mode = rig?.environmentPhysics ?? "none";
12520
12532
  if (mode === "none") {
@@ -12543,6 +12555,34 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
12543
12555
  } else if (this.meshes.size > 0) {
12544
12556
  this.clearMap(world, this.meshes);
12545
12557
  }
12558
+ this.maybeCaptureRoom(world, frame);
12559
+ }
12560
+ /**
12561
+ * Ask the runtime to scan the room when it turns out we have no room.
12562
+ *
12563
+ * Plane and mesh detection do NOT scan anything — they replay a scan the user
12564
+ * captured earlier through the system's own room setup. Most people have
12565
+ * never run it, and those who have may have run it in a DIFFERENT room, so a
12566
+ * mixed-reality experience opens against either nothing at all or someone
12567
+ * else's furniture, with no prompt and no error.
12568
+ *
12569
+ * `XRSession.initiateRoomCapture()` (Meta's extension) launches that setup
12570
+ * from inside the session, and exists precisely for this case. Contract, from
12571
+ * Meta's docs: callable ONCE per session, and only worth calling once we are
12572
+ * confident the room really is empty — the arrays populate a beat after the
12573
+ * session starts, so a check on frame one would fire for everyone.
12574
+ */
12575
+ maybeCaptureRoom(world, frame) {
12576
+ if (this.roomCaptureRequested) return;
12577
+ if (this.elapsed < ROOM_CAPTURE_DELAY_SECONDS) return;
12578
+ if (this.planes.size > 0 || this.meshes.size > 0) return;
12579
+ if ((frame.detectedPlanes?.size ?? 0) > 0) return;
12580
+ if ((frame.detectedMeshes?.size ?? 0) > 0) return;
12581
+ const session = world.getRenderer().xr.getSession?.();
12582
+ if (typeof session?.initiateRoomCapture !== "function") return;
12583
+ this.roomCaptureRequested = true;
12584
+ session.initiateRoomCapture()?.catch(() => {
12585
+ });
12546
12586
  }
12547
12587
  /** How many pieces of real geometry are currently colliding. Script-facing. */
12548
12588
  getDetectedCount() {