@relu-interactives/spatial-ecs 0.3.5 → 0.3.7

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.
Files changed (44) hide show
  1. package/dist/{System-vrU46Vuw.d.cts → System-TmC2EijF.d.cts} +2 -2
  2. package/dist/{System-vrU46Vuw.d.ts → System-TmC2EijF.d.ts} +2 -2
  3. package/dist/{XRRig-2686IqLQ.d.cts → XRInteractor-CCUzSqg9.d.ts} +96 -2
  4. package/dist/{XRRig-D2QURXl1.d.ts → XRInteractor-I2q6TKtF.d.cts} +96 -2
  5. package/dist/{chunk-4BZYK3JP.js → chunk-FQTOC7HK.js} +135 -88
  6. package/dist/chunk-FQTOC7HK.js.map +1 -0
  7. package/dist/{chunk-2LVFGVWI.js → chunk-JRJ3CL5H.js} +3 -3
  8. package/dist/{chunk-2LVFGVWI.js.map → chunk-JRJ3CL5H.js.map} +1 -1
  9. package/dist/{chunk-6QC2PJLD.js → chunk-N4GKZ7IV.js} +29 -3
  10. package/dist/chunk-N4GKZ7IV.js.map +1 -0
  11. package/dist/{chunk-G53MZVKQ.js → chunk-O6FJA4XW.js} +367 -53
  12. package/dist/chunk-O6FJA4XW.js.map +1 -0
  13. package/dist/components/index.cjs +26 -0
  14. package/dist/components/index.cjs.map +1 -1
  15. package/dist/components/index.d.cts +3 -3
  16. package/dist/components/index.d.ts +3 -3
  17. package/dist/components/index.js +1 -1
  18. package/dist/factories/index.cjs +185 -112
  19. package/dist/factories/index.cjs.map +1 -1
  20. package/dist/factories/index.d.cts +7 -3
  21. package/dist/factories/index.d.ts +7 -3
  22. package/dist/factories/index.js +3 -3
  23. package/dist/{index-BvCmmuJ0.d.cts → index-B9nwRDf9.d.cts} +65 -14
  24. package/dist/{index-BtKOaHfY.d.ts → index-BltjPEhg.d.cts} +10 -3
  25. package/dist/{index-Xu238jLn.d.cts → index-W8pUajCW.d.ts} +10 -3
  26. package/dist/{index-B-CViRMx.d.ts → index-_q9TVDI5.d.ts} +65 -14
  27. package/dist/index.cjs +684 -293
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +35 -9
  30. package/dist/index.d.ts +35 -9
  31. package/dist/index.js +8 -8
  32. package/dist/systems/index.cjs +386 -48
  33. package/dist/systems/index.cjs.map +1 -1
  34. package/dist/systems/index.d.cts +2 -2
  35. package/dist/systems/index.d.ts +2 -2
  36. package/dist/systems/index.js +3 -3
  37. package/dist/types.d.cts +1 -1
  38. package/dist/types.d.ts +1 -1
  39. package/dist/worldSnapshotUtils.d.cts +1 -1
  40. package/dist/worldSnapshotUtils.d.ts +1 -1
  41. package/package.json +1 -1
  42. package/dist/chunk-4BZYK3JP.js.map +0 -1
  43. package/dist/chunk-6QC2PJLD.js.map +0 -1
  44. package/dist/chunk-G53MZVKQ.js.map +0 -1
