@relu-interactives/spatial-ecs 0.3.7 → 0.3.8

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-LMTB4KQ4.js} +929 -42
  12. package/dist/chunk-LMTB4KQ4.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 +1187 -37
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +104 -9
  33. package/dist/index.d.ts +104 -9
  34. package/dist/index.js +8 -8
  35. package/dist/systems/index.cjs +1144 -37
  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
@@ -2099,6 +2099,22 @@ var _XRInteractableComponent = class _XRInteractableComponent extends Component
2099
2099
  this.grabbable = options?.grabbable ?? true;
2100
2100
  this.pointable = options?.pointable ?? true;
2101
2101
  this.grabMode = options?.grabMode ?? "kinematic";
2102
+ this.grabReach = options?.grabReach ?? "both";
2103
+ this.distanceBehavior = options?.distanceBehavior ?? "snap";
2104
+ this.grabGesture = options?.grabGesture ?? "any";
2105
+ this.grabRadius = options?.grabRadius ?? 0.15;
2106
+ this.grabOffsetPosition = options?.grabOffsetPosition ?? {
2107
+ x: 0,
2108
+ y: 0,
2109
+ z: 0
2110
+ };
2111
+ this.grabOffsetRotation = options?.grabOffsetRotation ?? {
2112
+ x: 0,
2113
+ y: 0,
2114
+ z: 0
2115
+ };
2116
+ this.throwOnRelease = options?.throwOnRelease ?? true;
2117
+ this.throwStrength = options?.throwStrength ?? 1;
2102
2118
  this.highlightOnHover = options?.highlightOnHover ?? true;
2103
2119
  }
2104
2120
  };
@@ -2143,6 +2159,209 @@ var _XRInteractorSideComponent = class _XRInteractorSideComponent extends Compon
2143
2159
  __name(_XRInteractorSideComponent, "XRInteractorSideComponent");
2144
2160
  var XRInteractorSideComponent = _XRInteractorSideComponent;
2145
2161
 
2162
+ // core/xr/handPosePresets.ts
2163
+ var WIDE = 0.35;
2164
+ var NARROW = 0.25;
2165
+ var EXTENDED = { curl: 0.05, tolerance: WIDE };
2166
+ var CURLED = { curl: 0.95, tolerance: WIDE };
2167
+ var THUMB_TUCKED = { curl: 0.7, tolerance: NARROW + 0.1 };
2168
+ var THUMB_RELAXED = { curl: 0.6, tolerance: 0.4 };
2169
+ function fingers(thumb, index, middle, ring, pinky) {
2170
+ return { thumb, index, middle, ring, pinky };
2171
+ }
2172
+ __name(fingers, "fingers");
2173
+ var THUMB_UP = {
2174
+ axis: "thumb",
2175
+ direction: "up",
2176
+ toleranceDegrees: 45
2177
+ };
2178
+ var THUMB_DOWN = {
2179
+ axis: "thumb",
2180
+ direction: "down",
2181
+ toleranceDegrees: 45
2182
+ };
2183
+ var HAND_POSE_PRESETS = {
2184
+ fist: {
2185
+ id: "fist",
2186
+ name: "Fist",
2187
+ fingers: fingers(THUMB_TUCKED, CURLED, CURLED, CURLED, CURLED)
2188
+ },
2189
+ "open-palm": {
2190
+ id: "open-palm",
2191
+ name: "Open Palm",
2192
+ fingers: fingers(EXTENDED, EXTENDED, EXTENDED, EXTENDED, EXTENDED)
2193
+ },
2194
+ point: {
2195
+ id: "point",
2196
+ name: "Point",
2197
+ fingers: fingers(THUMB_RELAXED, EXTENDED, CURLED, CURLED, CURLED)
2198
+ },
2199
+ "thumbs-up": {
2200
+ id: "thumbs-up",
2201
+ name: "Thumbs Up",
2202
+ fingers: fingers(EXTENDED, CURLED, CURLED, CURLED, CURLED),
2203
+ orientation: THUMB_UP
2204
+ },
2205
+ "thumbs-down": {
2206
+ id: "thumbs-down",
2207
+ name: "Thumbs Down",
2208
+ fingers: fingers(EXTENDED, CURLED, CURLED, CURLED, CURLED),
2209
+ orientation: THUMB_DOWN
2210
+ },
2211
+ peace: {
2212
+ id: "peace",
2213
+ name: "Peace",
2214
+ fingers: fingers(THUMB_RELAXED, EXTENDED, EXTENDED, CURLED, CURLED)
2215
+ },
2216
+ pinch: {
2217
+ id: "pinch",
2218
+ name: "Pinch",
2219
+ // Curl is left loose: what defines a pinch is the tips MEETING, and people
2220
+ // pinch with wildly different amounts of finger bend.
2221
+ fingers: fingers(
2222
+ { curl: 0.5, tolerance: 0.5 },
2223
+ { curl: 0.5, tolerance: 0.5 },
2224
+ { curl: 0.5, tolerance: 0.5 },
2225
+ { curl: 0.5, tolerance: 0.5 },
2226
+ { curl: 0.5, tolerance: 0.5 }
2227
+ ),
2228
+ pinch: { finger: "index", target: 1, tolerance: 0.25 }
2229
+ },
2230
+ ok: {
2231
+ id: "ok",
2232
+ name: "OK Sign",
2233
+ // Thumb and index meet in a ring while the rest stay up — that contrast with
2234
+ // a plain pinch is the whole gesture, so the other three are constrained.
2235
+ fingers: fingers(
2236
+ { curl: 0.5, tolerance: 0.4 },
2237
+ { curl: 0.55, tolerance: 0.35 },
2238
+ EXTENDED,
2239
+ EXTENDED,
2240
+ EXTENDED
2241
+ ),
2242
+ pinch: { finger: "index", target: 1, tolerance: 0.3 }
2243
+ },
2244
+ gun: {
2245
+ id: "gun",
2246
+ name: "Gun",
2247
+ fingers: fingers(EXTENDED, EXTENDED, CURLED, CURLED, CURLED)
2248
+ },
2249
+ rock: {
2250
+ id: "rock",
2251
+ name: "Rock Horns",
2252
+ fingers: fingers(THUMB_TUCKED, EXTENDED, CURLED, CURLED, EXTENDED)
2253
+ },
2254
+ shaka: {
2255
+ id: "shaka",
2256
+ name: "Shaka",
2257
+ fingers: fingers(EXTENDED, CURLED, CURLED, CURLED, EXTENDED)
2258
+ },
2259
+ "palm-up": {
2260
+ id: "palm-up",
2261
+ name: "Palm Up",
2262
+ fingers: fingers(EXTENDED, EXTENDED, EXTENDED, EXTENDED, EXTENDED),
2263
+ orientation: {
2264
+ axis: "palm-normal",
2265
+ direction: "up",
2266
+ toleranceDegrees: 40
2267
+ }
2268
+ }
2269
+ };
2270
+ function getHandPosePreset(id) {
2271
+ const preset = HAND_POSE_PRESETS[id];
2272
+ if (!preset) return null;
2273
+ return cloneHandPose(preset);
2274
+ }
2275
+ __name(getHandPosePreset, "getHandPosePreset");
2276
+ function cloneHandPose(definition) {
2277
+ return {
2278
+ ...definition,
2279
+ fingers: {
2280
+ thumb: { ...definition.fingers.thumb },
2281
+ index: { ...definition.fingers.index },
2282
+ middle: { ...definition.fingers.middle },
2283
+ ring: { ...definition.fingers.ring },
2284
+ pinky: { ...definition.fingers.pinky }
2285
+ },
2286
+ pinch: definition.pinch ? { ...definition.pinch } : void 0,
2287
+ orientation: definition.orientation ? { ...definition.orientation } : void 0
2288
+ };
2289
+ }
2290
+ __name(cloneHandPose, "cloneHandPose");
2291
+ function handPoseFromCurls(id, name, curls, options) {
2292
+ const tolerance = options?.tolerance ?? NARROW;
2293
+ const target = /* @__PURE__ */ __name((curl) => ({
2294
+ curl: Math.min(1, Math.max(0, curl)),
2295
+ tolerance
2296
+ }), "target");
2297
+ return {
2298
+ id,
2299
+ name,
2300
+ fingers: {
2301
+ thumb: target(curls.thumb),
2302
+ index: target(curls.index),
2303
+ middle: target(curls.middle),
2304
+ ring: target(curls.ring),
2305
+ pinky: target(curls.pinky)
2306
+ },
2307
+ pinch: options?.pinch,
2308
+ orientation: options?.orientation,
2309
+ hand: options?.hand
2310
+ };
2311
+ }
2312
+ __name(handPoseFromCurls, "handPoseFromCurls");
2313
+
2314
+ // core/components/XRHandPose.ts
2315
+ var DEFAULT_POSE_CURLS = {
2316
+ thumb: 0,
2317
+ index: 0,
2318
+ middle: 0,
2319
+ ring: 0,
2320
+ pinky: 0
2321
+ };
2322
+ function createHandPoseEntry(partial) {
2323
+ return {
2324
+ id: partial.id,
2325
+ name: partial.name ?? partial.id,
2326
+ preset: partial.preset ?? null,
2327
+ curls: { ...DEFAULT_POSE_CURLS, ...partial.curls },
2328
+ tolerance: partial.tolerance ?? 0.25,
2329
+ hand: partial.hand ?? null,
2330
+ orientation: partial.orientation ?? null,
2331
+ minHoldSeconds: partial.minHoldSeconds ?? 0.15,
2332
+ enabled: partial.enabled ?? true
2333
+ };
2334
+ }
2335
+ __name(createHandPoseEntry, "createHandPoseEntry");
2336
+ function handPoseDefinitionFor(entry, resolvePreset) {
2337
+ if (entry.preset) {
2338
+ const preset = resolvePreset(entry.preset);
2339
+ if (!preset) return null;
2340
+ return {
2341
+ ...preset,
2342
+ id: entry.id,
2343
+ name: entry.name,
2344
+ hand: entry.hand ?? preset.hand,
2345
+ orientation: entry.orientation ?? preset.orientation
2346
+ };
2347
+ }
2348
+ return handPoseFromCurls(entry.id, entry.name, entry.curls, {
2349
+ tolerance: entry.tolerance,
2350
+ orientation: entry.orientation ?? void 0,
2351
+ hand: entry.hand ?? void 0
2352
+ });
2353
+ }
2354
+ __name(handPoseDefinitionFor, "handPoseDefinitionFor");
2355
+ var _XRHandPoseComponent = class _XRHandPoseComponent extends Component {
2356
+ constructor(options) {
2357
+ super();
2358
+ this.enabled = options?.enabled ?? true;
2359
+ this.poses = (options?.poses ?? []).map((pose) => createHandPoseEntry(pose));
2360
+ }
2361
+ };
2362
+ __name(_XRHandPoseComponent, "XRHandPoseComponent");
2363
+ var XRHandPoseComponent = _XRHandPoseComponent;
2364
+
2146
2365
  // core/utils/dracoDecoderPath.ts
