@relu-interactives/spatial-ecs 0.3.7 → 0.3.9

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 (47) hide show
  1. package/dist/{System-TmC2EijF.d.cts → System-31wk-A0g.d.cts} +1 -1
  2. package/dist/{System-TmC2EijF.d.ts → System-31wk-A0g.d.ts} +1 -1
  3. package/dist/{XRInteractor-CCUzSqg9.d.ts → XRHandPose-CxPz8VBp.d.ts} +139 -20
  4. package/dist/{XRInteractor-I2q6TKtF.d.cts → XRHandPose-VuSbe4iz.d.cts} +139 -20
  5. package/dist/{chunk-FQTOC7HK.js → chunk-KIKIWCEB.js} +14 -5
  6. package/dist/chunk-KIKIWCEB.js.map +1 -0
  7. package/dist/{chunk-N4GKZ7IV.js → chunk-KVGGAL5V.js} +227 -3
  8. package/dist/chunk-KVGGAL5V.js.map +1 -0
  9. package/dist/{chunk-JRJ3CL5H.js → chunk-L5BP727H.js} +3 -3
  10. package/dist/{chunk-JRJ3CL5H.js.map → chunk-L5BP727H.js.map} +1 -1
  11. package/dist/{chunk-O6FJA4XW.js → chunk-W5TSYV6Q.js} +946 -50
  12. package/dist/chunk-W5TSYV6Q.js.map +1 -0
  13. package/dist/components/index.cjs +98 -0
  14. package/dist/components/index.cjs.map +1 -1
  15. package/dist/components/index.d.cts +4 -3
  16. package/dist/components/index.d.ts +4 -3
  17. package/dist/components/index.js +1 -1
  18. package/dist/docs/guide/vr-and-hand-poses.md +111 -0
  19. package/dist/factories/index.cjs +58 -0
  20. package/dist/factories/index.cjs.map +1 -1
  21. package/dist/factories/index.d.cts +6 -3
  22. package/dist/factories/index.d.ts +6 -3
  23. package/dist/factories/index.js +3 -3
  24. package/dist/handPose-dFxqbFFY.d.cts +240 -0
  25. package/dist/handPose-dFxqbFFY.d.ts +240 -0
  26. package/dist/{index-BltjPEhg.d.cts → index-BIQoaKAA.d.cts} +9 -3
  27. package/dist/{index-W8pUajCW.d.ts → index-BxdXgqiI.d.ts} +9 -3
  28. package/dist/{index-_q9TVDI5.d.ts → index-C7XQyrgE.d.ts} +79 -7
  29. package/dist/{index-B9nwRDf9.d.cts → index-DyN50JZo.d.cts} +79 -7
  30. package/dist/index.cjs +1205 -45
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +118 -9
  33. package/dist/index.d.ts +118 -9
  34. package/dist/index.js +8 -8
  35. package/dist/systems/index.cjs +1161 -45
  36. package/dist/systems/index.cjs.map +1 -1
  37. package/dist/systems/index.d.cts +3 -2
  38. package/dist/systems/index.d.ts +3 -2
  39. package/dist/systems/index.js +3 -3
  40. package/dist/types.d.cts +1 -1
  41. package/dist/types.d.ts +1 -1
  42. package/dist/worldSnapshotUtils.d.cts +1 -1
  43. package/dist/worldSnapshotUtils.d.ts +1 -1
  44. package/package.json +1 -1
  45. package/dist/chunk-FQTOC7HK.js.map +0 -1
  46. package/dist/chunk-N4GKZ7IV.js.map +0 -1
  47. package/dist/chunk-O6FJA4XW.js.map +0 -1
@@ -1,5 +1,5 @@
1
- import { resolveDracoDecoderPath, applyTransform, SHARED_MODEL_RESOURCE_FLAG } from './chunk-JRJ3CL5H.js';
2
- import { AnimationComponent, ModelComponent, CameraComponent, Object3DRef, Environment, Postprocessing, TransformComponent, PhysicsComponent, setPhysicsLiveRef, LightComponent, MaterialComponent, DEFAULT_MESH_GEOMETRY_PARAMETERS, MeshGeometryComponent, InstancedMeshComponent, ParentId, ScriptComponent, ScriptObjectComponent, XRRigComponent, VRTeleportAreaComponent, XRInteractableComponent, XRInteractorComponent, XRInteractorSideComponent, InstanceSlotComponent, Name, ImageComponent, SpriteComponent, VideoComponent, AudioComponent, ImageTarget, Selectable, PrefabNode, PrefabInstance } from './chunk-N4GKZ7IV.js';
1
+ import { resolveDracoDecoderPath, applyTransform, SHARED_MODEL_RESOURCE_FLAG } from './chunk-L5BP727H.js';
2
+ import { AnimationComponent, ModelComponent, CameraComponent, Object3DRef, Environment, Postprocessing, TransformComponent, PhysicsComponent, setPhysicsLiveRef, LightComponent, MaterialComponent, DEFAULT_MESH_GEOMETRY_PARAMETERS, MeshGeometryComponent, InstancedMeshComponent, ParentId, ScriptComponent, ScriptObjectComponent, XRRigComponent, VRTeleportAreaComponent, XRInteractableComponent, XRHandPoseComponent, createHandPoseEntry, XRInteractorComponent, XRInteractorSideComponent, InstanceSlotComponent, Name, ImageComponent, SpriteComponent, VideoComponent, AudioComponent, ImageTarget, Selectable, PrefabNode, PrefabInstance, handPoseDefinitionFor, getHandPosePreset } from './chunk-KVGGAL5V.js';
3
3
  import { __name } from './chunk-OY7ZODZP.js';
4
4
  import * as THREE7 from 'three';
5
5
  import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
@@ -2795,6 +2795,10 @@ function buildComponentValueMaps(snapshot) {
2795
2795
  snapshot.components,
2796
2796
  "XRInteractorComponent"
2797
2797
  ),
2798
+ xrHandPoseValues: readByComponent(
2799
+ snapshot.components,
2800
+ "XRHandPoseComponent"
2801
+ ),
2798
2802
  xrInteractorSideValues: readByComponent(
2799
2803
  snapshot.components,
2800
2804
  "XRInteractorSideComponent"
@@ -2908,13 +2912,9 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
2908
2912
  runtimeMessage: typeof entry.runtimeMessage === "string" ? entry.runtimeMessage : void 0
2909
2913
  };
2910
2914
  }).filter((entry) => entry.className || entry.scriptUrl) : [];
2911
- if (!entries.length) {
2912
- return;
2915
+ if (entries.length) {
2916
+ world.addComponent(targetEntityId, new ScriptComponent(entries));
2913
2917
  }
2914
- world.addComponent(
2915
- targetEntityId,
2916
- new ScriptComponent(entries)
2917
- );
2918
2918
  }
2919
2919
  const sourceScriptObject = asRecord(values.scriptObjectValues.get(sourceEntityId));