@@ -2109,6 +2109,7 @@ var XRInteractableComponent = _XRInteractableComponent;
2109
2109
  var _XRRigComponent = class _XRRigComponent extends Component {
2110
2110
  constructor(options) {
2111
2111
  super();
2112
+ this.trackingOrigin = options?.trackingOrigin ?? "floor";
2112
2113
  this.locomotion = options?.locomotion ?? "teleport";
2113
2114
  this.moveSpeed = options?.moveSpeed ?? 2;
2114
2115
  this.snapTurn = options?.snapTurn ?? true;
@@ -2119,6 +2120,29 @@ var _XRRigComponent = class _XRRigComponent extends Component {
2119
2120
  __name(_XRRigComponent, "XRRigComponent");
2120
2121
  var XRRigComponent = _XRRigComponent;
2121
2122
 
2123
+ // core/components/XRInteractor.ts
2124
+ var _XRInteractorComponent = class _XRInteractorComponent extends Component {
2125
+ constructor(options) {
2126
+ super();
2127
+ this.interactorType = options?.interactorType ?? "both";
2128
+ this.handPreset = options?.handPreset ?? "default";
2129
+ this.handColor = options?.handColor ?? "#e8b48a";
2130
+ this.handOpacity = options?.handOpacity ?? 1;
2131
+ this.handEmissiveIntensity = options?.handEmissiveIntensity ?? 0;
2132
+ this.showRay = options?.showRay ?? true;
2133
+ }
2134
+ };
2135
+ __name(_XRInteractorComponent, "XRInteractorComponent");
2136
+ var XRInteractorComponent = _XRInteractorComponent;
2137
+ var _XRInteractorSideComponent = class _XRInteractorSideComponent extends Component {
2138
+ constructor(options) {
2139
+ super();
2140
+ this.side = options?.side ?? "left";
2141
+ }
2142
+ };
2143
+ __name(_XRInteractorSideComponent, "XRInteractorSideComponent");
2144
+ var XRInteractorSideComponent = _XRInteractorSideComponent;
2145
+
2122
2146
  // core/utils/dracoDecoderPath.ts
2123
2147
  var LOCAL_DRACO_DECODER_RELATIVE_PATH = "./assets/draco/";
2124
2148
  var DRACO_DECODER_CDN_PATH = "https://www.gstatic.com/draco/versioned/decoders/1.5.7/";
@@ -4913,6 +4937,14 @@ function buildComponentValueMaps(snapshot) {
4913
4937
  snapshot.components,
4914
4938
  "XRInteractableComponent"
4915
4939
  ),
4940
+ xrInteractorValues: readByComponent(
4941
+ snapshot.components,
4942
+ "XRInteractorComponent"
4943
+ ),
4944
+ xrInteractorSideValues: readByComponent(
4945
+ snapshot.components,
4946
+ "XRInteractorSideComponent"
4947
+ ),
4916
4948
  instancedMeshValues: readByComponent(snapshot.components, "InstancedMeshComponent"),
4917
4949
  instanceSlotValues: readByComponent(snapshot.components, "InstanceSlotComponent"),
4918
4950
  prefabNodeValues: readByComponent(snapshot.components, "PrefabNode"),
@@ -5099,6 +5131,60 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
5099
5131
  }
5100
5132
  }
5101
5133
  }