2147
2366
  var LOCAL_DRACO_DECODER_RELATIVE_PATH = "./assets/draco/";
2148
2367
  var DRACO_DECODER_CDN_PATH = "https://www.gstatic.com/draco/versioned/decoders/1.5.7/";
@@ -4941,6 +5160,10 @@ function buildComponentValueMaps(snapshot) {
4941
5160
  snapshot.components,
4942
5161
  "XRInteractorComponent"
4943
5162
  ),
5163
+ xrHandPoseValues: readByComponent(
5164
+ snapshot.components,
5165
+ "XRHandPoseComponent"
5166
+ ),
4944
5167
  xrInteractorSideValues: readByComponent(
4945
5168
  snapshot.components,
4946
5169
  "XRInteractorSideComponent"
@@ -5126,11 +5349,65 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
5126
5349
  if (typeof sourceInteractable.grabMode === "string") {
5127
5350
  interactable.grabMode = sourceInteractable.grabMode;
5128
5351
  }
5352
+ if (typeof sourceInteractable.grabReach === "string") {
5353
+ interactable.grabReach = sourceInteractable.grabReach;
5354
+ }
5355
+ if (typeof sourceInteractable.distanceBehavior === "string") {
5356
+ interactable.distanceBehavior = sourceInteractable.distanceBehavior;
5357
+ }
5358
+ if (typeof sourceInteractable.grabGesture === "string") {
5359
+ interactable.grabGesture = sourceInteractable.grabGesture;
5360
+ }
5361
+ if (typeof sourceInteractable.grabRadius === "number") {
5362
+ interactable.grabRadius = sourceInteractable.grabRadius;
5363
+ }
5364
+ if (typeof sourceInteractable.throwOnRelease === "boolean") {
5365
+ interactable.throwOnRelease = sourceInteractable.throwOnRelease;
5366
+ }
5367
+ if (typeof sourceInteractable.throwStrength === "number") {
5368
+ interactable.throwStrength = sourceInteractable.throwStrength;
5369
+ }
5370
+ const grabPos = asRecord3(sourceInteractable.grabOffsetPosition);
5371
+ if (grabPos) {
5372
+ interactable.grabOffsetPosition = {
5373
+ x: typeof grabPos.x === "number" ? grabPos.x : 0,
5374
+ y: typeof grabPos.y === "number" ? grabPos.y : 0,
5375
+ z: typeof grabPos.z === "number" ? grabPos.z : 0
5376
+ };
5377
+ }
5378
+ const grabRot = asRecord3(sourceInteractable.grabOffsetRotation);
5379
+ if (grabRot) {
5380
+ interactable.grabOffsetRotation = {
5381
+ x: typeof grabRot.x === "number" ? grabRot.x : 0,
5382
+ y: typeof grabRot.y === "number" ? grabRot.y : 0,
5383
+ z: typeof grabRot.z === "number" ? grabRot.z : 0
5384
+ };
5385
+ }
5129
5386
  if (typeof sourceInteractable.highlightOnHover === "boolean") {
5130
5387
  interactable.highlightOnHover = sourceInteractable.highlightOnHover;
5131
5388
  }
5132
5389
  }
5133
5390
  }