2920
2920
  if (sourceScriptObject) {
@@ -2963,10 +2963,14 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
2963
2963
  const sourceInteractable = asRecord(
2964
2964
  values.xrInteractableValues.get(sourceEntityId)
2965
2965
  );
2966
- const interactable = world.getComponent(
2966
+ let interactable = world.getComponent(
2967
2967
  targetEntityId,
2968
2968
  XRInteractableComponent
2969
2969
  );
2970
+ if (sourceInteractable && !interactable) {
2971
+ interactable = new XRInteractableComponent();
2972
+ world.addComponent(targetEntityId, interactable);
2973
+ }
2970
2974
  if (sourceInteractable && interactable) {
2971
2975
  if (typeof sourceInteractable.enabled === "boolean") {
2972
2976
  interactable.enabled = sourceInteractable.enabled;
@@ -2980,11 +2984,69 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
2980
2984
  if (typeof sourceInteractable.grabMode === "string") {
2981
2985
  interactable.grabMode = sourceInteractable.grabMode;
2982
2986
  }
2987
+ if (typeof sourceInteractable.grabReach === "string") {
2988
+ interactable.grabReach = sourceInteractable.grabReach;
2989
+ }
2990
+ if (typeof sourceInteractable.distanceBehavior === "string") {
2991
+ interactable.distanceBehavior = sourceInteractable.distanceBehavior;
2992
+ }
2993
+ if (typeof sourceInteractable.grabGesture === "string") {
2994
+ interactable.grabGesture = sourceInteractable.grabGesture;
2995
+ }
2996
+ if (typeof sourceInteractable.grabRadius === "number") {
2997
+ interactable.grabRadius = sourceInteractable.grabRadius;
2998
+ }
2999
+ if (typeof sourceInteractable.throwOnRelease === "boolean") {
3000
+ interactable.throwOnRelease = sourceInteractable.throwOnRelease;
3001
+ }
3002
+ if (typeof sourceInteractable.throwStrength === "number") {
3003
+ interactable.throwStrength = sourceInteractable.throwStrength;
3004
+ }
3005
+ const grabPos = asRecord(sourceInteractable.grabOffsetPosition);
3006
+ if (grabPos) {
3007
+ interactable.grabOffsetPosition = {
3008
+ x: typeof grabPos.x === "number" ? grabPos.x : 0,
3009
+ y: typeof grabPos.y === "number" ? grabPos.y : 0,
3010
+ z: typeof grabPos.z === "number" ? grabPos.z : 0
3011
+ };
3012
+ }
3013
+ const grabRot = asRecord(sourceInteractable.grabOffsetRotation);
3014
+ if (grabRot) {
3015
+ interactable.grabOffsetRotation = {
3016
+ x: typeof grabRot.x === "number" ? grabRot.x : 0,
3017
+ y: typeof grabRot.y === "number" ? grabRot.y : 0,
3018
+ z: typeof grabRot.z === "number" ? grabRot.z : 0
3019
+ };
3020
+ }
2983
3021
  if (typeof sourceInteractable.highlightOnHover === "boolean") {
2984
3022
  interactable.highlightOnHover = sourceInteractable.highlightOnHover;
2985
3023
  }
2986
3024
  }
2987
3025
  }
3026
+ {
3027
+ const sourceHandPose = asRecord(
3028
+ values.xrHandPoseValues.get(sourceEntityId)
3029
+ );
3030
+ let handPose = world.getComponent(targetEntityId, XRHandPoseComponent);
3031
+ if (sourceHandPose && !handPose) {
3032
+ handPose = new XRHandPoseComponent();
3033
+ world.addComponent(targetEntityId, handPose);
3034
+ }
3035
+ if (sourceHandPose && handPose) {
3036
+ if (typeof sourceHandPose.enabled === "boolean") {
3037
+ handPose.enabled = sourceHandPose.enabled;
3038
+ }
3039
+ if (Array.isArray(sourceHandPose.poses)) {
3040
+ handPose.poses = sourceHandPose.poses.map((entry) => asRecord(entry)).filter(
3041
+ (entry) => entry !== null && typeof entry.id === "string"
3042
+ ).map(
3043
+ (entry) => createHandPoseEntry(
3044
+ entry
3045
+ )
3046
+ );
3047
+ }
3048
+ }
3049
+ }
2988
3050
  {
2989
3051
  const sourceInteractor = asRecord(
2990
3052
  values.xrInteractorValues.get(sourceEntityId)
@@ -4914,6 +4976,17 @@ var _Behaviour = class _Behaviour {
4914
4976
  *
4915
4977
  * @param input - The source doing the grabbing.
4916
4978
  */
4979
+ /**
4980
+ * A hand pose declared on this entity started being held.
4981
+ *
4982
+ * Fires once the pose has been sustained for its hold time, not the instant
4983
+ * the fingers pass through it.
4984
+ */
4985
+ onHandPoseStart(_event) {
4986
+ }
4987
+ /** A held hand pose ended, or its hand stopped being tracked. */
4988
+ onHandPoseEnd(_event) {
4989
+ }
4917
4990
  onGrab(input) {
4918
4991
  }
4919
4992
  /** Called when this entity is released. Optional XR hook. */
@@ -6056,13 +6129,65 @@ function blankState(source) {
6056
6129
  squeezeEnd: false,
6057
6130
  axes: [],
6058
6131
  hasAxes: false,
6059
- buttons: []
6132
+ buttons: [],
6133
+ joints: null
6060
6134
  };
6061
6135
  }
6062
6136
  __name(blankState, "blankState");
6137
+ var JOINT_MATRIX_BUFFER = new Float32Array(25 * 16);
6138
+ function readHandJoints(frame, hand, referenceSpace, rigMatrix, existing) {
6139
+ const names = [];
6140
+ const spaces = [];
6141
+ for (const [name, space] of hand) {
6142
+ names.push(name);
6143
+ spaces.push(space);
6144
+ }
6145
+ if (spaces.length === 0) return null;
6146
+ const target = existing ?? {};
6147
+ const matrix = new THREE7.Matrix4();
6148
+ const buffer = spaces.length * 16 <= JOINT_MATRIX_BUFFER.length ? JOINT_MATRIX_BUFFER : new Float32Array(spaces.length * 16);
6149
+ const fillPoses = frame.fillPoses;
6150
+ if (typeof fillPoses === "function") {
6151
+ let filled = false;
6152
+ try {
6153
+ filled = fillPoses.call(frame, spaces, referenceSpace, buffer);
6154
+ } catch {
6155
+ filled = false;
6156
+ }
6157
+ if (filled) {
6158
+ for (let i = 0; i < names.length; i += 1) {
6159
+ matrix.fromArray(buffer, i * 16).premultiply(rigMatrix);
6160
+ const vector = target[names[i]] ?? new THREE7.Vector3();
6161
+ vector.setFromMatrixPosition(matrix);
6162
+ target[names[i]] = vector;
6163
+ }
6164
+ return target;
6165
+ }
6166
+ }
6167
+ let anyTracked = false;
6168
+ for (let i = 0; i < names.length; i += 1) {
6169
+ const pose = frame.getJointPose?.(
6170
+ spaces[i],
6171
+ referenceSpace
6172
+ );
6173
+ if (!pose) {
6174
+ target[names[i]] = void 0;
6175
+ continue;
6176
+ }
6177
+ matrix.fromArray(pose.transform.matrix).premultiply(rigMatrix);
6178
+ const vector = target[names[i]] ?? new THREE7.Vector3();
6179
+ vector.setFromMatrixPosition(matrix);
6180
+ target[names[i]] = vector;
6181
+ anyTracked = true;
6182
+ }
6183
+ return anyTracked ? target : null;
6184
+ }
6185
+ __name(readHandJoints, "readHandJoints");
6063
6186
  var _XRInputSystem = class _XRInputSystem {
6064
6187
  constructor() {
6065
6188
  this.records = /* @__PURE__ */ new Map();
6189
+ /** Reference-space → world matrix, rebuilt each frame from the XR rig. */
6190
+ this.referenceToWorld = new THREE7.Matrix4();
6066
6191
  this.session = null;
6067
6192
  this.onSelectStart = /* @__PURE__ */ __name((event) => this.mark(event, "pendingSelectStart"), "onSelectStart");
6068
6193
  this.onSelectEnd = /* @__PURE__ */ __name((event) => this.mark(event, "pendingSelectEnd"), "onSelectEnd");
@@ -6142,6 +6267,14 @@ var _XRInputSystem = class _XRInputSystem {
6142
6267
  const referenceSpace = renderer.xr.getReferenceSpace();
6143
6268
  const frame = renderer.xr.getFrame?.();
6144
6269
  const live = /* @__PURE__ */ new Set();
6270
+ const rigMatrix = this.referenceToWorld;
6271
+ const rig = world.getCamera().parent;
6272
+ if (rig) {
6273
+ rig.updateMatrixWorld();
6274
+ rigMatrix.copy(rig.matrixWorld);
6275
+ } else {
6276
+ rigMatrix.identity();
6277
+ }
6145
6278
  for (const source of session.inputSources) {
6146
6279
  live.add(source);
6147
6280
  const record = this.ensureRecord(source);
@@ -6152,7 +6285,7 @@ var _XRInputSystem = class _XRInputSystem {
6152
6285
  if (frame && referenceSpace && source.targetRaySpace) {
6153
6286
  const pose = frame.getPose(source.targetRaySpace, referenceSpace);
6154
6287
  if (pose) {
6155
- const m = new THREE7.Matrix4().fromArray(pose.transform.matrix);
6288
+ const m = new THREE7.Matrix4().fromArray(pose.transform.matrix).premultiply(rigMatrix);
6156
6289
  state.rayOrigin.setFromMatrixPosition(m);
6157
6290
  state.rayDirection.set(0, 0, -1).applyQuaternion(
6158
6291
  new THREE7.Quaternion().setFromRotationMatrix(m)
@@ -6162,7 +6295,7 @@ var _XRInputSystem = class _XRInputSystem {
6162
6295
  if (frame && referenceSpace && source.gripSpace) {
6163
6296
  const gripPose = frame.getPose(source.gripSpace, referenceSpace);
6164
6297
  if (gripPose) {
6165
- const m = new THREE7.Matrix4().fromArray(gripPose.transform.matrix);
6298
+ const m = new THREE7.Matrix4().fromArray(gripPose.transform.matrix).premultiply(rigMatrix);
6166
6299
  state.gripPosition ?? (state.gripPosition = new THREE7.Vector3());
6167
6300
  state.gripQuaternion ?? (state.gripQuaternion = new THREE7.Quaternion());
6168
6301
  state.gripPosition.setFromMatrixPosition(m);
@@ -6172,6 +6305,17 @@ var _XRInputSystem = class _XRInputSystem {
6172
6305
  state.gripPosition = null;
6173
6306
  state.gripQuaternion = null;
6174
6307
  }
6308
+ if (frame && referenceSpace && source.hand) {
6309
+ state.joints = readHandJoints(
6310
+ frame,
6311
+ source.hand,
6312
+ referenceSpace,
6313
+ rigMatrix,
6314
+ state.joints
6315
+ );
6316
+ } else {
6317
+ state.joints = null;
6318
+ }
6175
6319
  const gamepad = source.gamepad;
6176
6320
  if (gamepad) {
6177
6321
  state.axes = Array.from(gamepad.axes);
@@ -6236,20 +6380,86 @@ var _XRInputSystem = class _XRInputSystem {
6236
6380
  };
6237
6381
  __name(_XRInputSystem, "XRInputSystem");
6238
6382
  var XRInputSystem = _XRInputSystem;
6383
+ var VELOCITY_SAMPLES = 5;
6384
+ var MAX_SAMPLE_AGE_SECONDS = 0.2;
6385
+ var MAX_PLAUSIBLE_SPEED = 20;
6386
+ var _ThrowVelocityTracker = class _ThrowVelocityTracker {
6387
+ constructor() {
6388
+ this.samples = [];
6389
+ }
6390
+ /** Record where the hand is now. Call once per frame while held. */
6391
+ record(position, time) {
6392
+ this.samples.push({ position: position.clone(), time });
6393
+ if (this.samples.length > VELOCITY_SAMPLES) {
6394
+ this.samples.shift();
6395
+ }
6396
+ }
6397
+ /**
6398
+ * Velocity to release with, in metres/second.
6399
+ *
6400
+ * Uses the FASTEST segment inside the window rather than the most recent or
6401
+ * the window average. Users decelerate as they open their hand, so the last
6402
+ * segment understates the throw and the average washes it out; peak speed is
6403
+ * what matches the motion the user actually felt they made.
6404
+ */
6405
+ computeVelocity(now) {
6406
+ const fresh = this.samples.filter(
6407
+ (sample) => now - sample.time <= MAX_SAMPLE_AGE_SECONDS
6408
+ );
6409
+ if (fresh.length < 2) return new THREE7.Vector3();
6410
+ let best = new THREE7.Vector3();
6411
+ let bestSpeed = 0;
6412
+ for (let i = 1; i < fresh.length; i += 1) {
6413
+ const previous = fresh[i - 1];
6414
+ const current = fresh[i];
6415
+ const dt = current.time - previous.time;
6416
+ if (dt <= 1e-5) continue;
6417
+ const velocity = new THREE7.Vector3().subVectors(current.position, previous.position).divideScalar(dt);
6418
+ const speed = velocity.length();
6419
+ if (speed > MAX_PLAUSIBLE_SPEED) continue;
6420
+ if (speed > bestSpeed) {
6421
+ bestSpeed = speed;
6422
+ best = velocity;
6423
+ }
6424
+ }
6425
+ return best;
6426
+ }
6427
+ clear() {
6428
+ this.samples.length = 0;
6429
+ }
6430
+ };
6431
+ __name(_ThrowVelocityTracker, "ThrowVelocityTracker");
6432
+ var ThrowVelocityTracker = _ThrowVelocityTracker;
6433
+ function angularVelocityFrom(previous, current, deltaTime) {
6434
+ if (deltaTime <= 1e-5) return new THREE7.Vector3();
6435
+ const delta = current.clone().multiply(previous.clone().invert());
6436
+ if (delta.w < 0) {
6437
+ delta.set(-delta.x, -delta.y, -delta.z, -delta.w);
6438
+ }
6439
+ const angle = 2 * Math.acos(THREE7.MathUtils.clamp(delta.w, -1, 1));
6440
+ const sinHalf = Math.sqrt(Math.max(0, 1 - delta.w * delta.w));
6441
+ if (sinHalf < 1e-6 || angle < 1e-6) return new THREE7.Vector3();
6442
+ return new THREE7.Vector3(delta.x, delta.y, delta.z).divideScalar(sinHalf).multiplyScalar(angle / deltaTime);
6443
+ }
6444
+ __name(angularVelocityFrom, "angularVelocityFrom");
6445
+
6446
+ // core/systems/XRInteractionSystem.ts
6239
6447
  var RAYLESS_HOLD_DISTANCE = 0.5;
6240
6448
  var _XRInteractionSystem = class _XRInteractionSystem {
6241
6449
  constructor(inputSystem) {
6242
6450
  this.raycaster = new THREE7.Raycaster();
6243
- /** Which entity each source is holding. Keyed by source state identity. */
6244
6451
  this.grabs = /* @__PURE__ */ new Map();
6245
6452
  this.hoveredEntityId = null;
6453
+ /** Monotonic seconds, accumulated from deltaTime for velocity sampling. */
6454
+ this.elapsed = 0;
6246
6455
  this.inputSystem = inputSystem;
6247
6456
  }
6248
- update(world) {
6457
+ update(world, deltaTime) {
6249
6458
  if (!world.isXrPresenting()) {
6250
6459
  this.releaseAll(world);
6251
6460
  return;
6252
6461
  }
6462
+ this.elapsed += Math.max(0, deltaTime);
6253
6463
  const inputs = this.inputSystem.getInputs();
6254
6464
  const interactables = this.collectInteractables(world);
6255
6465
  this.hoveredEntityId = null;
@@ -6260,32 +6470,105 @@ var _XRInteractionSystem = class _XRInteractionSystem {
6260
6470
  }
6261
6471
  const held = this.grabs.get(input);
6262
6472
  if (held) {
6263
- this.moveHeld(world, input, held);
6264
- if (input.selectEnd) this.release(world, input);
6473
+ this.moveHeld(world, input, held, deltaTime);
6474
+ if (this.wantsRelease(world, input, held)) {
6475
+ this.release(world, input);
6476
+ }
6265
6477
  continue;
6266
6478
  }
6267
- const hitEntityId = this.raycast(input, interactables);
6268
- if (hitEntityId === null) continue;
6479
+ const candidate = this.findGrabCandidate(world, input, interactables);
6480
+ if (candidate === null) continue;
6269
6481
  if (this.hoveredEntityId === null) {
6270
- this.hoveredEntityId = hitEntityId;
6482
+ this.hoveredEntityId = candidate.entityId;
6271
6483
  }
6272
6484
  const interactable = world.getComponent(
6273
- hitEntityId,
6485
+ candidate.entityId,
6274
6486
  XRInteractableComponent
6275
6487
  );
6276
- if (input.selectStart && interactable?.grabbable) {
6277
- this.grab(world, input, hitEntityId);
6488
+ if (interactable?.grabbable && this.gestureMatches(
6489
+ input,
6490
+ interactable,
6491
+ /* starting */
6492
+ true
6493
+ )) {
6494
+ this.grab(world, input, candidate.entityId, candidate.viaDistance);
6495
+ }
6496
+ }
6497
+ }
6498
+ /** Whether the grab gesture for this object is currently starting. */
6499
+ gestureMatches(input, interactable, starting) {
6500
+ const pinch = starting ? input.selectStart : input.select;
6501
+ const full = starting ? input.squeezeStart : input.squeeze;
6502
+ switch (interactable.grabGesture) {
6503
+ case "pinch":
6504
+ return pinch;
6505
+ case "full":
6506
+ return full;
6507
+ default:
6508
+ return pinch || full;
6509
+ }
6510
+ }
6511
+ /** True once the gesture that started the grab has ended. */
6512
+ wantsRelease(world, input, held) {
6513
+ const interactable = world.getComponent(
6514
+ held.entityId,
6515
+ XRInteractableComponent
6516
+ );
6517
+ if (!interactable) return true;
6518
+ switch (interactable.grabGesture) {
6519
+ case "pinch":
6520
+ return input.selectEnd || !input.select;
6521
+ case "full":
6522
+ return input.squeezeEnd || !input.squeeze;
6523
+ default:
6524
+ return !input.select && !input.squeeze;
6525
+ }
6526
+ }
6527
+ /**
6528
+ * Find what this hand would grab: a physically-reachable object first, then
6529
+ * one along the pointer ray.
6530
+ *
6531
+ * Proximity wins over the ray so that reaching for something you are touching
6532
+ * never picks up a distant object behind it, which feels broken.
6533
+ */
6534
+ findGrabCandidate(world, input, interactables) {
6535
+ const hand = this.handPosition(input);
6536
+ let nearestId = null;
6537
+ let nearestDistance = Number.POSITIVE_INFINITY;
6538
+ for (const entry of interactables) {
6539
+ const interactable2 = world.getComponent(
6540
+ entry.entityId,
6541
+ XRInteractableComponent
6542
+ );
6543
+ if (!interactable2) continue;
6544
+ if (interactable2.grabReach === "distance") continue;
6545
+ const position = new THREE7.Vector3();
6546
+ entry.object.getWorldPosition(position);
6547
+ const distance = position.distanceTo(hand);
6548
+ if (distance <= interactable2.grabRadius && distance < nearestDistance) {
6549
+ nearestDistance = distance;
6550
+ nearestId = entry.entityId;
6278
6551
  }
6279
6552
  }
6553
+ if (nearestId !== null) {
6554
+ return { entityId: nearestId, viaDistance: false };
6555
+ }
6556
+ const hitId = this.raycast(input, interactables);
6557
+ if (hitId === null) return null;
6558
+ const interactable = world.getComponent(hitId, XRInteractableComponent);
6559
+ const viaDistanceAllowed = interactable?.grabReach === "distance" || interactable?.grabReach === "both";
6560
+ return { entityId: hitId, viaDistance: viaDistanceAllowed };
6280
6561
  }
6281
6562
  collectInteractables(world) {
6282
6563
  const out = [];
6283
6564
  for (const entityId of world.query(XRInteractableComponent, Object3DRef)) {
6284
6565
  const interactable = world.getComponent(entityId, XRInteractableComponent);
6285
- if (!interactable?.enabled || !interactable.pointable) continue;
6566
+ if (!interactable?.enabled) continue;
6286
6567
  if (!world.isEntityVisible(entityId)) continue;
6287
6568
  const object = world.getComponent(entityId, Object3DRef)?.object;
6288
- if (object) out.push({ entityId, object });
6569
+ if (object) {
6570
+ out.push({ entityId, object, pointable: interactable.pointable });
6571
+ }
6289
6572
  }
6290
6573
  return out;
6291
6574
  }
@@ -6293,9 +6576,11 @@ var _XRInteractionSystem = class _XRInteractionSystem {
6293
6576
  raycast(input, interactables) {
6294
6577
  if (interactables.length === 0) return null;
6295
6578
  this.raycaster.set(input.rayOrigin, input.rayDirection);
6579
+ this.raycaster.far = Number.POSITIVE_INFINITY;
6296
6580
  let nearestId = null;
6297
6581
  let nearestDistance = Number.POSITIVE_INFINITY;
6298
6582
  for (const entry of interactables) {
6583
+ if (!entry.pointable) continue;
6299
6584
  const hit = this.raycaster.intersectObject(entry.object, true)[0];
6300
6585
  if (!hit) continue;
6301
6586
  if (hit.distance < nearestDistance) {
@@ -6305,43 +6590,120 @@ var _XRInteractionSystem = class _XRInteractionSystem {
6305
6590
  }
6306
6591
  return nearestId;
6307
6592
  }
6308
- grab(world, input, entityId) {
6593
+ grab(world, input, entityId, viaDistance) {
6309
6594
  const transform = world.getComponent(entityId, TransformComponent);
6310
- if (!transform) return;
6595
+ const interactable = world.getComponent(entityId, XRInteractableComponent);
6596
+ if (!transform || !interactable) return;
6311
6597
  const hand = this.handPosition(input);
6312
- const offset = new THREE7.Vector3(
6313
- transform.position.x - hand.x,
6314
- transform.position.y - hand.y,
6315
- transform.position.z - hand.z
6316
- );
6598
+ const handQuaternion = this.handQuaternion(input);
6599
+ const remote = viaDistance && interactable.distanceBehavior === "remote";
6600
+ let offset;
6601
+ let remoteDistance = 0;
6602
+ if (remote) {
6603
+ const position = new THREE7.Vector3(
6604
+ transform.position.x,
6605
+ transform.position.y,
6606
+ transform.position.z
6607
+ );
6608
+ remoteDistance = position.distanceTo(input.rayOrigin);
6609
+ offset = new THREE7.Vector3();
6610
+ } else if (viaDistance) {
6611
+ offset = new THREE7.Vector3(
6612
+ interactable.grabOffsetPosition.x,
6613
+ interactable.grabOffsetPosition.y,
6614
+ interactable.grabOffsetPosition.z
6615
+ );
6616
+ } else {
6617
+ offset = new THREE7.Vector3(
6618
+ transform.position.x - hand.x,
6619
+ transform.position.y - hand.y,
6620
+ transform.position.z - hand.z
6621
+ );
6622
+ }
6317
6623
  const physics = world.getComponent(entityId, PhysicsComponent);
6318
6624
  let restoreBodyType = null;
6319
- const interactable = world.getComponent(entityId, XRInteractableComponent);
6320
- if (physics && interactable?.grabMode === "kinematic") {
6625
+ if (physics) {
6321
6626
  restoreBodyType = physics.values.rigidbody.type;
6322
6627
  physics.values.rigidbody.type = "kinematicPosition";
6323
6628
  }
6324
- this.grabs.set(input, { entityId, offset, restoreBodyType });
6629
+ const tracker = new ThrowVelocityTracker();
6630
+ tracker.record(hand, this.elapsed);
6631
+ this.grabs.set(input, {
6632
+ entityId,
6633
+ offset,
6634
+ rotationOffset: new THREE7.Quaternion().setFromEuler(
6635
+ new THREE7.Euler(
6636
+ interactable.grabOffsetRotation.x,
6637
+ interactable.grabOffsetRotation.y,
6638
+ interactable.grabOffsetRotation.z
6639
+ )
6640
+ ),
6641
+ remoteDistance,
6642
+ remote,
6643
+ restoreBodyType,
6644
+ tracker,
6645
+ lastHandQuaternion: handQuaternion.clone(),
6646
+ angularVelocity: new THREE7.Vector3()
6647
+ });
6325
6648
  this.dispatch(world, entityId, (b) => b.onGrab(input));
6326
6649
  }
6327
- moveHeld(world, input, held) {
6650
+ moveHeld(world, input, held, deltaTime) {
6328
6651
  const transform = world.getComponent(held.entityId, TransformComponent);
6329
6652
  if (!transform) return;
6330
6653
  const hand = this.handPosition(input);
6331
- transform.position.x = hand.x + held.offset.x;
6332
- transform.position.y = hand.y + held.offset.y;
6333
- transform.position.z = hand.z + held.offset.z;
6654
+ if (held.remote) {
6655
+ const target = new THREE7.Vector3().copy(input.rayDirection).multiplyScalar(held.remoteDistance).add(input.rayOrigin);
6656
+ transform.position.x = target.x;
6657
+ transform.position.y = target.y;
6658
+ transform.position.z = target.z;
6659
+ } else {
6660
+ transform.position.x = hand.x + held.offset.x;
6661
+ transform.position.y = hand.y + held.offset.y;
6662
+ transform.position.z = hand.z + held.offset.z;
6663
+ }
6664
+ held.tracker.record(
6665
+ held.remote ? new THREE7.Vector3(
6666
+ transform.position.x,
6667
+ transform.position.y,
6668
+ transform.position.z
6669
+ ) : hand,
6670
+ this.elapsed
6671
+ );
6672
+ const handQuaternion = this.handQuaternion(input);
6673
+ held.angularVelocity = angularVelocityFrom(
6674
+ held.lastHandQuaternion,
6675
+ handQuaternion,
6676
+ deltaTime
6677
+ );
6678
+ held.lastHandQuaternion = handQuaternion;
6334
6679
  }
6335
6680
  release(world, input) {
6336
6681
  const held = this.grabs.get(input);
6337
6682
  if (!held) return;
6338
6683
  this.grabs.delete(input);
6339
- if (held.restoreBodyType !== null) {
6340
- const physics = world.getComponent(held.entityId, PhysicsComponent);
6341
- if (physics) {
6342
- physics.values.rigidbody.type = held.restoreBodyType;
6684
+ const interactable = world.getComponent(
6685
+ held.entityId,
6686
+ XRInteractableComponent
6687
+ );
6688
+ const physics = world.getComponent(held.entityId, PhysicsComponent);
6689
+ if (held.restoreBodyType !== null && physics) {
6690
+ physics.values.rigidbody.type = held.restoreBodyType;
6691
+ const body = physics.rigidBody;
6692
+ const dynamic = held.restoreBodyType === "dynamic";
6693
+ if (body && dynamic && interactable?.throwOnRelease) {
6694
+ const velocity = held.tracker.computeVelocity(this.elapsed).multiplyScalar(interactable.throwStrength);
6695
+ try {
6696
+ body.setLinvel(
6697
+ { x: velocity.x, y: velocity.y, z: velocity.z },
6698
+ true
6699
+ );
6700
+ const spin = held.angularVelocity;
6701
+ body.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true);
6702
+ } catch {
6703
+ }
6343
6704
  }
6344
6705
  }
6706
+ held.tracker.clear();
6345
6707
  this.dispatch(world, held.entityId, (b) => b.onRelease(input));
6346
6708
  }
6347
6709
  releaseAll(world) {
@@ -6350,14 +6712,25 @@ var _XRInteractionSystem = class _XRInteractionSystem {
6350
6712
  }
6351
6713
  }
6352
6714
  /**
6353
- * Where the "hand" is for this source. Controllers and tracked hands report a
6354
- * grip pose; gaze and transient-pointer sources have none, so we hold the
6355
- * object a fixed distance along the ray instead.
6715
+ * Where the "hand" is. Controllers and tracked hands report a grip pose;
6716
+ * gaze and transient-pointer sources have none, so we hold the object a fixed
6717
+ * distance along the ray instead — which is what makes Vision Pro work.
6356
6718
  */
6357
6719
  handPosition(input) {
6358
6720
  if (input.gripPosition) return input.gripPosition;
6359
6721
  return new THREE7.Vector3().copy(input.rayDirection).multiplyScalar(RAYLESS_HOLD_DISTANCE).add(input.rayOrigin);
6360
6722
  }
6723
+ /**
6724
+ * Hand orientation. Grip-less sources (gaze / transient-pointer) have none,
6725
+ * so we derive one from the aim direction — enough for spin on release.
6726
+ */
6727
+ handQuaternion(input) {
6728
+ if (input.gripQuaternion) return input.gripQuaternion.clone();
6729
+ return new THREE7.Quaternion().setFromUnitVectors(
6730
+ new THREE7.Vector3(0, 0, -1),
6731
+ input.rayDirection.clone().normalize()
6732
+ );
6733
+ }
6361
6734
  dispatch(world, entityId, visit) {
6362
6735
  const script = world.getComponent(entityId, ScriptComponent);
6363
6736
  const instances = script?.scriptInstances;
@@ -6927,6 +7300,7 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
6927
7300
  this.handStyleSignature = "";
6928
7301
  this.handMaterial = null;
6929
7302
  this.interactionSystem = options?.interactionSystem ?? null;
7303
+ this.inputSystem = options?.inputSystem ?? null;
6930
7304
  this.group.name = "XRControllerVisuals";
6931
7305
  this.group.userData.ecsPreviewHidden = true;
6932
7306
  }
@@ -6943,8 +7317,11 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
6943
7317
  for (const { index, side } of SLOTS) {
6944
7318
  const visuals = this.slots.get(index);
6945
7319
  if (!visuals) continue;
6946
- visuals.controllerModel.visible = showControllers;
6947
- visuals.handModel.visible = showHands;
7320
+ const liveKind = this.liveKindFor(side);
7321
+ const hasController = liveKind === null || liveKind === "controller";
7322
+ const hasHand = liveKind === null || liveKind === "hand";
7323
+ visuals.controllerModel.visible = showControllers && hasController;
7324
+ visuals.handModel.visible = showHands && hasHand;
6948
7325
  visuals.ray.visible = this.shouldShowRay(interactor);
6949
7326
  if (showHands && this.handMaterial) {
6950
7327
  applyHandMaterial(visuals.handModel, this.handMaterial);
@@ -6952,6 +7329,20 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
6952
7329
  this.syncCustomContent(world, visuals, side);
6953
7330
  }
6954
7331
  }
7332
+ /**
7333
+ * What kind of source is currently driving this hand, or null when we cannot
7334
+ * tell (no input system wired) — in which case callers fall back to showing
7335
+ * everything rather than hiding the user's hands entirely.
7336
+ */
7337
+ liveKindFor(side) {
7338
+ if (!this.inputSystem) return null;
7339
+ const inputs = this.inputSystem.getInputs();
7340
+ if (inputs.length === 0) return null;
7341
+ for (const input of inputs) {
7342
+ if (input.handedness === side && input.connected) return input.kind;
7343
+ }
7344
+ return "none";
7345
+ }
6955
7346
  /** The scene's interactor config, if one was authored. */
6956
7347
  resolveInteractor(world) {
6957
7348
  const entityId = world.query(XRInteractorComponent)[0];
@@ -7211,7 +7602,512 @@ var _XRTeleportVisualSystem = class _XRTeleportVisualSystem {
7211
7602
  };
7212
7603
  __name(_XRTeleportVisualSystem, "XRTeleportVisualSystem");
7213
7604
  var XRTeleportVisualSystem = _XRTeleportVisualSystem;
7605
+ var XR_FINGERS = [
7606
+ "thumb",
7607
+ "index",
7608
+ "middle",
7609
+ "ring",
7610
+ "pinky"
7611
+ ];
7612
+ var PINCH_FINGERS = [
7613
+ "index",
7614
+ "middle",
7615
+ "ring",
7616
+ "pinky"
7617
+ ];
7618
+ var FINGER_CHAINS = {
7619
+ thumb: [
7620
+ "thumb-metacarpal",
7621
+ "thumb-phalanx-proximal",
7622
+ "thumb-phalanx-distal",
7623
+ "thumb-tip"
7624
+ ],
7625
+ index: [
7626
+ "index-finger-metacarpal",
7627
+ "index-finger-phalanx-proximal",
7628
+ "index-finger-phalanx-intermediate",
7629
+ "index-finger-phalanx-distal",
7630
+ "index-finger-tip"
7631
+ ],
7632
+ middle: [
7633
+ "middle-finger-metacarpal",
7634
+ "middle-finger-phalanx-proximal",
7635
+ "middle-finger-phalanx-intermediate",
7636
+ "middle-finger-phalanx-distal",
7637
+ "middle-finger-tip"
7638
+ ],
7639
+ ring: [
7640
+ "ring-finger-metacarpal",
7641
+ "ring-finger-phalanx-proximal",
7642
+ "ring-finger-phalanx-intermediate",
7643
+ "ring-finger-phalanx-distal",
7644
+ "ring-finger-tip"
7645
+ ],
7646
+ pinky: [
7647
+ "pinky-finger-metacarpal",
7648
+ "pinky-finger-phalanx-proximal",
7649
+ "pinky-finger-phalanx-intermediate",
7650
+ "pinky-finger-phalanx-distal",
7651
+ "pinky-finger-tip"
7652
+ ]
7653
+ };
7654
+ var TIP_JOINTS = {
7655
+ thumb: "thumb-tip",
7656
+ index: "index-finger-tip",
7657
+ middle: "middle-finger-tip",
7658
+ ring: "ring-finger-tip",
7659
+ pinky: "pinky-finger-tip"
7660
+ };
7661
+ var METACARPAL_JOINTS = {
7662
+ thumb: "thumb-metacarpal",
7663
+ index: "index-finger-metacarpal",
7664
+ middle: "middle-finger-metacarpal",
7665
+ ring: "ring-finger-metacarpal",
7666
+ pinky: "pinky-finger-metacarpal"
7667
+ };
7668
+ var FALLBACK_HAND_SCALE = 0.08;
7669
+ var STRAIGHT_COS = 0.95;
7670
+ var CURLED_COS = 0.55;
7671
+ function clamp01(value) {
7672
+ return value < 0 ? 0 : value > 1 ? 1 : value;
7673
+ }
7674
+ __name(clamp01, "clamp01");
7675
+ function boneDirections(joints, finger) {
7676
+ const chain = FINGER_CHAINS[finger];
7677
+ const directions = [];
7678
+ for (let i = 1; i < chain.length; i += 1) {
7679
+ const from = joints[chain[i - 1]];
7680
+ const to = joints[chain[i]];
7681
+ if (!from || !to) return null;
7682
+ const direction = new THREE7.Vector3().subVectors(to, from);
7683
+ if (direction.lengthSq() < 1e-12) return null;
7684
+ directions.push(direction.normalize());
7685
+ }
7686
+ return directions.length >= 2 ? directions : null;
7687
+ }
7688
+ __name(boneDirections, "boneDirections");
7689
+ function bendAmounts(directions) {
7690
+ const bends = [];
7691
+ for (let i = 1; i < directions.length; i += 1) {
7692
+ const cos = directions[i - 1].dot(directions[i]);
7693
+ const straightness = clamp01((cos - CURLED_COS) / (STRAIGHT_COS - CURLED_COS));
7694
+ bends.push(1 - straightness);
7695
+ }
7696
+ return bends;
7697
+ }
7698
+ __name(bendAmounts, "bendAmounts");
7699
+ function average(values) {
7700
+ if (values.length === 0) return 0;
7701
+ let sum = 0;
7702
+ for (const value of values) sum += value;
7703
+ return sum / values.length;
7704
+ }
7705
+ __name(average, "average");
7706
+ function computeFingerCurl(joints, finger) {
7707
+ const directions = boneDirections(joints, finger);
7708
+ if (!directions) return null;
7709
+ const bends = bendAmounts(directions);
7710
+ if (bends.length === 0) return null;
7711
+ return {
7712
+ baseCurl: bends[0],
7713
+ tipCurl: average(bends.slice(1)) || bends[bends.length - 1],
7714
+ curl: average(bends)
7715
+ };
7716
+ }
7717
+ __name(computeFingerCurl, "computeFingerCurl");
7718
+ function computePalmWidth(joints) {
7719
+ const index = joints["index-finger-metacarpal"];
7720
+ const pinky = joints["pinky-finger-metacarpal"];
7721
+ if (!index || !pinky) return null;
7722
+ const width = index.distanceTo(pinky);
7723
+ return width > 1e-6 ? width : null;
7724
+ }
7725
+ __name(computePalmWidth, "computePalmWidth");
7726
+ function estimateHandScale(joints) {
7727
+ const measurements = [];
7728
+ const width = computePalmWidth(joints);
7729
+ if (width !== null) measurements.push(width);
7730
+ const wrist = joints.wrist;
7731
+ const middleTip = joints["middle-finger-tip"];
7732
+ if (wrist && middleTip) {
7733
+ const span = wrist.distanceTo(middleTip);
7734
+ if (span > 1e-6) measurements.push(span * 0.5);
7735
+ }
7736
+ if (measurements.length === 0) return FALLBACK_HAND_SCALE;
7737
+ return average(measurements);
7738
+ }
7739
+ __name(estimateHandScale, "estimateHandScale");
7740
+ function computePinch(joints, finger) {
7741
+ const thumbTip = joints["thumb-tip"];
7742
+ const fingerTip = joints[TIP_JOINTS[finger]];
7743
+ if (!thumbTip || !fingerTip) return null;
7744
+ const scale = estimateHandScale(joints);
7745
+ const distance = thumbTip.distanceTo(fingerTip);
7746
+ return clamp01(1 - distance / (scale * 0.7));
7747
+ }
7748
+ __name(computePinch, "computePinch");
7749
+ function computeSpread(joints, a, b) {
7750
+ const aBase = joints[METACARPAL_JOINTS[a]];
7751
+ const aTip = joints[TIP_JOINTS[a]];
7752
+ const bBase = joints[METACARPAL_JOINTS[b]];
7753
+ const bTip = joints[TIP_JOINTS[b]];
7754
+ if (!aBase || !aTip || !bBase || !bTip) return null;
7755
+ const dirA = new THREE7.Vector3().subVectors(aTip, aBase);
7756
+ const dirB = new THREE7.Vector3().subVectors(bTip, bBase);
7757
+ if (dirA.lengthSq() < 1e-12 || dirB.lengthSq() < 1e-12) return null;
7758
+ return clamp01((1 - dirA.normalize().dot(dirB.normalize())) / 0.45);
7759
+ }
7760
+ __name(computeSpread, "computeSpread");
7761
+ function computePalmPose(joints, handedness) {
7762
+ const wrist = joints.wrist;
7763
+ const indexBase = joints["index-finger-metacarpal"];
7764
+ const pinkyBase = joints["pinky-finger-metacarpal"];
7765
+ if (!wrist || !indexBase || !pinkyBase) return null;
7766
+ const u = new THREE7.Vector3().subVectors(indexBase, wrist);
7767
+ const v = new THREE7.Vector3().subVectors(pinkyBase, wrist);
7768
+ const normal = new THREE7.Vector3().crossVectors(u, v);
7769
+ if (normal.lengthSq() < 1e-12) return null;
7770
+ normal.normalize();
7771
+ if (handedness === "left") normal.multiplyScalar(-1);
7772
+ const right = new THREE7.Vector3().subVectors(indexBase, pinkyBase);
7773
+ if (right.lengthSq() < 1e-12) return null;
7774
+ right.normalize();
7775
+ if (handedness === "left") right.multiplyScalar(-1);
7776
+ const up = new THREE7.Vector3().crossVectors(normal, right).normalize();
7777
+ const center = new THREE7.Vector3().add(wrist).add(indexBase).add(pinkyBase).divideScalar(3);
7778
+ return {
7779
+ center,
7780
+ normal,
7781
+ right,
7782
+ up,
7783
+ width: computePalmWidth(joints) ?? FALLBACK_HAND_SCALE
7784
+ };
7785
+ }
7786
+ __name(computePalmPose, "computePalmPose");
7787
+ function computeHandShape(joints, handedness) {
7788
+ const fingers = {};
7789
+ let tracked = 0;
7790
+ for (const finger of XR_FINGERS) {
7791
+ const curl = computeFingerCurl(joints, finger);
7792
+ if (curl) {
7793
+ fingers[finger] = curl;
7794
+ tracked += 1;
7795
+ } else {
7796
+ fingers[finger] = { baseCurl: 0, tipCurl: 0, curl: 0 };
7797
+ }
7798
+ }
7799
+ if (tracked === 0) return null;
7800
+ const pinch = {};
7801
+ for (const finger of PINCH_FINGERS) {
7802
+ pinch[finger] = computePinch(joints, finger) ?? 0;
7803
+ }
7804
+ const thumbBase = joints["thumb-metacarpal"];
7805
+ const thumbTip = joints["thumb-tip"];
7806
+ let thumbDirection = null;
7807
+ if (thumbBase && thumbTip) {
7808
+ const direction = new THREE7.Vector3().subVectors(thumbTip, thumbBase);
7809
+ if (direction.lengthSq() > 1e-12) thumbDirection = direction.normalize();
7810
+ }
7811
+ return {
7812
+ handedness,
7813
+ fingers,
7814
+ pinch,
7815
+ spread: {
7816
+ thumbIndex: computeSpread(joints, "thumb", "index") ?? 0,
7817
+ indexMiddle: computeSpread(joints, "index", "middle") ?? 0,
7818
+ middleRing: computeSpread(joints, "middle", "ring") ?? 0,
7819
+ ringPinky: computeSpread(joints, "ring", "pinky") ?? 0
7820
+ },
7821
+ palm: computePalmPose(joints, handedness),
7822
+ scale: estimateHandScale(joints),
7823
+ thumbDirection
7824
+ };
7825
+ }
7826
+ __name(computeHandShape, "computeHandShape");
7827
+ var MATCH_FALLOFF = 0.25;
7828
+ var NO_MATCH = {
7829
+ matched: false,
7830
+ confidence: 0,
7831
+ worstConstraint: null
7832
+ };
7833
+ function scoreAgainst(actual, target, tolerance) {
7834
+ const error = Math.abs(actual - target);
7835
+ if (error <= tolerance) return 1;
7836
+ return clamp01(1 - (error - tolerance) / MATCH_FALLOFF);
7837
+ }
7838
+ __name(scoreAgainst, "scoreAgainst");
7839
+ var WORLD_DIRECTIONS = {
7840
+ up: new THREE7.Vector3(0, 1, 0),
7841
+ down: new THREE7.Vector3(0, -1, 0),
7842
+ forward: new THREE7.Vector3(0, 0, -1),
7843
+ back: new THREE7.Vector3(0, 0, 1),
7844
+ left: new THREE7.Vector3(-1, 0, 0),
7845
+ right: new THREE7.Vector3(1, 0, 0)
7846
+ };
7847
+ function axisVector(shape, axis) {
7848
+ if (axis === "thumb") return shape.thumbDirection;
7849
+ if (!shape.palm) return null;
7850
+ return axis === "palm-normal" ? shape.palm.normal : shape.palm.up;
7851
+ }
7852
+ __name(axisVector, "axisVector");
7853
+ function resolveDirection(direction, shape, context) {
7854
+ if (direction !== "toward-head" && direction !== "away-from-head") {
7855
+ return WORLD_DIRECTIONS[direction];
7856
+ }
7857
+ const head = context?.headPosition;
7858
+ const origin = shape.palm?.center;
7859
+ if (!head || !origin) return null;
7860
+ const toHead = new THREE7.Vector3().subVectors(head, origin);
7861
+ if (toHead.lengthSq() < 1e-12) return null;
7862
+ toHead.normalize();
7863
+ return direction === "toward-head" ? toHead : toHead.multiplyScalar(-1);
7864
+ }
7865
+ __name(resolveDirection, "resolveDirection");
7866
+ function matchHandPose(shape, definition, context) {
7867
+ if (definition.hand && definition.hand !== shape.handedness) return NO_MATCH;
7868
+ let confidence = 1;
7869
+ let worstScore = 1;
7870
+ let worstConstraint = null;
7871
+ const consider = /* @__PURE__ */ __name((score, label) => {
7872
+ if (score < confidence) confidence = score;
7873
+ if (score < worstScore) {
7874
+ worstScore = score;
7875
+ worstConstraint = label;
7876
+ }
7877
+ }, "consider");
7878
+ for (const finger of XR_FINGERS) {
7879
+ const target = definition.fingers[finger];
7880
+ if (!target) continue;
7881
+ const actual = shape.fingers[finger];
7882
+ consider(scoreAgainst(actual.curl, target.curl, target.tolerance), finger);
7883
+ if (target.baseCurl !== void 0) {
7884
+ consider(
7885
+ scoreAgainst(actual.baseCurl, target.baseCurl, target.tolerance),
7886
+ `${finger} knuckle`
7887
+ );
7888
+ }
7889
+ if (target.tipCurl !== void 0) {
7890
+ consider(
7891
+ scoreAgainst(actual.tipCurl, target.tipCurl, target.tolerance),
7892
+ `${finger} tip`
7893
+ );
7894
+ }
7895
+ }
7896
+ if (definition.pinch) {
7897
+ const { finger, target, tolerance } = definition.pinch;
7898
+ consider(
7899
+ scoreAgainst(shape.pinch[finger], target, tolerance),
7900
+ `${finger} pinch`
7901
+ );
7902
+ }
7903
+ if (definition.orientation) {
7904
+ const { axis, direction, toleranceDegrees } = definition.orientation;
7905
+ const handAxis = axisVector(shape, axis);
7906
+ const wanted = resolveDirection(direction, shape, context);
7907
+ if (!handAxis || !wanted) {
7908
+ consider(0, "orientation");
7909
+ } else {
7910
+ const angle = THREE7.MathUtils.radToDeg(
7911
+ Math.acos(THREE7.MathUtils.clamp(handAxis.dot(wanted), -1, 1))
7912
+ );
7913
+ const normalizedError = Math.max(0, angle - toleranceDegrees) / 90;
7914
+ consider(clamp01(1 - normalizedError / MATCH_FALLOFF), "orientation");
7915
+ }
7916
+ }
7917
+ return {
7918
+ matched: confidence >= 1,
7919
+ confidence,
7920
+ worstConstraint: confidence >= 1 ? null : worstConstraint
7921
+ };
7922
+ }
7923
+ __name(matchHandPose, "matchHandPose");
7924
+ var DEFAULT_ENTER_CONFIDENCE = 0.8;
7925
+ var DEFAULT_EXIT_CONFIDENCE = 0.55;
7926
+ var DEFAULT_MIN_HOLD_SECONDS = 0.15;
7927
+ var DEFAULT_LOST_GRACE_SECONDS = 0.25;
7928
+ var _HandPoseTracker = class _HandPoseTracker {
7929
+ constructor(options) {
7930
+ this.active = false;
7931
+ this.holdingSince = null;
7932
+ this.lostSince = null;
7933
+ this.enterConfidence = options?.enterConfidence ?? DEFAULT_ENTER_CONFIDENCE;
7934
+ const exit = options?.exitConfidence ?? DEFAULT_EXIT_CONFIDENCE;
7935
+ this.exitConfidence = Math.min(exit, this.enterConfidence - 1e-3);
7936
+ this.minHoldSeconds = options?.minHoldSeconds ?? DEFAULT_MIN_HOLD_SECONDS;
7937
+ this.lostGraceSeconds = options?.lostGraceSeconds ?? DEFAULT_LOST_GRACE_SECONDS;
7938
+ }
7939
+ isActive() {
7940
+ return this.active;
7941
+ }
7942
+ /** Feed this frame's confidence. Returns an event on a transition. */
7943
+ update(confidence, now) {
7944
+ this.lostSince = null;
7945
+ if (this.active) {
7946
+ if (confidence < this.exitConfidence) {
7947
+ this.active = false;
7948
+ this.holdingSince = null;
7949
+ return "end";
7950
+ }
7951
+ return null;
7952
+ }
7953
+ if (confidence >= this.enterConfidence) {
7954
+ if (this.holdingSince === null) this.holdingSince = now;
7955
+ if (now - this.holdingSince >= this.minHoldSeconds) {
7956
+ this.active = true;
7957
+ return "start";
7958
+ }
7959
+ return null;
7960
+ }
7961
+ this.holdingSince = null;
7962
+ return null;
7963
+ }
7964
+ /** Feed a frame where the hand isn't tracked at all. */
7965
+ miss(now) {
7966
+ this.holdingSince = null;
7967
+ if (!this.active) return null;
7968
+ if (this.lostSince === null) this.lostSince = now;
7969
+ if (now - this.lostSince < this.lostGraceSeconds) return null;
7970
+ this.active = false;
7971
+ this.lostSince = null;
7972
+ return "end";
7973
+ }
7974
+ reset() {
7975
+ this.active = false;
7976
+ this.holdingSince = null;
7977
+ this.lostSince = null;
7978
+ }
7979
+ };
7980
+ __name(_HandPoseTracker, "HandPoseTracker");
7981
+ var HandPoseTracker = _HandPoseTracker;
7982
+
7983
+ // core/systems/XRHandPoseSystem.ts
7984
+ var _XRHandPoseSystem = class _XRHandPoseSystem {
7985
+ constructor(inputSystem) {
7986
+ this.trackers = /* @__PURE__ */ new Map();
7987
+ this.shapes = /* @__PURE__ */ new Map();
7988
+ /** Reused so head-relative orientation checks allocate nothing per frame. */
7989
+ this.headPosition = new THREE7.Vector3();
7990
+ this.elapsed = 0;
7991
+ this.inputSystem = inputSystem;
7992
+ }
7993
+ update(world, deltaTime) {
7994
+ if (!world.isXrPresenting()) {
7995
+ this.resetAll();
7996
+ return;
7997
+ }
7998
+ this.elapsed += deltaTime;
7999
+ this.refreshShapes();
8000
+ const entries = this.collectEntries(world);
8001
+ if (entries.length === 0) return;
8002
+ world.getCamera().getWorldPosition(this.headPosition);
8003
+ const context = { headPosition: this.headPosition };
8004
+ for (const { entityId, entry } of entries) {
8005
+ const definition = handPoseDefinitionFor(entry, getHandPosePreset);
8006
+ if (!definition) continue;
8007
+ for (const handedness of ["left", "right"]) {
8008
+ if (entry.hand && entry.hand !== handedness) continue;
8009
+ const key = `${entityId}:${entry.id}`;
8010
+ const trackers = this.trackersFor(handedness);
8011
+ let tracker = trackers.get(key);
8012
+ if (!tracker) {
8013
+ tracker = new HandPoseTracker({
8014
+ minHoldSeconds: entry.minHoldSeconds
8015
+ });
8016
+ trackers.set(key, tracker);
8017
+ }
8018
+ const shape = this.shapes.get(handedness) ?? null;
8019
+ if (!shape) {
8020
+ if (tracker.miss(this.elapsed) === "end") {
8021
+ this.dispatch(world, entityId, "end", {
8022
+ poseId: entry.id,
8023
+ poseName: entry.name,
8024
+ handedness,
8025
+ confidence: 0
8026
+ });
8027
+ }
8028
+ continue;
8029
+ }
8030
+ const match = matchHandPose(shape, definition, context);
8031
+ const event = tracker.update(match.confidence, this.elapsed);
8032
+ if (event) {
8033
+ this.dispatch(world, entityId, event, {
8034
+ poseId: entry.id,
8035
+ poseName: entry.name,
8036
+ handedness,
8037
+ confidence: match.confidence
8038
+ });
8039
+ }
8040
+ }
8041
+ }
8042
+ }
8043
+ /** Derive each hand's shape once for the whole frame. */
8044
+ refreshShapes() {
8045
+ for (const handedness of ["left", "right"]) {
8046
+ const input = this.inputSystem.getInput(handedness);
8047
+ const joints = input?.joints ?? null;
8048
+ this.shapes.set(
8049
+ handedness,
8050
+ joints ? computeHandShape(joints, handedness) : null
8051
+ );
8052
+ }
8053
+ }
8054
+ collectEntries(world) {
8055
+ const collected = [];
8056
+ for (const entityId of world.query(XRHandPoseComponent)) {
8057
+ const component = world.getComponent(entityId, XRHandPoseComponent);
8058
+ if (!component?.enabled) continue;
8059
+ for (const entry of component.poses) {
8060
+ if (entry.enabled) collected.push({ entityId, entry });
8061
+ }
8062
+ }
8063
+ return collected;
8064
+ }
8065
+ trackersFor(handedness) {
8066
+ let trackers = this.trackers.get(handedness);
8067
+ if (!trackers) {
8068
+ trackers = /* @__PURE__ */ new Map();
8069
+ this.trackers.set(handedness, trackers);
8070
+ }
8071
+ return trackers;
8072
+ }
8073
+ /**
8074
+ * Deliver to the entity's own Behaviours.
8075
+ *
8076
+ * Scoped to the entity that declared the pose rather than broadcast, so two
8077
+ * objects listening for the same gesture stay independent.
8078
+ */
8079
+ dispatch(world, entityId, event, detail) {
8080
+ const script = world.getComponent(entityId, ScriptComponent);
8081
+ const instances = script?.scriptInstances;
8082
+ if (!instances) return;
8083
+ for (const instance of instances.values()) {
8084
+ if (!(instance instanceof Behaviour)) continue;
8085
+ try {
8086
+ if (event === "start") instance.onHandPoseStart(detail);
8087
+ else instance.onHandPoseEnd(detail);
8088
+ } catch (error) {
8089
+ console.error("[XRHandPoseSystem] Behaviour hook failed:", error);
8090
+ }
8091
+ }
8092
+ }
8093
+ resetAll() {
8094
+ for (const trackers of this.trackers.values()) {
8095
+ for (const tracker of trackers.values()) tracker.reset();
8096
+ }
8097
+ this.shapes.clear();
8098
+ }
8099
+ /** Live shape for a hand — powers the editor's pose readout. */
8100
+ getHandShape(handedness) {
8101
+ return this.shapes.get(handedness) ?? null;
8102
+ }
8103
+ /** Whether a given entity's pose is currently held. */
8104
+ isPoseActive(entityId, poseId, handedness) {
8105
+ return this.trackers.get(handedness)?.get(`${entityId}:${poseId}`)?.isActive() ?? false;
8106
+ }
8107
+ };
8108
+ __name(_XRHandPoseSystem, "XRHandPoseSystem");
8109
+ var XRHandPoseSystem = _XRHandPoseSystem;
7214
8110
 
7215
- export { ADDED_NODE_MARKER_COMPONENT, ARC_GRAVITY, ARC_MAX_TIME, ARC_SPEED, ARC_STEPS, AnimationSystem, AudioSystem, Behaviour, CameraSyncSystem, EnvironmentSyncSystem, HYDRATABLE_ENTITY_KINDS, InputSystem, InstancedMeshSystem, KeyboardKeys, LightSyncSystem, MaterialSyncSystem, MeshGeometrySystem, NODE_KEY_SEPARATOR, NO_XR_CAPABILITIES, OVERRIDE_DIFF_EXCLUDED_COMPONENTS, OVERRIDE_ROOT_EXCLUDED_COMPONENTS, ObjectVisibilitySystem, PREFAB_FILE_VERSION, PREFAB_FILE_VERSION_NESTED, PhysicsSyncSystem, PostprocessingSyncSystem, RenderSystem, SCRIPT_OBJECT_ROOT_FLAG, ScriptBehaviourSystem, ScriptObject, ScriptObjectSystem, TransformSyncSystem, VRTeleportAreaSystem, VideoSystem, XRControllerVisualSystem, XRInputSystem, XRInteractionSystem, XRLocomotionSystem, XRSessionSystem, XRTeleportVisualSystem, applyHandMaterial, arcGroundCrossing, buildComponentValueMaps, buildNodeKeyMapsFromSnapshot, buildNodeMapsFromSnapshot, canonicalizeComponentValue, clearPrefabCache, collectDiffPaths, collectEntitySubtree, collectSnapshotSubtreeIds, computeInstanceOverrides, createHandMaterial, decanonicalizeComponentValue, despawnPrefab, encodeOverride, findSnapshotRootEntityIds, findTopLevelNestedRegions, groupOverridePaths, hydrateWorldFromSnapshot, hydrateWorldFromSnapshotDetailed, indexSnapshotByEntity, instantiateSubtree, loadPrefab, mergeComponentValue, parseOverride, parsePrefabAsset, prefabFileVersionFor, readXrCapabilities, restoreComponentState, restorePostCreateComponents, sampleTeleportArc, setPrefabAssetUrlResolver, spawnPrefab };
7216
- //# sourceMappingURL=chunk-O6FJA4XW.js.map
7217
- //# sourceMappingURL=chunk-O6FJA4XW.js.map
8111
+ export { ADDED_NODE_MARKER_COMPONENT, ARC_GRAVITY, ARC_MAX_TIME, ARC_SPEED, ARC_STEPS, AnimationSystem, AudioSystem, Behaviour, CURLED_COS, CameraSyncSystem, DEFAULT_ENTER_CONFIDENCE, DEFAULT_EXIT_CONFIDENCE, DEFAULT_LOST_GRACE_SECONDS, DEFAULT_MIN_HOLD_SECONDS, EnvironmentSyncSystem, FALLBACK_HAND_SCALE, HYDRATABLE_ENTITY_KINDS, HandPoseTracker, InputSystem, InstancedMeshSystem, KeyboardKeys, LightSyncSystem, MATCH_FALLOFF, MAX_PLAUSIBLE_SPEED, MAX_SAMPLE_AGE_SECONDS, MaterialSyncSystem, MeshGeometrySystem, NODE_KEY_SEPARATOR, NO_XR_CAPABILITIES, OVERRIDE_DIFF_EXCLUDED_COMPONENTS, OVERRIDE_ROOT_EXCLUDED_COMPONENTS, ObjectVisibilitySystem, PINCH_FINGERS, PREFAB_FILE_VERSION, PREFAB_FILE_VERSION_NESTED, PhysicsSyncSystem, PostprocessingSyncSystem, RenderSystem, SCRIPT_OBJECT_ROOT_FLAG, STRAIGHT_COS, ScriptBehaviourSystem, ScriptObject, ScriptObjectSystem, ThrowVelocityTracker, TransformSyncSystem, VELOCITY_SAMPLES, VRTeleportAreaSystem, VideoSystem, XRControllerVisualSystem, XRHandPoseSystem, XRInputSystem, XRInteractionSystem, XRLocomotionSystem, XRSessionSystem, XRTeleportVisualSystem, XR_FINGERS, angularVelocityFrom, applyHandMaterial, arcGroundCrossing, buildComponentValueMaps, buildNodeKeyMapsFromSnapshot, buildNodeMapsFromSnapshot, canonicalizeComponentValue, clearPrefabCache, collectDiffPaths, collectEntitySubtree, collectSnapshotSubtreeIds, computeFingerCurl, computeHandShape, computeInstanceOverrides, computePalmPose, computePalmWidth, computePinch, computeSpread, createHandMaterial, decanonicalizeComponentValue, despawnPrefab, encodeOverride, estimateHandScale, findSnapshotRootEntityIds, findTopLevelNestedRegions, groupOverridePaths, hydrateWorldFromSnapshot, hydrateWorldFromSnapshotDetailed, indexSnapshotByEntity, instantiateSubtree, loadPrefab, matchHandPose, mergeComponentValue, parseOverride, parsePrefabAsset, prefabFileVersionFor, readXrCapabilities, restoreComponentState, restorePostCreateComponents, sampleTeleportArc, setPrefabAssetUrlResolver, spawnPrefab };
8112
+ //# sourceMappingURL=chunk-W5TSYV6Q.js.map
8113
+ //# sourceMappingURL=chunk-W5TSYV6Q.js.map