5134
+ {
5135
+ const sourceInteractor = asRecord3(
5136
+ values.xrInteractorValues.get(sourceEntityId)
5137
+ );
5138
+ const interactor = world.getComponent(targetEntityId, XRInteractorComponent);
5139
+ if (sourceInteractor && interactor) {
5140
+ if (typeof sourceInteractor.interactorType === "string") {
5141
+ interactor.interactorType = sourceInteractor.interactorType;
5142
+ }
5143
+ if (typeof sourceInteractor.handPreset === "string") {
5144
+ interactor.handPreset = sourceInteractor.handPreset;
5145
+ }
5146
+ if (typeof sourceInteractor.handColor === "string") {
5147
+ interactor.handColor = sourceInteractor.handColor;
5148
+ }
5149
+ if (typeof sourceInteractor.handOpacity === "number") {
5150
+ interactor.handOpacity = sourceInteractor.handOpacity;
5151
+ }
5152
+ if (typeof sourceInteractor.handEmissiveIntensity === "number") {
5153
+ interactor.handEmissiveIntensity = sourceInteractor.handEmissiveIntensity;
5154
+ }
5155
+ if (typeof sourceInteractor.showRay === "boolean") {
5156
+ interactor.showRay = sourceInteractor.showRay;
5157
+ }
5158
+ }
5159
+ }
5160
+ {
5161
+ const sourceSide = asRecord3(
5162
+ values.xrInteractorSideValues.get(sourceEntityId)
5163
+ );
5164
+ if (sourceSide && typeof sourceSide.side === "string") {
5165
+ const existing = world.getComponent(
5166
+ targetEntityId,
5167
+ XRInteractorSideComponent
5168
+ );
5169
+ if (existing) {
5170
+ existing.side = sourceSide.side;
5171
+ } else {
5172
+ world.addComponent(
5173
+ targetEntityId,
5174
+ new XRInteractorSideComponent({
5175
+ side: sourceSide.side
5176
+ })
5177
+ );
5178
+ }
5179
+ }
5180
+ }
5181
+ {
5182
+ const sourceRigOrigin = asRecord3(values.xrRigValues.get(sourceEntityId));
5183
+ const rigForOrigin = world.getComponent(targetEntityId, XRRigComponent);
5184
+ if (sourceRigOrigin && rigForOrigin && typeof sourceRigOrigin.trackingOrigin === "string") {
5185
+ rigForOrigin.trackingOrigin = sourceRigOrigin.trackingOrigin;
5186
+ }
5187
+ }
5102
5188
  {
5103
5189
  const imComponent = world.getComponent(targetEntityId, InstancedMeshComponent);
5104
5190
  if (imComponent && imComponent.instanceEntityIds.length > 0) {
@@ -5688,6 +5774,7 @@ var HYDRATABLE_ENTITY_KINDS = /* @__PURE__ */ new Set([
5688
5774
  "instanceSlot",
5689
5775
  "scriptObject",
5690
5776
  "xrRig",
5777
+ "xrInteractor",
5691
5778
  "teleportArea"
5692
5779
  ]);
5693
5780
 
@@ -7698,6 +7785,9 @@ var _XRInputSystem = class _XRInputSystem {
7698
7785
  state.hasAxes = false;
7699
7786
  state.buttons = [];
7700
7787
  }
7788
+ }
7789
+ for (const record of this.records.values()) {
7790
+ const state = record.state;
7701
7791
  if (record.pendingSelectStart) {
7702
7792
  state.select = true;
7703
7793
  state.selectStart = true;
@@ -7933,6 +8023,10 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
7933
8023
  /** Curve up to the landing point, for the visual system. Empty when not aiming. */
7934
8024
  this.aimCurve = [];
7935
8025
  this.landingPoint = null;
8026
+ /** True when the current aim lands somewhere the player may actually stand. */
8027
+ this.aimValid = false;
8028
+ /** Sources currently aiming via thumbstick (hysteresis, so it doesn't chatter). */
8029
+ this.stickAiming = /* @__PURE__ */ new WeakSet();
7936
8030
  /** True once we've tried to self-heal camera parenting for this session. */
7937
8031
  this.parentingChecked = false;
7938
8032
  this.inputSystem = inputSystem;
@@ -7940,6 +8034,7 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
7940
8034
  update(world, deltaTime) {
7941
8035
  this.aimCurve = [];
7942
8036
  this.landingPoint = null;
8037
+ this.aimValid = false;
7943
8038
  if (!world.isXrPresenting()) {
7944
8039
  this.parentingChecked = false;
7945
8040
  return;
@@ -7947,6 +8042,7 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
7947
8042
  if (!this.parentingChecked) {
7948
8043
  this.parentingChecked = true;
7949
8044
  this.ensureCameraUnderRig(world);
8045
+ this.warnOnIncompleteSetup(world);
7950
8046
  }
7951
8047
  const rigEntityId = world.query(XRRigComponent, TransformComponent)[0];
7952
8048
  if (typeof rigEntityId !== "number") return;
@@ -7964,6 +8060,26 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
7964
8060
  this.applySmoothMove(world, rig, rigTransform, inputs, deltaTime);
7965
8061
  }
7966
8062
  }
8063
+ /**
8064
+ * Name what is missing, once per session.
8065
+ *
8066
+ * Inside a headset there is no console and no inspector, so a scene that
8067
+ * simply cannot move gives the author nothing to go on. These messages are
8068
+ * visible over remote debugging and say exactly which entity to add.
8069
+ */
8070
+ warnOnIncompleteSetup(world) {
8071
+ if (typeof world.query(XRRigComponent)[0] !== "number") {
8072
+ console.warn(
8073
+ "[XRLocomotion] No XR Rig in this scene \u2014 the player cannot move. Add one via Add Object \u2192 VR \u2192 XR Rig (the scene camera is parented to it automatically)."
8074
+ );
8075
+ return;
8076
+ }
8077
+ if (world.query(VRTeleportAreaComponent).length === 0) {
8078
+ console.warn(
8079
+ "[XRLocomotion] No Teleport Area in this scene \u2014 teleport has nowhere to land. Add one via Add Object \u2192 VR \u2192 Teleport Area, or add a Teleport Area component to an existing floor."
8080
+ );
8081
+ }
8082
+ }
7967
8083
  /**
7968
8084
  * Guarantee the render camera is a CHILD of the rig.
7969
8085
  *
@@ -8031,29 +8147,60 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
8031
8147
  }
8032
8148
  applyTeleport(world, transform, inputs) {
8033
8149
  const targets = this.collectTeleportTargets(world);
8034
- if (targets.length === 0) return;
8035
8150
  for (const input of inputs) {
8036
- if (!input.select && !input.selectEnd) continue;
8151
+ const source = input;
8152
+ const stick = this.readStickAim(input, source);
8153
+ const aiming = input.select || stick.aiming;
8154
+ const commit = input.selectEnd || stick.released;
8155
+ if (!aiming && !commit) continue;
8037
8156
  const points = sampleTeleportArc(
8038
8157
  input.rayOrigin,
8039
8158
  input.rayDirection,
8040
8159
  this.arcPoints
8041
8160
  );
8042
8161
  const hit = this.raycastArc(points, targets);
8043
- if (!hit) continue;
8044
- this.landingPoint = hit.point;
8045
- this.aimCurve = points.slice(0, hit.segmentIndex + 1).concat(hit.point);
8046
- if (input.selectEnd) {
8162
+ this.aimCurve = hit ? points.slice(0, hit.segmentIndex + 1).concat(hit.point) : points.slice();
8163
+ this.landingPoint = hit ? hit.point : null;
8164
+ this.aimValid = hit !== null;
8165
+ if (commit && hit) {
8047
8166
  transform.position.x = hit.point.x;
8048
8167
  transform.position.y = hit.point.y;
8049
8168
  transform.position.z = hit.point.z;
8050
8169
  this.aimCurve = [];
8051
8170
  this.landingPoint = null;
8171
+ this.aimValid = false;
8172
+ } else if (commit) {
8173
+ this.aimCurve = [];
8174
+ this.aimValid = false;
8052
8175
  }
8053
8176
  }
8054
8177
  }
8178
+ /**
8179
+ * Thumbstick-forward aiming with hysteresis.
8180
+ *
8181
+ * Engaging at 0.6 and releasing at 0.3 stops a stick resting near the
8182
+ * threshold from flickering the arc on and off — and the release edge is what
8183
+ * commits the teleport, so chatter there would teleport the player randomly.
8184
+ */
8185
+ readStickAim(input, source) {
8186
+ if (!input.hasAxes || input.axes.length < 4) {
8187
+ return { aiming: false, released: false };
8188
+ }
8189
+ const forward = -(input.axes[3] ?? 0);
8190
+ const wasAiming = this.stickAiming.has(source);
8191
+ if (!wasAiming && forward > 0.6) {
8192
+ this.stickAiming.add(source);
8193
+ return { aiming: true, released: false };
8194
+ }
8195
+ if (wasAiming && forward < 0.3) {
8196
+ this.stickAiming.delete(source);
8197
+ return { aiming: false, released: true };
8198
+ }
8199
+ return { aiming: wasAiming, released: false };
8200
+ }
8055
8201
  /** Walk the arc segment-by-segment, returning the first surface hit. */
8056
8202
  raycastArc(points, targets) {
8203
+ if (targets.length === 0) return null;
8057
8204
  for (let i = 1; i < points.length; i += 1) {
8058
8205
  const from = points[i - 1];
8059
8206
  const to = points[i];
@@ -8084,6 +8231,10 @@ var _XRLocomotionSystem = class _XRLocomotionSystem {
8084
8231
  getAimCurve() {
8085
8232
  return this.aimCurve;
8086
8233
  }
8234
+ /** True when the aim lands on a valid teleport area. */
8235
+ isAimValid() {
8236
+ return this.aimValid;
8237
+ }
8087
8238
  /** Where the arc currently lands, or null when not aiming at a valid area. */
8088
8239
  getLandingPoint() {
8089
8240
  return this.landingPoint;
@@ -8126,6 +8277,8 @@ var _XRSessionSystem = class _XRSessionSystem {
8126
8277
  constructor(inputSystem) {
8127
8278
  this.presenting = false;
8128
8279
  this.capabilities = NO_XR_CAPABILITIES;
8280
+ /** Y compensation applied when a floor-relative space was requested but not granted. */
8281
+ this.appliedOriginOffset = 0;
8129
8282
  this.inputSystem = inputSystem ?? null;
8130
8283
  }
8131
8284
  update(world) {
@@ -8149,11 +8302,59 @@ var _XRSessionSystem = class _XRSessionSystem {
8149
8302
  if (session && this.inputSystem) {
8150
8303
  this.inputSystem.attachSession(session);
8151
8304
  }
8305
+ this.applyTrackingOrigin(world, session ?? null);
8152
8306
  this.forEachBehaviour(world, (behaviour) => {
8153
8307
  behaviour.onXRSessionStart(this.capabilities);
8154
8308
  });
8155
8309
  }
8310
+ /**
8311
+ * Apply the rig's authored tracking origin, and compensate when the device
8312
+ * cannot honour it.
8313
+ *
8314
+ * `floor` (`local-floor`) puts Y=0 on the user's real floor, which is what a
8315
+ * scene authored standing on the ground expects. `eye` (`local`) puts Y=0 at
8316
+ * the headset, for seated experiences.
8317
+ *
8318
+ * The compensation matters: `local-floor` is only ever REQUESTED as an
8319
+ * optional feature (requiring it would make `requestSession` reject outright
8320
+ * on devices that lack it). When it isn't granted, the runtime silently falls
8321
+ * back to `local` — the origin jumps to head height and a floor-authored
8322
+ * scene swallows the user, which is exactly the "I'm standing under the
8323
+ * building" symptom. Offsetting the rig down by the eye height restores the
8324
+ * intended floor relationship instead of leaving it broken.
8325
+ */
8326
+ applyTrackingOrigin(world, session) {
8327
+ const rigEntityId = world.query(XRRigComponent, TransformComponent)[0];
8328
+ if (typeof rigEntityId !== "number") return;
8329
+ const rig = world.getComponent(rigEntityId, XRRigComponent);
8330
+ const transform = world.getComponent(rigEntityId, TransformComponent);
8331
+ if (!rig || !transform) return;
8332
+ const renderer = world.getRenderer();
8333
+ const wantsFloor = rig.trackingOrigin === "floor";
8334
+ try {
8335
+ renderer.xr.setReferenceSpaceType(wantsFloor ? "local-floor" : "local");
8336
+ } catch {
8337
+ }
8338
+ const granted = session?.enabledFeatures;
8339
+ const floorGranted = Array.isArray(granted) ? granted.includes("local-floor") : (
8340
+ // No report at all: trust the request, since a wrong offset is worse
8341
+ // than none on devices that did honour it.
8342
+ true
8343
+ );
8344
+ this.appliedOriginOffset = wantsFloor && !floorGranted ? rig.fallbackEyeHeight : 0;
8345
+ if (this.appliedOriginOffset !== 0) {
8346
+ transform.position.y -= this.appliedOriginOffset;
8347
+ }
8348
+ }
8156
8349
  handleSessionEnd(world) {
8350
+ if (this.appliedOriginOffset !== 0) {
8351
+ const rigEntityId = world.query(XRRigComponent, TransformComponent)[0];
8352
+ if (typeof rigEntityId === "number") {
8353
+ const transform = world.getComponent(rigEntityId, TransformComponent);
8354
+ if (transform) transform.position.y += this.appliedOriginOffset;
8355
+ }
8356
+ this.appliedOriginOffset = 0;
8357
+ }
8157
8358
  this.capabilities = NO_XR_CAPABILITIES;
8158
8359
  this.inputSystem?.detachSession();
8159
8360
  this.forEachBehaviour(world, (behaviour) => {
@@ -8247,17 +8448,74 @@ var _VRTeleportAreaSystem = class _VRTeleportAreaSystem {
8247
8448
  };
8248
8449
  __name(_VRTeleportAreaSystem, "VRTeleportAreaSystem");
8249
8450
  var VRTeleportAreaSystem = _VRTeleportAreaSystem;
8451
+ function createHandMaterial(preset, options) {
8452
+ const color = new THREE14__namespace.Color(options.color);
8453
+ switch (preset) {
8454
+ case "ghost":
8455
+ return new THREE14__namespace.MeshBasicMaterial({
8456
+ color,
8457
+ transparent: true,
8458
+ opacity: Math.min(1, Math.max(0.05, options.opacity * 0.45)),
8459
+ depthWrite: false,
8460
+ side: THREE14__namespace.DoubleSide
8461
+ });
8462
+ case "neon":
8463
+ return new THREE14__namespace.MeshStandardMaterial({
8464
+ color,
8465
+ emissive: color,
8466
+ emissiveIntensity: Math.max(0.5, options.emissiveIntensity || 2),
8467
+ roughness: 0.35,
8468
+ metalness: 0,
8469
+ transparent: options.opacity < 1,
8470
+ opacity: options.opacity
8471
+ });
8472
+ case "skeleton":
8473
+ return new THREE14__namespace.MeshBasicMaterial({
8474
+ color,
8475
+ wireframe: true,
8476
+ transparent: true,
8477
+ opacity: options.opacity
8478
+ });
8479
+ case "custom":
8480
+ case "default":
8481
+ return new THREE14__namespace.MeshStandardMaterial({
8482
+ color,
8483
+ roughness: 0.85,
8484
+ metalness: 0,
8485
+ emissive: color,
8486
+ emissiveIntensity: Math.max(0, options.emissiveIntensity),
8487
+ transparent: options.opacity < 1,
8488
+ opacity: options.opacity,
8489
+ side: THREE14__namespace.DoubleSide
8490
+ });
8491
+ default: {
8492
+ return new THREE14__namespace.MeshStandardMaterial({ color });
8493
+ }
8494
+ }
8495
+ }
8496
+ __name(createHandMaterial, "createHandMaterial");
8497
+ function applyHandMaterial(root, material) {
8498
+ root.traverse((child) => {
8499
+ const mesh = child;
8500
+ if (!mesh.isMesh) return;
8501
+ mesh.material = material;
8502
+ });
8503
+ }
8504
+ __name(applyHandMaterial, "applyHandMaterial");
8505
+ var SLOTS = [
8506
+ { index: 0, side: "left" },
8507
+ { index: 1, side: "right" }
8508
+ ];
8250
8509
  var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8251
8510
  constructor(options) {
8252
8511
  this.controllerModelFactory = new XRControllerModelFactory_js.XRControllerModelFactory();
8253
8512
  this.handModelFactory = new XRHandModelFactory_js.XRHandModelFactory();
8254
8513
  this.group = new THREE14__namespace.Group();
8255
- this.built = false;
8514
+ this.slots = /* @__PURE__ */ new Map();
8256
8515
  this.attachedTo = null;
8257
- /** Index → whether we've already attached visuals for that slot. */
8258
- this.ready = /* @__PURE__ */ new Set();
8259
- this.rays = [];
8260
- this.slots = options?.slots ?? 2;
8516
+ /** Last applied preset signature, so materials rebuild only on real change. */
8517
+ this.handStyleSignature = "";
8518
+ this.handMaterial = null;
8261
8519
  this.interactionSystem = options?.interactionSystem ?? null;
8262
8520
  this.group.name = "XRControllerVisuals";
8263
8521
  this.group.userData.ecsPreviewHidden = true;
@@ -8266,39 +8524,111 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8266
8524
  const renderer = world.getRenderer();
8267
8525
  if (!renderer.xr?.enabled) return;
8268
8526
  this.ensureAttached(world);
8269
- this.updateRayVisibility();
8270
- if (this.built) return;
8271
- for (let index = 0; index < this.slots; index += 1) {
8272
- if (this.ready.has(index)) continue;
8527
+ this.ensureSlots(world);
8528
+ const interactor = this.resolveInteractor(world);
8529
+ const type = interactor?.interactorType ?? "both";
8530
+ const showControllers = type === "controllers" || type === "both";
8531
+ const showHands = type === "hands" || type === "both";
8532
+ this.syncHandMaterial(interactor);
8533
+ for (const { index, side } of SLOTS) {
8534
+ const visuals = this.slots.get(index);
8535
+ if (!visuals) continue;
8536
+ visuals.controllerModel.visible = showControllers;
8537
+ visuals.handModel.visible = showHands;
8538
+ visuals.ray.visible = this.shouldShowRay(interactor);
8539
+ if (showHands && this.handMaterial) {
8540
+ applyHandMaterial(visuals.handModel, this.handMaterial);
8541
+ }
8542
+ this.syncCustomContent(world, visuals, side);
8543
+ }
8544
+ }
8545
+ /** The scene's interactor config, if one was authored. */
8546
+ resolveInteractor(world) {
8547
+ const entityId = world.query(XRInteractorComponent)[0];
8548
+ if (typeof entityId !== "number") return null;
8549
+ return world.getComponent(entityId, XRInteractorComponent) ?? null;
8550
+ }
8551
+ /**
8552
+ * Re-parent the author's left/right interactor content onto the tracked grip
8553
+ * space so it follows the hand.
8554
+ *
8555
+ * `ObjectManagementSystem` reconciles ECS objects back under their ParentId
8556
+ * every frame, so this re-attach has to be re-asserted — the same coexistence
8557
+ * dance the AR anchor system performs.
8558
+ */
8559
+ syncCustomContent(world, visuals, side) {
8560
+ let content = null;
8561
+ for (const entityId of world.query(
8562
+ XRInteractorSideComponent,
8563
+ Object3DRef
8564
+ )) {
8565
+ const slot = world.getComponent(entityId, XRInteractorSideComponent);
8566
+ if (slot?.side !== side) continue;
8567
+ content = world.getComponent(entityId, Object3DRef)?.object ?? null;
8568
+ break;
8569
+ }
8570
+ if (!content) {
8571
+ visuals.attachedCustom = null;
8572
+ return;
8573
+ }
8574
+ if (content.parent !== visuals.grip) {
8575
+ visuals.grip.add(content);
8576
+ }
8577
+ visuals.attachedCustom = content;
8578
+ }
8579
+ /** Rebuild the hand material only when the authored style actually changes. */
8580
+ syncHandMaterial(interactor) {
8581
+ const preset = interactor?.handPreset ?? "default";
8582
+ const color = interactor?.handColor ?? "#e8b48a";
8583
+ const opacity = interactor?.handOpacity ?? 1;
8584
+ const emissive = interactor?.handEmissiveIntensity ?? 0;
8585
+ const signature = `${preset}:${color}:${opacity}:${emissive}`;
8586
+ if (signature === this.handStyleSignature && this.handMaterial) return;
8587
+ this.handStyleSignature = signature;
8588
+ this.handMaterial?.dispose();
8589
+ this.handMaterial = createHandMaterial(preset, {
8590
+ color,
8591
+ opacity,
8592
+ emissiveIntensity: emissive
8593
+ });
8594
+ }
8595
+ /**
8596
+ * The ray is meaningful only when it points at something. Authors can turn it
8597
+ * off entirely; otherwise it appears on hover/grab so it isn't permanent
8598
+ * clutter implying the whole scene is clickable.
8599
+ */
8600
+ shouldShowRay(interactor) {
8601
+ if (interactor && !interactor.showRay) return false;
8602
+ if (!this.interactionSystem) return true;
8603
+ return this.interactionSystem.getHoveredEntityId() !== null || this.interactionSystem.isGrabbing();
8604
+ }
8605
+ /** Lazily build per-slot visuals; the device reports profiles late. */
8606
+ ensureSlots(world) {
8607
+ const renderer = world.getRenderer();
8608
+ for (const { index } of SLOTS) {
8609
+ if (this.slots.has(index)) continue;
8273
8610
  const controller = renderer.xr.getController(index);
8274
8611
  const grip = renderer.xr.getControllerGrip(index);
8275
8612
  const hand = renderer.xr.getHand(index);
8276
8613
  if (!controller || !grip || !hand) continue;
8277
8614
  const ray = this.createRay();
8278
- this.rays.push(ray);
8279
8615
  controller.add(ray);
8280
- this.group.add(controller);
8281
- grip.add(this.controllerModelFactory.createControllerModel(grip));
8282
- this.group.add(grip);
8283
- hand.add(this.handModelFactory.createHandModel(hand, "mesh"));
8284
- this.group.add(hand);
8285
- this.ready.add(index);
8286
- }
8287
- if (this.ready.size >= this.slots) {
8288
- this.built = true;
8616
+ const controllerModel = this.controllerModelFactory.createControllerModel(grip);
8617
+ grip.add(controllerModel);
8618
+ const handModel = this.handModelFactory.createHandModel(hand, "mesh");
8619
+ hand.add(handModel);
8620
+ this.group.add(controller, grip, hand);
8621
+ this.slots.set(index, {
8622
+ controller,
8623
+ grip,
8624
+ hand,
8625
+ ray,
8626
+ controllerModel,
8627
+ handModel,
8628
+ attachedCustom: null
8629
+ });
8289
8630
  }
8290
8631
  }
8291
- /**
8292
- * Show the aim ray only when it is meaningful: hovering something
8293
- * interactive, or mid-grab. A permanently-drawn ray is visual noise, and it
8294
- * implies everything is clickable when most of the scene is not. Teleport
8295
- * aiming has its own arc, so the straight ray stays hidden for that.
8296
- */
8297
- updateRayVisibility() {
8298
- if (!this.interactionSystem) return;
8299
- const active = this.interactionSystem.getHoveredEntityId() !== null || this.interactionSystem.isGrabbing();
8300
- for (const ray of this.rays) ray.visible = active;
8301
- }
8302
8632
  /**
8303
8633
  * Parent the visuals to the XR rig when there is one, so locomotion moves the
8304
8634
  * hands with the player. Falls back to the scene root.
@@ -8324,7 +8654,7 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8324
8654
  const line = new THREE14__namespace.Line(geometry, material);
8325
8655
  line.name = "XRPointerRay";
8326
8656
  line.renderOrder = 999;
8327
- line.visible = this.interactionSystem === null;
8657
+ line.visible = false;
8328
8658
  return line;
8329
8659
  }
8330
8660
  dispose() {
@@ -8340,9 +8670,10 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8340
8670
  }
8341
8671
  });
8342
8672
  this.group.clear();
8343
- this.rays.length = 0;
8344
- this.ready.clear();
8345
- this.built = false;
8673
+ this.slots.clear();
8674
+ this.handMaterial?.dispose();
8675
+ this.handMaterial = null;
8676
+ this.handStyleSignature = "";
8346
8677
  this.attachedTo = null;
8347
8678
  }
8348
8679
  };
@@ -8350,6 +8681,7 @@ __name(_XRControllerVisualSystem, "XRControllerVisualSystem");
8350
8681
  var XRControllerVisualSystem = _XRControllerVisualSystem;
8351
8682
  var MARKER_RADIUS = 0.28;
8352
8683
  var ACCENT = new THREE14__namespace.Color("#4da3ff");
8684
+ var BLOCKED = new THREE14__namespace.Color("#ff5a5a");
8353
8685
  var _XRTeleportVisualSystem = class _XRTeleportVisualSystem {
8354
8686
  constructor(locomotionSystem) {
8355
8687
  this.group = new THREE14__namespace.Group();
@@ -8380,16 +8712,22 @@ var _XRTeleportVisualSystem = class _XRTeleportVisualSystem {
8380
8712
  }
8381
8713
  const curve = this.locomotionSystem.getAimCurve();
8382
8714
  const landing = this.locomotionSystem.getLandingPoint();
8383
- if (!landing || curve.length < 2) {
8715
+ const valid = this.locomotionSystem.isAimValid();
8716
+ if (curve.length < 2) {
8384
8717
  this.setVisible(false);
8385
8718
  return;
8386
8719
  }
8387
- this.updateArcGeometry(curve);
8388
- this.marker.position.copy(landing);
8389
- this.marker.position.y += 0.01;
8720
+ this.updateArcGeometry(curve, valid ? ACCENT : BLOCKED);
8721
+ if (valid && landing) {
8722
+ this.marker.position.copy(landing);
8723
+ this.marker.position.y += 0.01;
8724
+ this.marker.visible = true;
8725
+ } else {
8726
+ this.marker.visible = false;
8727
+ }
8390
8728
  this.setVisible(true);
8391
8729
  }
8392
- updateArcGeometry(curve) {
8730
+ updateArcGeometry(curve, tint) {
8393
8731
  const count = curve.length;
8394
8732
  const positions = new Float32Array(count * 3);
8395
8733
  const colors = new Float32Array(count * 3);
@@ -8400,9 +8738,9 @@ var _XRTeleportVisualSystem = class _XRTeleportVisualSystem {
8400
8738
  positions[i * 3 + 2] = point.z;
8401
8739
  const t = count > 1 ? i / (count - 1) : 0;
8402
8740
  const fade = 1 - t * 0.75;
8403
- colors[i * 3] = ACCENT.r * fade;
8404
- colors[i * 3 + 1] = ACCENT.g * fade;
8405
- colors[i * 3 + 2] = ACCENT.b * fade;
8741
+ colors[i * 3] = tint.r * fade;
8742
+ colors[i * 3 + 1] = tint.g * fade;
8743
+ colors[i * 3 + 2] = tint.b * fade;
8406
8744
  }
8407
8745
  this.arcGeometry.setAttribute(
8408
8746
  "position",