5391
+ {
5392
+ const sourceHandPose = asRecord3(
5393
+ values.xrHandPoseValues.get(sourceEntityId)
5394
+ );
5395
+ const handPose = world.getComponent(targetEntityId, XRHandPoseComponent);
5396
+ if (sourceHandPose && handPose) {
5397
+ if (typeof sourceHandPose.enabled === "boolean") {
5398
+ handPose.enabled = sourceHandPose.enabled;
5399
+ }
5400
+ if (Array.isArray(sourceHandPose.poses)) {
5401
+ handPose.poses = sourceHandPose.poses.map((entry) => asRecord3(entry)).filter(
5402
+ (entry) => entry !== null && typeof entry.id === "string"
5403
+ ).map(
5404
+ (entry) => createHandPoseEntry(
5405
+ entry
5406
+ )
5407
+ );
5408
+ }
5409
+ }
5410
+ }
5134
5411
  {
5135
5412
  const sourceInteractor = asRecord3(
5136
5413
  values.xrInteractorValues.get(sourceEntityId)
@@ -6517,6 +6794,17 @@ var _Behaviour = class _Behaviour {
6517
6794
  *
6518
6795
  * @param input - The source doing the grabbing.
6519
6796
  */
6797
+ /**
6798
+ * A hand pose declared on this entity started being held.
6799
+ *
6800
+ * Fires once the pose has been sustained for its hold time, not the instant
6801
+ * the fingers pass through it.
6802
+ */
6803
+ onHandPoseStart(_event) {
6804
+ }
6805
+ /** A held hand pose ended, or its hand stopped being tracked. */
6806
+ onHandPoseEnd(_event) {
6807
+ }
6520
6808
  onGrab(input) {
6521
6809
  }
6522
6810
  /** Called when this entity is released. Optional XR hook. */
@@ -7659,13 +7947,65 @@ function blankState(source) {
7659
7947
  squeezeEnd: false,
7660
7948
  axes: [],
7661
7949
  hasAxes: false,
7662
- buttons: []
7950
+ buttons: [],
7951
+ joints: null
7663
7952
  };
7664
7953
  }
7665
7954
  __name(blankState, "blankState");
7955
+ var JOINT_MATRIX_BUFFER = new Float32Array(25 * 16);
7956
+ function readHandJoints(frame, hand, referenceSpace, rigMatrix, existing) {
7957
+ const names = [];
7958
+ const spaces = [];
7959
+ for (const [name, space] of hand) {
7960
+ names.push(name);
7961
+ spaces.push(space);
7962
+ }
7963
+ if (spaces.length === 0) return null;
7964
+ const target = existing ?? {};
7965
+ const matrix = new THREE14__namespace.Matrix4();
7966
+ const buffer = spaces.length * 16 <= JOINT_MATRIX_BUFFER.length ? JOINT_MATRIX_BUFFER : new Float32Array(spaces.length * 16);
7967
+ const fillPoses = frame.fillPoses;
7968
+ if (typeof fillPoses === "function") {
7969
+ let filled = false;
7970
+ try {
7971
+ filled = fillPoses.call(frame, spaces, referenceSpace, buffer);
7972
+ } catch {
7973
+ filled = false;
7974
+ }
7975
+ if (filled) {
7976
+ for (let i = 0; i < names.length; i += 1) {
7977
+ matrix.fromArray(buffer, i * 16).premultiply(rigMatrix);
7978
+ const vector = target[names[i]] ?? new THREE14__namespace.Vector3();
7979
+ vector.setFromMatrixPosition(matrix);
7980
+ target[names[i]] = vector;
7981
+ }
7982
+ return target;
7983
+ }
7984
+ }
7985
+ let anyTracked = false;
7986
+ for (let i = 0; i < names.length; i += 1) {
7987
+ const pose = frame.getJointPose?.(
7988
+ spaces[i],
7989
+ referenceSpace
7990
+ );
7991
+ if (!pose) {
7992
+ target[names[i]] = void 0;
7993
+ continue;
7994
+ }
7995
+ matrix.fromArray(pose.transform.matrix).premultiply(rigMatrix);
7996
+ const vector = target[names[i]] ?? new THREE14__namespace.Vector3();
7997
+ vector.setFromMatrixPosition(matrix);
7998
+ target[names[i]] = vector;
7999
+ anyTracked = true;
8000
+ }
8001
+ return anyTracked ? target : null;
8002
+ }
8003
+ __name(readHandJoints, "readHandJoints");
7666
8004
  var _XRInputSystem = class _XRInputSystem {
7667
8005
  constructor() {
7668
8006
  this.records = /* @__PURE__ */ new Map();
8007
+ /** Reference-space → world matrix, rebuilt each frame from the XR rig. */
8008
+ this.referenceToWorld = new THREE14__namespace.Matrix4();
7669
8009
  this.session = null;
7670
8010
  this.onSelectStart = /* @__PURE__ */ __name((event) => this.mark(event, "pendingSelectStart"), "onSelectStart");
7671
8011
  this.onSelectEnd = /* @__PURE__ */ __name((event) => this.mark(event, "pendingSelectEnd"), "onSelectEnd");
@@ -7745,6 +8085,14 @@ var _XRInputSystem = class _XRInputSystem {
7745
8085
  const referenceSpace = renderer.xr.getReferenceSpace();
7746
8086
  const frame = renderer.xr.getFrame?.();
7747
8087
  const live = /* @__PURE__ */ new Set();
8088
+ const rigMatrix = this.referenceToWorld;
8089
+ const rig = world.getCamera().parent;
8090
+ if (rig) {
8091
+ rig.updateMatrixWorld();
8092
+ rigMatrix.copy(rig.matrixWorld);
8093
+ } else {
8094
+ rigMatrix.identity();
8095
+ }
7748
8096
  for (const source of session.inputSources) {
7749
8097
  live.add(source);
7750
8098
  const record = this.ensureRecord(source);
@@ -7755,7 +8103,7 @@ var _XRInputSystem = class _XRInputSystem {
7755
8103
  if (frame && referenceSpace && source.targetRaySpace) {
7756
8104
  const pose = frame.getPose(source.targetRaySpace, referenceSpace);
7757
8105
  if (pose) {
7758
- const m = new THREE14__namespace.Matrix4().fromArray(pose.transform.matrix);
8106
+ const m = new THREE14__namespace.Matrix4().fromArray(pose.transform.matrix).premultiply(rigMatrix);
7759
8107
  state.rayOrigin.setFromMatrixPosition(m);
7760
8108
  state.rayDirection.set(0, 0, -1).applyQuaternion(
7761
8109
  new THREE14__namespace.Quaternion().setFromRotationMatrix(m)
@@ -7765,7 +8113,7 @@ var _XRInputSystem = class _XRInputSystem {
7765
8113
  if (frame && referenceSpace && source.gripSpace) {
7766
8114
  const gripPose = frame.getPose(source.gripSpace, referenceSpace);
7767
8115
  if (gripPose) {
7768
- const m = new THREE14__namespace.Matrix4().fromArray(gripPose.transform.matrix);
8116
+ const m = new THREE14__namespace.Matrix4().fromArray(gripPose.transform.matrix).premultiply(rigMatrix);
7769
8117
  state.gripPosition ?? (state.gripPosition = new THREE14__namespace.Vector3());
7770
8118
  state.gripQuaternion ?? (state.gripQuaternion = new THREE14__namespace.Quaternion());
7771
8119
  state.gripPosition.setFromMatrixPosition(m);
@@ -7775,6 +8123,17 @@ var _XRInputSystem = class _XRInputSystem {
7775
8123
  state.gripPosition = null;
7776
8124
  state.gripQuaternion = null;
7777
8125
  }
8126
+ if (frame && referenceSpace && source.hand) {
8127
+ state.joints = readHandJoints(
8128
+ frame,
8129
+ source.hand,
8130
+ referenceSpace,
8131
+ rigMatrix,
8132
+ state.joints
8133
+ );
8134
+ } else {
8135
+ state.joints = null;
8136
+ }
7778
8137
  const gamepad = source.gamepad;
7779
8138
  if (gamepad) {
7780
8139
  state.axes = Array.from(gamepad.axes);
@@ -7839,20 +8198,84 @@ var _XRInputSystem = class _XRInputSystem {
7839
8198
  };
7840
8199
  __name(_XRInputSystem, "XRInputSystem");
7841
8200
  var XRInputSystem = _XRInputSystem;
8201
+ var VELOCITY_SAMPLES = 5;
8202
+ var MAX_SAMPLE_AGE_SECONDS = 0.2;
8203
+ var _ThrowVelocityTracker = class _ThrowVelocityTracker {
8204
+ constructor() {
8205
+ this.samples = [];
8206
+ }
8207
+ /** Record where the hand is now. Call once per frame while held. */
8208
+ record(position, time) {
8209
+ this.samples.push({ position: position.clone(), time });
8210
+ if (this.samples.length > VELOCITY_SAMPLES) {
8211
+ this.samples.shift();
8212
+ }
8213
+ }
8214
+ /**
8215
+ * Velocity to release with, in metres/second.
8216
+ *
8217
+ * Uses the FASTEST segment inside the window rather than the most recent or
8218
+ * the window average. Users decelerate as they open their hand, so the last
8219
+ * segment understates the throw and the average washes it out; peak speed is
8220
+ * what matches the motion the user actually felt they made.
8221
+ */
8222
+ computeVelocity(now) {
8223
+ const fresh = this.samples.filter(
8224
+ (sample) => now - sample.time <= MAX_SAMPLE_AGE_SECONDS
8225
+ );
8226
+ if (fresh.length < 2) return new THREE14__namespace.Vector3();
8227
+ let best = new THREE14__namespace.Vector3();
8228
+ let bestSpeed = 0;
8229
+ for (let i = 1; i < fresh.length; i += 1) {
8230
+ const previous = fresh[i - 1];
8231
+ const current = fresh[i];
8232
+ const dt = current.time - previous.time;
8233
+ if (dt <= 1e-5) continue;
8234
+ const velocity = new THREE14__namespace.Vector3().subVectors(current.position, previous.position).divideScalar(dt);
8235
+ const speed = velocity.length();
8236
+ if (speed > bestSpeed) {
8237
+ bestSpeed = speed;
8238
+ best = velocity;
8239
+ }
8240
+ }
8241
+ return best;
8242
+ }
8243
+ clear() {
8244
+ this.samples.length = 0;
8245
+ }
8246
+ };
8247
+ __name(_ThrowVelocityTracker, "ThrowVelocityTracker");
8248
+ var ThrowVelocityTracker = _ThrowVelocityTracker;
8249
+ function angularVelocityFrom(previous, current, deltaTime) {
8250
+ if (deltaTime <= 1e-5) return new THREE14__namespace.Vector3();
8251
+ const delta = current.clone().multiply(previous.clone().invert());
8252
+ if (delta.w < 0) {
8253
+ delta.set(-delta.x, -delta.y, -delta.z, -delta.w);
8254
+ }
8255
+ const angle = 2 * Math.acos(THREE14__namespace.MathUtils.clamp(delta.w, -1, 1));
8256
+ const sinHalf = Math.sqrt(Math.max(0, 1 - delta.w * delta.w));
8257
+ if (sinHalf < 1e-6 || angle < 1e-6) return new THREE14__namespace.Vector3();
8258
+ return new THREE14__namespace.Vector3(delta.x, delta.y, delta.z).divideScalar(sinHalf).multiplyScalar(angle / deltaTime);
8259
+ }
8260
+ __name(angularVelocityFrom, "angularVelocityFrom");
8261
+
8262
+ // core/systems/XRInteractionSystem.ts
7842
8263
  var RAYLESS_HOLD_DISTANCE = 0.5;
7843
8264
  var _XRInteractionSystem = class _XRInteractionSystem {
7844
8265
  constructor(inputSystem) {
7845
8266
  this.raycaster = new THREE14__namespace.Raycaster();
7846
- /** Which entity each source is holding. Keyed by source state identity. */
7847
8267
  this.grabs = /* @__PURE__ */ new Map();
7848
8268
  this.hoveredEntityId = null;
8269
+ /** Monotonic seconds, accumulated from deltaTime for velocity sampling. */
8270
+ this.elapsed = 0;
7849
8271
  this.inputSystem = inputSystem;
7850
8272
  }
7851
- update(world) {
8273
+ update(world, deltaTime) {
7852
8274
  if (!world.isXrPresenting()) {
7853
8275
  this.releaseAll(world);
7854
8276
  return;
7855
8277
  }
8278
+ this.elapsed += Math.max(0, deltaTime);
7856
8279
  const inputs = this.inputSystem.getInputs();
7857
8280
  const interactables = this.collectInteractables(world);
7858
8281
  this.hoveredEntityId = null;
@@ -7863,29 +8286,100 @@ var _XRInteractionSystem = class _XRInteractionSystem {
7863
8286
  }
7864
8287
  const held = this.grabs.get(input);
7865
8288
  if (held) {
7866
- this.moveHeld(world, input, held);
7867
- if (input.selectEnd) this.release(world, input);
8289
+ this.moveHeld(world, input, held, deltaTime);
8290
+ if (this.wantsRelease(world, input, held)) {
8291
+ this.release(world, input);
8292
+ }
7868
8293
  continue;
7869
8294
  }
7870
- const hitEntityId = this.raycast(input, interactables);
7871
- if (hitEntityId === null) continue;
8295
+ const candidate = this.findGrabCandidate(world, input, interactables);
8296
+ if (candidate === null) continue;
7872
8297
  if (this.hoveredEntityId === null) {
7873
- this.hoveredEntityId = hitEntityId;
8298
+ this.hoveredEntityId = candidate.entityId;
7874
8299
  }
7875
8300
  const interactable = world.getComponent(
7876
- hitEntityId,
8301
+ candidate.entityId,
8302
+ XRInteractableComponent
8303
+ );
8304
+ if (interactable?.grabbable && this.gestureMatches(
8305
+ input,
8306
+ interactable,
8307
+ /* starting */
8308
+ true
8309
+ )) {
8310
+ this.grab(world, input, candidate.entityId, candidate.viaDistance);
8311
+ }
8312
+ }
8313
+ }
8314
+ /** Whether the grab gesture for this object is currently starting. */
8315
+ gestureMatches(input, interactable, starting) {
8316
+ const pinch = starting ? input.selectStart : input.select;
8317
+ const full = starting ? input.squeezeStart : input.squeeze;
8318
+ switch (interactable.grabGesture) {
8319
+ case "pinch":
8320
+ return pinch;
8321
+ case "full":
8322
+ return full;
8323
+ default:
8324
+ return pinch || full;
8325
+ }
8326
+ }
8327
+ /** True once the gesture that started the grab has ended. */
8328
+ wantsRelease(world, input, held) {
8329
+ const interactable = world.getComponent(
8330
+ held.entityId,
8331
+ XRInteractableComponent
8332
+ );
8333
+ if (!interactable) return true;
8334
+ switch (interactable.grabGesture) {
8335
+ case "pinch":
8336
+ return input.selectEnd || !input.select;
8337
+ case "full":
8338
+ return input.squeezeEnd || !input.squeeze;
8339
+ default:
8340
+ return !input.select && !input.squeeze;
8341
+ }
8342
+ }
8343
+ /**
8344
+ * Find what this hand would grab: a physically-reachable object first, then
8345
+ * one along the pointer ray.
8346
+ *
8347
+ * Proximity wins over the ray so that reaching for something you are touching
8348
+ * never picks up a distant object behind it, which feels broken.
8349
+ */
8350
+ findGrabCandidate(world, input, interactables) {
8351
+ const hand = this.handPosition(input);
8352
+ let nearestId = null;
8353
+ let nearestDistance = Number.POSITIVE_INFINITY;
8354
+ for (const entry of interactables) {
8355
+ const interactable2 = world.getComponent(
8356
+ entry.entityId,
7877
8357
  XRInteractableComponent
7878
8358
  );
7879
- if (input.selectStart && interactable?.grabbable) {
7880
- this.grab(world, input, hitEntityId);
8359
+ if (!interactable2) continue;
8360
+ if (interactable2.grabReach === "distance") continue;
8361
+ const position = new THREE14__namespace.Vector3();
8362
+ entry.object.getWorldPosition(position);
8363
+ const distance = position.distanceTo(hand);
8364
+ if (distance <= interactable2.grabRadius && distance < nearestDistance) {
8365
+ nearestDistance = distance;
8366
+ nearestId = entry.entityId;
7881
8367
  }
7882
8368
  }
8369
+ if (nearestId !== null) {
8370
+ return { entityId: nearestId, viaDistance: false };
8371
+ }
8372
+ const hitId = this.raycast(input, interactables);
8373
+ if (hitId === null) return null;
8374
+ const interactable = world.getComponent(hitId, XRInteractableComponent);
8375
+ const viaDistanceAllowed = interactable?.grabReach === "distance" || interactable?.grabReach === "both";
8376
+ return { entityId: hitId, viaDistance: viaDistanceAllowed };
7883
8377
  }
7884
8378
  collectInteractables(world) {
7885
8379
  const out = [];
7886
8380
  for (const entityId of world.query(XRInteractableComponent, Object3DRef)) {
7887
8381
  const interactable = world.getComponent(entityId, XRInteractableComponent);
7888
- if (!interactable?.enabled || !interactable.pointable) continue;
8382
+ if (!interactable?.enabled) continue;
7889
8383
  if (!world.isEntityVisible(entityId)) continue;
7890
8384
  const object = world.getComponent(entityId, Object3DRef)?.object;
7891
8385
  if (object) out.push({ entityId, object });
@@ -7896,6 +8390,7 @@ var _XRInteractionSystem = class _XRInteractionSystem {
7896
8390
  raycast(input, interactables) {
7897
8391
  if (interactables.length === 0) return null;
7898
8392
  this.raycaster.set(input.rayOrigin, input.rayDirection);
8393
+ this.raycaster.far = Number.POSITIVE_INFINITY;
7899
8394
  let nearestId = null;
7900
8395
  let nearestDistance = Number.POSITIVE_INFINITY;
7901
8396
  for (const entry of interactables) {
@@ -7908,43 +8403,120 @@ var _XRInteractionSystem = class _XRInteractionSystem {
7908
8403
  }
7909
8404
  return nearestId;
7910
8405
  }
7911
- grab(world, input, entityId) {
8406
+ grab(world, input, entityId, viaDistance) {
7912
8407
  const transform = world.getComponent(entityId, TransformComponent);
7913
- if (!transform) return;
8408
+ const interactable = world.getComponent(entityId, XRInteractableComponent);
8409
+ if (!transform || !interactable) return;
7914
8410
  const hand = this.handPosition(input);
7915
- const offset = new THREE14__namespace.Vector3(
7916
- transform.position.x - hand.x,
7917
- transform.position.y - hand.y,
7918
- transform.position.z - hand.z
7919
- );
8411
+ const handQuaternion = this.handQuaternion(input);
8412
+ const remote = viaDistance && interactable.distanceBehavior === "remote";
8413
+ let offset;
8414
+ let remoteDistance = 0;
8415
+ if (remote) {
8416
+ const position = new THREE14__namespace.Vector3(
8417
+ transform.position.x,
8418
+ transform.position.y,
8419
+ transform.position.z
8420
+ );
8421
+ remoteDistance = position.distanceTo(input.rayOrigin);
8422
+ offset = new THREE14__namespace.Vector3();
8423
+ } else if (viaDistance) {
8424
+ offset = new THREE14__namespace.Vector3(
8425
+ interactable.grabOffsetPosition.x,
8426
+ interactable.grabOffsetPosition.y,
8427
+ interactable.grabOffsetPosition.z
8428
+ );
8429
+ } else {
8430
+ offset = new THREE14__namespace.Vector3(
8431
+ transform.position.x - hand.x,
8432
+ transform.position.y - hand.y,
8433
+ transform.position.z - hand.z
8434
+ );
8435
+ }
7920
8436
  const physics = world.getComponent(entityId, PhysicsComponent);
7921
8437
  let restoreBodyType = null;
7922
- const interactable = world.getComponent(entityId, XRInteractableComponent);
7923
- if (physics && interactable?.grabMode === "kinematic") {
8438
+ if (physics) {
7924
8439
  restoreBodyType = physics.values.rigidbody.type;
7925
8440
  physics.values.rigidbody.type = "kinematicPosition";
7926
8441
  }
7927
- this.grabs.set(input, { entityId, offset, restoreBodyType });
8442
+ const tracker = new ThrowVelocityTracker();
8443
+ tracker.record(hand, this.elapsed);
8444
+ this.grabs.set(input, {
8445
+ entityId,
8446
+ offset,
8447
+ rotationOffset: new THREE14__namespace.Quaternion().setFromEuler(
8448
+ new THREE14__namespace.Euler(
8449
+ interactable.grabOffsetRotation.x,
8450
+ interactable.grabOffsetRotation.y,
8451
+ interactable.grabOffsetRotation.z
8452
+ )
8453
+ ),
8454
+ remoteDistance,
8455
+ remote,
8456
+ restoreBodyType,
8457
+ tracker,
8458
+ lastHandQuaternion: handQuaternion.clone(),
8459
+ angularVelocity: new THREE14__namespace.Vector3()
8460
+ });
7928
8461
  this.dispatch(world, entityId, (b) => b.onGrab(input));
7929
8462
  }
7930
- moveHeld(world, input, held) {
8463
+ moveHeld(world, input, held, deltaTime) {
7931
8464
  const transform = world.getComponent(held.entityId, TransformComponent);
7932
8465
  if (!transform) return;
7933
8466
  const hand = this.handPosition(input);
7934
- transform.position.x = hand.x + held.offset.x;
7935
- transform.position.y = hand.y + held.offset.y;
7936
- transform.position.z = hand.z + held.offset.z;
8467
+ if (held.remote) {
8468
+ const target = new THREE14__namespace.Vector3().copy(input.rayDirection).multiplyScalar(held.remoteDistance).add(input.rayOrigin);
8469
+ transform.position.x = target.x;
8470
+ transform.position.y = target.y;
8471
+ transform.position.z = target.z;
8472
+ } else {
8473
+ transform.position.x = hand.x + held.offset.x;
8474
+ transform.position.y = hand.y + held.offset.y;
8475
+ transform.position.z = hand.z + held.offset.z;
8476
+ }
8477
+ held.tracker.record(
8478
+ held.remote ? new THREE14__namespace.Vector3(
8479
+ transform.position.x,
8480
+ transform.position.y,
8481
+ transform.position.z
8482
+ ) : hand,
8483
+ this.elapsed
8484
+ );
8485
+ const handQuaternion = this.handQuaternion(input);
8486
+ held.angularVelocity = angularVelocityFrom(
8487
+ held.lastHandQuaternion,
8488
+ handQuaternion,
8489
+ deltaTime
8490
+ );
8491
+ held.lastHandQuaternion = handQuaternion;
7937
8492
  }
7938
8493
  release(world, input) {
7939
8494
  const held = this.grabs.get(input);
7940
8495
  if (!held) return;
7941
8496
  this.grabs.delete(input);
7942
- if (held.restoreBodyType !== null) {
7943
- const physics = world.getComponent(held.entityId, PhysicsComponent);
7944
- if (physics) {
7945
- physics.values.rigidbody.type = held.restoreBodyType;
8497
+ const interactable = world.getComponent(
8498
+ held.entityId,
8499
+ XRInteractableComponent
8500
+ );
8501
+ const physics = world.getComponent(held.entityId, PhysicsComponent);
8502
+ if (held.restoreBodyType !== null && physics) {
8503
+ physics.values.rigidbody.type = held.restoreBodyType;
8504
+ const body = physics.rigidBody;
8505
+ const dynamic = held.restoreBodyType === "dynamic";
8506
+ if (body && dynamic && interactable?.throwOnRelease) {
8507
+ const velocity = held.tracker.computeVelocity(this.elapsed).multiplyScalar(interactable.throwStrength);
8508
+ try {
8509
+ body.setLinvel(
8510
+ { x: velocity.x, y: velocity.y, z: velocity.z },
8511
+ true
8512
+ );
8513
+ const spin = held.angularVelocity;
8514
+ body.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true);
8515
+ } catch {
8516
+ }
7946
8517
  }
7947
8518
  }
8519
+ held.tracker.clear();
7948
8520
  this.dispatch(world, held.entityId, (b) => b.onRelease(input));
7949
8521
  }
7950
8522
  releaseAll(world) {
@@ -7953,14 +8525,25 @@ var _XRInteractionSystem = class _XRInteractionSystem {
7953
8525
  }
7954
8526
  }
7955
8527
  /**
7956
- * Where the "hand" is for this source. Controllers and tracked hands report a
7957
- * grip pose; gaze and transient-pointer sources have none, so we hold the
7958
- * object a fixed distance along the ray instead.
8528
+ * Where the "hand" is. Controllers and tracked hands report a grip pose;
8529
+ * gaze and transient-pointer sources have none, so we hold the object a fixed
8530
+ * distance along the ray instead — which is what makes Vision Pro work.
7959
8531
  */
7960
8532
  handPosition(input) {
7961
8533
  if (input.gripPosition) return input.gripPosition;
7962
8534
  return new THREE14__namespace.Vector3().copy(input.rayDirection).multiplyScalar(RAYLESS_HOLD_DISTANCE).add(input.rayOrigin);
7963
8535
  }
8536
+ /**
8537
+ * Hand orientation. Grip-less sources (gaze / transient-pointer) have none,
8538
+ * so we derive one from the aim direction — enough for spin on release.
8539
+ */
8540
+ handQuaternion(input) {
8541
+ if (input.gripQuaternion) return input.gripQuaternion.clone();
8542
+ return new THREE14__namespace.Quaternion().setFromUnitVectors(
8543
+ new THREE14__namespace.Vector3(0, 0, -1),
8544
+ input.rayDirection.clone().normalize()
8545
+ );
8546
+ }
7964
8547
  dispatch(world, entityId, visit) {
7965
8548
  const script = world.getComponent(entityId, ScriptComponent);
7966
8549
  const instances = script?.scriptInstances;
@@ -8517,6 +9100,7 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8517
9100
  this.handStyleSignature = "";
8518
9101
  this.handMaterial = null;
8519
9102
  this.interactionSystem = options?.interactionSystem ?? null;
9103
+ this.inputSystem = options?.inputSystem ?? null;
8520
9104
  this.group.name = "XRControllerVisuals";
8521
9105
  this.group.userData.ecsPreviewHidden = true;
8522
9106
  }
@@ -8533,8 +9117,11 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8533
9117
  for (const { index, side } of SLOTS) {
8534
9118
  const visuals = this.slots.get(index);
8535
9119
  if (!visuals) continue;
8536
- visuals.controllerModel.visible = showControllers;
8537
- visuals.handModel.visible = showHands;
9120
+ const liveKind = this.liveKindFor(side);
9121
+ const hasController = liveKind === null || liveKind === "controller";
9122
+ const hasHand = liveKind === null || liveKind === "hand";
9123
+ visuals.controllerModel.visible = showControllers && hasController;
9124
+ visuals.handModel.visible = showHands && hasHand;
8538
9125
  visuals.ray.visible = this.shouldShowRay(interactor);
8539
9126
  if (showHands && this.handMaterial) {
8540
9127
  applyHandMaterial(visuals.handModel, this.handMaterial);
@@ -8542,6 +9129,20 @@ var _XRControllerVisualSystem = class _XRControllerVisualSystem {
8542
9129
  this.syncCustomContent(world, visuals, side);
8543
9130
  }
8544
9131
  }
9132
+ /**
9133
+ * What kind of source is currently driving this hand, or null when we cannot
9134
+ * tell (no input system wired) — in which case callers fall back to showing
9135
+ * everything rather than hiding the user's hands entirely.
9136
+ */
9137
+ liveKindFor(side) {
9138
+ if (!this.inputSystem) return null;
9139
+ const inputs = this.inputSystem.getInputs();
9140
+ if (inputs.length === 0) return null;
9141
+ for (const input of inputs) {
9142
+ if (input.handedness === side && input.connected) return input.kind;
9143
+ }
9144
+ return "none";
9145
+ }
8545
9146
  /** The scene's interactor config, if one was authored. */
8546
9147
  resolveInteractor(world) {
8547
9148
  const entityId = world.query(XRInteractorComponent)[0];
@@ -8801,6 +9402,511 @@ var _XRTeleportVisualSystem = class _XRTeleportVisualSystem {
8801
9402
  };
8802
9403
  __name(_XRTeleportVisualSystem, "XRTeleportVisualSystem");
8803
9404
  var XRTeleportVisualSystem = _XRTeleportVisualSystem;
9405
+ var XR_FINGERS = [
9406
+ "thumb",
9407
+ "index",
9408
+ "middle",
9409
+ "ring",
9410
+ "pinky"
9411
+ ];
9412
+ var PINCH_FINGERS = [
9413
+ "index",
9414
+ "middle",
9415
+ "ring",
9416
+ "pinky"
9417
+ ];
9418
+ var FINGER_CHAINS = {
9419
+ thumb: [
9420
+ "thumb-metacarpal",
9421
+ "thumb-phalanx-proximal",
9422
+ "thumb-phalanx-distal",
9423
+ "thumb-tip"
9424
+ ],
9425
+ index: [
9426
+ "index-finger-metacarpal",
9427
+ "index-finger-phalanx-proximal",
9428
+ "index-finger-phalanx-intermediate",
9429
+ "index-finger-phalanx-distal",
9430
+ "index-finger-tip"
9431
+ ],
9432
+ middle: [
9433
+ "middle-finger-metacarpal",
9434
+ "middle-finger-phalanx-proximal",
9435
+ "middle-finger-phalanx-intermediate",
9436
+ "middle-finger-phalanx-distal",
9437
+ "middle-finger-tip"
9438
+ ],
9439
+ ring: [
9440
+ "ring-finger-metacarpal",
9441
+ "ring-finger-phalanx-proximal",
9442
+ "ring-finger-phalanx-intermediate",
9443
+ "ring-finger-phalanx-distal",
9444
+ "ring-finger-tip"
9445
+ ],
9446
+ pinky: [
9447
+ "pinky-finger-metacarpal",
9448
+ "pinky-finger-phalanx-proximal",
9449
+ "pinky-finger-phalanx-intermediate",
9450
+ "pinky-finger-phalanx-distal",
9451
+ "pinky-finger-tip"
9452
+ ]
9453
+ };
9454
+ var TIP_JOINTS = {
9455
+ thumb: "thumb-tip",
9456
+ index: "index-finger-tip",
9457
+ middle: "middle-finger-tip",
9458
+ ring: "ring-finger-tip",
9459
+ pinky: "pinky-finger-tip"
9460
+ };
9461
+ var METACARPAL_JOINTS = {
9462
+ thumb: "thumb-metacarpal",
9463
+ index: "index-finger-metacarpal",
9464
+ middle: "middle-finger-metacarpal",
9465
+ ring: "ring-finger-metacarpal",
9466
+ pinky: "pinky-finger-metacarpal"
9467
+ };
9468
+ var FALLBACK_HAND_SCALE = 0.08;
9469
+ var STRAIGHT_COS = 0.95;
9470
+ var CURLED_COS = 0.55;
9471
+ function clamp01(value) {
9472
+ return value < 0 ? 0 : value > 1 ? 1 : value;
9473
+ }
9474
+ __name(clamp01, "clamp01");
9475
+ function boneDirections(joints, finger) {
9476
+ const chain = FINGER_CHAINS[finger];
9477
+ const directions = [];
9478
+ for (let i = 1; i < chain.length; i += 1) {
9479
+ const from = joints[chain[i - 1]];
9480
+ const to = joints[chain[i]];
9481
+ if (!from || !to) return null;
9482
+ const direction = new THREE14__namespace.Vector3().subVectors(to, from);
9483
+ if (direction.lengthSq() < 1e-12) return null;
9484
+ directions.push(direction.normalize());
9485
+ }
9486
+ return directions.length >= 2 ? directions : null;
9487
+ }
9488
+ __name(boneDirections, "boneDirections");
9489
+ function bendAmounts(directions) {
9490
+ const bends = [];
9491
+ for (let i = 1; i < directions.length; i += 1) {
9492
+ const cos = directions[i - 1].dot(directions[i]);
9493
+ const straightness = clamp01((cos - CURLED_COS) / (STRAIGHT_COS - CURLED_COS));
9494
+ bends.push(1 - straightness);
9495
+ }
9496
+ return bends;
9497
+ }
9498
+ __name(bendAmounts, "bendAmounts");
9499
+ function average(values) {
9500
+ if (values.length === 0) return 0;
9501
+ let sum = 0;
9502
+ for (const value of values) sum += value;
9503
+ return sum / values.length;
9504
+ }
9505
+ __name(average, "average");
9506
+ function computeFingerCurl(joints, finger) {
9507
+ const directions = boneDirections(joints, finger);
9508
+ if (!directions) return null;
9509
+ const bends = bendAmounts(directions);
9510
+ if (bends.length === 0) return null;
9511
+ return {
9512
+ baseCurl: bends[0],
9513
+ tipCurl: average(bends.slice(1)) || bends[bends.length - 1],
9514
+ curl: average(bends)
9515
+ };
9516
+ }
9517
+ __name(computeFingerCurl, "computeFingerCurl");
9518
+ function computePalmWidth(joints) {
9519
+ const index = joints["index-finger-metacarpal"];
9520
+ const pinky = joints["pinky-finger-metacarpal"];
9521
+ if (!index || !pinky) return null;
9522
+ const width = index.distanceTo(pinky);
9523
+ return width > 1e-6 ? width : null;
9524
+ }
9525
+ __name(computePalmWidth, "computePalmWidth");
9526
+ function estimateHandScale(joints) {
9527
+ const measurements = [];
9528
+ const width = computePalmWidth(joints);
9529
+ if (width !== null) measurements.push(width);
9530
+ const wrist = joints.wrist;
9531
+ const middleTip = joints["middle-finger-tip"];
9532
+ if (wrist && middleTip) {
9533
+ const span = wrist.distanceTo(middleTip);
9534
+ if (span > 1e-6) measurements.push(span * 0.5);
9535
+ }
9536
+ if (measurements.length === 0) return FALLBACK_HAND_SCALE;
9537
+ return average(measurements);
9538
+ }
9539
+ __name(estimateHandScale, "estimateHandScale");
9540
+ function computePinch(joints, finger) {
9541
+ const thumbTip = joints["thumb-tip"];
9542
+ const fingerTip = joints[TIP_JOINTS[finger]];
9543
+ if (!thumbTip || !fingerTip) return null;
9544
+ const scale = estimateHandScale(joints);
9545
+ const distance = thumbTip.distanceTo(fingerTip);
9546
+ return clamp01(1 - distance / (scale * 0.7));
9547
+ }
9548
+ __name(computePinch, "computePinch");
9549
+ function computeSpread(joints, a, b) {
9550
+ const aBase = joints[METACARPAL_JOINTS[a]];
9551
+ const aTip = joints[TIP_JOINTS[a]];
9552
+ const bBase = joints[METACARPAL_JOINTS[b]];
9553
+ const bTip = joints[TIP_JOINTS[b]];
9554
+ if (!aBase || !aTip || !bBase || !bTip) return null;
9555
+ const dirA = new THREE14__namespace.Vector3().subVectors(aTip, aBase);
9556
+ const dirB = new THREE14__namespace.Vector3().subVectors(bTip, bBase);
9557
+ if (dirA.lengthSq() < 1e-12 || dirB.lengthSq() < 1e-12) return null;
9558
+ return clamp01((1 - dirA.normalize().dot(dirB.normalize())) / 0.45);
9559
+ }
9560
+ __name(computeSpread, "computeSpread");
9561
+ function computePalmPose(joints, handedness) {
9562
+ const wrist = joints.wrist;
9563
+ const indexBase = joints["index-finger-metacarpal"];
9564
+ const pinkyBase = joints["pinky-finger-metacarpal"];
9565
+ if (!wrist || !indexBase || !pinkyBase) return null;
9566
+ const u = new THREE14__namespace.Vector3().subVectors(indexBase, wrist);
9567
+ const v = new THREE14__namespace.Vector3().subVectors(pinkyBase, wrist);
9568
+ const normal = new THREE14__namespace.Vector3().crossVectors(u, v);
9569
+ if (normal.lengthSq() < 1e-12) return null;
9570
+ normal.normalize();
9571
+ if (handedness === "left") normal.multiplyScalar(-1);
9572
+ const right = new THREE14__namespace.Vector3().subVectors(indexBase, pinkyBase);
9573
+ if (right.lengthSq() < 1e-12) return null;
9574
+ right.normalize();
9575
+ if (handedness === "left") right.multiplyScalar(-1);
9576
+ const up = new THREE14__namespace.Vector3().crossVectors(normal, right).normalize();
9577
+ const center = new THREE14__namespace.Vector3().add(wrist).add(indexBase).add(pinkyBase).divideScalar(3);
9578
+ return {
9579
+ center,
9580
+ normal,
9581
+ right,
9582
+ up,
9583
+ width: computePalmWidth(joints) ?? FALLBACK_HAND_SCALE
9584
+ };
9585
+ }
9586
+ __name(computePalmPose, "computePalmPose");
9587
+ function computeHandShape(joints, handedness) {
9588
+ const fingers2 = {};
9589
+ let tracked = 0;
9590
+ for (const finger of XR_FINGERS) {
9591
+ const curl = computeFingerCurl(joints, finger);
9592
+ if (curl) {
9593
+ fingers2[finger] = curl;
9594
+ tracked += 1;
9595
+ } else {
9596
+ fingers2[finger] = { baseCurl: 0, tipCurl: 0, curl: 0 };
9597
+ }
9598
+ }
9599
+ if (tracked === 0) return null;
9600
+ const pinch = {};
9601
+ for (const finger of PINCH_FINGERS) {
9602
+ pinch[finger] = computePinch(joints, finger) ?? 0;
9603
+ }
9604
+ const thumbBase = joints["thumb-metacarpal"];
9605
+ const thumbTip = joints["thumb-tip"];
9606
+ let thumbDirection = null;
9607
+ if (thumbBase && thumbTip) {
9608
+ const direction = new THREE14__namespace.Vector3().subVectors(thumbTip, thumbBase);
9609
+ if (direction.lengthSq() > 1e-12) thumbDirection = direction.normalize();
9610
+ }
9611
+ return {
9612
+ handedness,
9613
+ fingers: fingers2,
9614
+ pinch,
9615
+ spread: {
9616
+ thumbIndex: computeSpread(joints, "thumb", "index") ?? 0,
9617
+ indexMiddle: computeSpread(joints, "index", "middle") ?? 0,
9618
+ middleRing: computeSpread(joints, "middle", "ring") ?? 0,
9619
+ ringPinky: computeSpread(joints, "ring", "pinky") ?? 0
9620
+ },
9621
+ palm: computePalmPose(joints, handedness),
9622
+ scale: estimateHandScale(joints),
9623
+ thumbDirection
9624
+ };
9625
+ }
9626
+ __name(computeHandShape, "computeHandShape");
9627
+ var MATCH_FALLOFF = 0.25;
9628
+ var NO_MATCH = {
9629
+ matched: false,
9630
+ confidence: 0,
9631
+ worstConstraint: null
9632
+ };
9633
+ function scoreAgainst(actual, target, tolerance) {
9634
+ const error = Math.abs(actual - target);
9635
+ if (error <= tolerance) return 1;
9636
+ return clamp01(1 - (error - tolerance) / MATCH_FALLOFF);
9637
+ }
9638
+ __name(scoreAgainst, "scoreAgainst");
9639
+ var WORLD_DIRECTIONS = {
9640
+ up: new THREE14__namespace.Vector3(0, 1, 0),
9641
+ down: new THREE14__namespace.Vector3(0, -1, 0),
9642
+ forward: new THREE14__namespace.Vector3(0, 0, -1),
9643
+ back: new THREE14__namespace.Vector3(0, 0, 1),
9644
+ left: new THREE14__namespace.Vector3(-1, 0, 0),
9645
+ right: new THREE14__namespace.Vector3(1, 0, 0)
9646
+ };
9647
+ function axisVector(shape, axis) {
9648
+ if (axis === "thumb") return shape.thumbDirection;
9649
+ if (!shape.palm) return null;
9650
+ return axis === "palm-normal" ? shape.palm.normal : shape.palm.up;
9651
+ }
9652
+ __name(axisVector, "axisVector");
9653
+ function resolveDirection(direction, shape, context) {
9654
+ if (direction !== "toward-head" && direction !== "away-from-head") {
9655
+ return WORLD_DIRECTIONS[direction];
9656
+ }
9657
+ const head = context?.headPosition;
9658
+ const origin = shape.palm?.center;
9659
+ if (!head || !origin) return null;
9660
+ const toHead = new THREE14__namespace.Vector3().subVectors(head, origin);
9661
+ if (toHead.lengthSq() < 1e-12) return null;
9662
+ toHead.normalize();
9663
+ return direction === "toward-head" ? toHead : toHead.multiplyScalar(-1);
9664
+ }
9665
+ __name(resolveDirection, "resolveDirection");
9666
+ function matchHandPose(shape, definition, context) {
9667
+ if (definition.hand && definition.hand !== shape.handedness) return NO_MATCH;
9668
+ let confidence = 1;
9669
+ let worstScore = 1;
9670
+ let worstConstraint = null;
9671
+ const consider = /* @__PURE__ */ __name((score, label) => {
9672
+ if (score < confidence) confidence = score;
9673
+ if (score < worstScore) {
9674
+ worstScore = score;
9675
+ worstConstraint = label;
9676
+ }
9677
+ }, "consider");
9678
+ for (const finger of XR_FINGERS) {
9679
+ const target = definition.fingers[finger];
9680
+ if (!target) continue;
9681
+ const actual = shape.fingers[finger];
9682
+ consider(scoreAgainst(actual.curl, target.curl, target.tolerance), finger);
9683
+ if (target.baseCurl !== void 0) {
9684
+ consider(
9685
+ scoreAgainst(actual.baseCurl, target.baseCurl, target.tolerance),
9686
+ `${finger} knuckle`
9687
+ );
9688
+ }
9689
+ if (target.tipCurl !== void 0) {
9690
+ consider(
9691
+ scoreAgainst(actual.tipCurl, target.tipCurl, target.tolerance),
9692
+ `${finger} tip`
9693
+ );
9694
+ }
9695
+ }
9696
+ if (definition.pinch) {
9697
+ const { finger, target, tolerance } = definition.pinch;
9698
+ consider(
9699
+ scoreAgainst(shape.pinch[finger], target, tolerance),
9700
+ `${finger} pinch`
9701
+ );
9702
+ }
9703
+ if (definition.orientation) {
9704
+ const { axis, direction, toleranceDegrees } = definition.orientation;
9705
+ const handAxis = axisVector(shape, axis);
9706
+ const wanted = resolveDirection(direction, shape, context);
9707
+ if (!handAxis || !wanted) {
9708
+ consider(0, "orientation");
9709
+ } else {
9710
+ const angle = THREE14__namespace.MathUtils.radToDeg(
9711
+ Math.acos(THREE14__namespace.MathUtils.clamp(handAxis.dot(wanted), -1, 1))
9712
+ );
9713
+ const normalizedError = Math.max(0, angle - toleranceDegrees) / 90;
9714
+ consider(clamp01(1 - normalizedError / MATCH_FALLOFF), "orientation");
9715
+ }
9716
+ }
9717
+ return {
9718
+ matched: confidence >= 1,
9719
+ confidence,
9720
+ worstConstraint: confidence >= 1 ? null : worstConstraint
9721
+ };
9722
+ }
9723
+ __name(matchHandPose, "matchHandPose");
9724
+ var DEFAULT_ENTER_CONFIDENCE = 0.8;
9725
+ var DEFAULT_EXIT_CONFIDENCE = 0.55;
9726
+ var DEFAULT_MIN_HOLD_SECONDS = 0.15;
9727
+ var DEFAULT_LOST_GRACE_SECONDS = 0.25;
9728
+ var _HandPoseTracker = class _HandPoseTracker {
9729
+ constructor(options) {
9730
+ this.active = false;
9731
+ this.holdingSince = null;
9732
+ this.lostSince = null;
9733
+ this.enterConfidence = options?.enterConfidence ?? DEFAULT_ENTER_CONFIDENCE;
9734
+ const exit = options?.exitConfidence ?? DEFAULT_EXIT_CONFIDENCE;
9735
+ this.exitConfidence = Math.min(exit, this.enterConfidence - 1e-3);
9736
+ this.minHoldSeconds = options?.minHoldSeconds ?? DEFAULT_MIN_HOLD_SECONDS;
9737
+ this.lostGraceSeconds = options?.lostGraceSeconds ?? DEFAULT_LOST_GRACE_SECONDS;
9738
+ }
9739
+ isActive() {
9740
+ return this.active;
9741
+ }
9742
+ /** Feed this frame's confidence. Returns an event on a transition. */
9743
+ update(confidence, now) {
9744
+ this.lostSince = null;
9745
+ if (this.active) {
9746
+ if (confidence < this.exitConfidence) {
9747
+ this.active = false;
9748
+ this.holdingSince = null;
9749
+ return "end";
9750
+ }
9751
+ return null;
9752
+ }
9753
+ if (confidence >= this.enterConfidence) {
9754
+ if (this.holdingSince === null) this.holdingSince = now;
9755
+ if (now - this.holdingSince >= this.minHoldSeconds) {
9756
+ this.active = true;
9757
+ return "start";
9758
+ }
9759
+ return null;
9760
+ }
9761
+ this.holdingSince = null;
9762
+ return null;
9763
+ }
9764
+ /** Feed a frame where the hand isn't tracked at all. */
9765
+ miss(now) {
9766
+ this.holdingSince = null;
9767
+ if (!this.active) return null;
9768
+ if (this.lostSince === null) this.lostSince = now;
9769
+ if (now - this.lostSince < this.lostGraceSeconds) return null;
9770
+ this.active = false;
9771
+ this.lostSince = null;
9772
+ return "end";
9773
+ }
9774
+ reset() {
9775
+ this.active = false;
9776
+ this.holdingSince = null;
9777
+ this.lostSince = null;
9778
+ }
9779
+ };
9780
+ __name(_HandPoseTracker, "HandPoseTracker");
9781
+ var HandPoseTracker = _HandPoseTracker;
9782
+
9783
+ // core/systems/XRHandPoseSystem.ts
9784
+ var _XRHandPoseSystem = class _XRHandPoseSystem {
9785
+ constructor(inputSystem) {
9786
+ this.trackers = /* @__PURE__ */ new Map();
9787
+ this.shapes = /* @__PURE__ */ new Map();
9788
+ /** Reused so head-relative orientation checks allocate nothing per frame. */
9789
+ this.headPosition = new THREE14__namespace.Vector3();
9790
+ this.elapsed = 0;
9791
+ this.inputSystem = inputSystem;
9792
+ }
9793
+ update(world, deltaTime) {
9794
+ if (!world.isXrPresenting()) {
9795
+ this.resetAll();
9796
+ return;
9797
+ }
9798
+ this.elapsed += deltaTime;
9799
+ this.refreshShapes();
9800
+ const entries = this.collectEntries(world);
9801
+ if (entries.length === 0) return;
9802
+ world.getCamera().getWorldPosition(this.headPosition);
9803
+ const context = { headPosition: this.headPosition };
9804
+ for (const { entityId, entry } of entries) {
9805
+ const definition = handPoseDefinitionFor(entry, getHandPosePreset);
9806
+ if (!definition) continue;
9807
+ for (const handedness of ["left", "right"]) {
9808
+ if (entry.hand && entry.hand !== handedness) continue;
9809
+ const key = `${entityId}:${entry.id}`;
9810
+ const trackers = this.trackersFor(handedness);
9811
+ let tracker = trackers.get(key);
9812
+ if (!tracker) {
9813
+ tracker = new HandPoseTracker({
9814
+ minHoldSeconds: entry.minHoldSeconds
9815
+ });
9816
+ trackers.set(key, tracker);
9817
+ }
9818
+ const shape = this.shapes.get(handedness) ?? null;
9819
+ if (!shape) {
9820
+ if (tracker.miss(this.elapsed) === "end") {
9821
+ this.dispatch(world, entityId, "end", {
9822
+ poseId: entry.id,
9823
+ poseName: entry.name,
9824
+ handedness,
9825
+ confidence: 0
9826
+ });
9827
+ }
9828
+ continue;
9829
+ }
9830
+ const match = matchHandPose(shape, definition, context);
9831
+ const event = tracker.update(match.confidence, this.elapsed);
9832
+ if (event) {
9833
+ this.dispatch(world, entityId, event, {
9834
+ poseId: entry.id,
9835
+ poseName: entry.name,
9836
+ handedness,
9837
+ confidence: match.confidence
9838
+ });
9839
+ }
9840
+ }
9841
+ }
9842
+ }
9843
+ /** Derive each hand's shape once for the whole frame. */
9844
+ refreshShapes() {
9845
+ for (const handedness of ["left", "right"]) {
9846
+ const input = this.inputSystem.getInput(handedness);
9847
+ const joints = input?.joints ?? null;
9848
+ this.shapes.set(
9849
+ handedness,
9850
+ joints ? computeHandShape(joints, handedness) : null
9851
+ );
9852
+ }
9853
+ }
9854
+ collectEntries(world) {
9855
+ const collected = [];
9856
+ for (const entityId of world.query(XRHandPoseComponent)) {
9857
+ const component = world.getComponent(entityId, XRHandPoseComponent);
9858
+ if (!component?.enabled) continue;
9859
+ for (const entry of component.poses) {
9860
+ if (entry.enabled) collected.push({ entityId, entry });
9861
+ }
9862
+ }
9863
+ return collected;
9864
+ }
9865
+ trackersFor(handedness) {
9866
+ let trackers = this.trackers.get(handedness);
9867
+ if (!trackers) {
9868
+ trackers = /* @__PURE__ */ new Map();
9869
+ this.trackers.set(handedness, trackers);
9870
+ }
9871
+ return trackers;
9872
+ }
9873
+ /**
9874
+ * Deliver to the entity's own Behaviours.
9875
+ *
9876
+ * Scoped to the entity that declared the pose rather than broadcast, so two
9877
+ * objects listening for the same gesture stay independent.
9878
+ */
9879
+ dispatch(world, entityId, event, detail) {
9880
+ const script = world.getComponent(entityId, ScriptComponent);
9881
+ const instances = script?.scriptInstances;
9882
+ if (!instances) return;
9883
+ for (const instance of instances.values()) {
9884
+ if (!(instance instanceof Behaviour)) continue;
9885
+ try {
9886
+ if (event === "start") instance.onHandPoseStart(detail);
9887
+ else instance.onHandPoseEnd(detail);
9888
+ } catch (error) {
9889
+ console.error("[XRHandPoseSystem] Behaviour hook failed:", error);
9890
+ }
9891
+ }
9892
+ }
9893
+ resetAll() {
9894
+ for (const trackers of this.trackers.values()) {
9895
+ for (const tracker of trackers.values()) tracker.reset();
9896
+ }
9897
+ this.shapes.clear();
9898
+ }
9899
+ /** Live shape for a hand — powers the editor's pose readout. */
9900
+ getHandShape(handedness) {
9901
+ return this.shapes.get(handedness) ?? null;
9902
+ }
9903
+ /** Whether a given entity's pose is currently held. */
9904
+ isPoseActive(entityId, poseId, handedness) {
9905
+ return this.trackers.get(handedness)?.get(`${entityId}:${poseId}`)?.isActive() ?? false;
9906
+ }
9907
+ };
9908
+ __name(_XRHandPoseSystem, "XRHandPoseSystem");
9909
+ var XRHandPoseSystem = _XRHandPoseSystem;
8804
9910
 
8805
9911
  exports.AnimationSystem = AnimationSystem;
8806
9912
  exports.AudioSystem = AudioSystem;
@@ -8824,6 +9930,7 @@ exports.TransformSyncSystem = TransformSyncSystem;
8824
9930
  exports.VRTeleportAreaSystem = VRTeleportAreaSystem;
8825
9931
  exports.VideoSystem = VideoSystem;
8826
9932
  exports.XRControllerVisualSystem = XRControllerVisualSystem;
9933
+ exports.XRHandPoseSystem = XRHandPoseSystem;
8827
9934
  exports.XRInputSystem = XRInputSystem;
8828
9935
  exports.XRInteractionSystem = XRInteractionSystem;
8829
9936
  exports.XRLocomotionSystem = XRLocomotionSystem;