@threlte/xr 1.5.4 → 1.6.0

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 (73) hide show
  1. package/dist/components/Controller.svelte +59 -57
  2. package/dist/components/Hand.svelte +24 -29
  3. package/dist/components/XR.svelte +146 -16
  4. package/dist/components/XR.svelte.d.ts +20 -0
  5. package/dist/components/XROrigin.svelte +82 -0
  6. package/dist/components/XROrigin.svelte.d.ts +33 -0
  7. package/dist/components/internal/Cursor.svelte +5 -10
  8. package/dist/components/internal/PointerCursor.svelte +18 -4
  9. package/dist/components/internal/TeleportCursor.svelte +4 -1
  10. package/dist/components/internal/TeleportRay.svelte +15 -3
  11. package/dist/hooks/currentReadable.svelte.d.ts +28 -1
  12. package/dist/hooks/currentReadable.svelte.js +36 -9
  13. package/dist/hooks/useController.svelte.d.ts +3 -3
  14. package/dist/hooks/useController.svelte.js +30 -7
  15. package/dist/hooks/useHand.svelte.d.ts +2 -2
  16. package/dist/hooks/useHand.svelte.js +26 -5
  17. package/dist/hooks/useHandJoint.svelte.js +8 -6
  18. package/dist/hooks/useHitTest.svelte.js +56 -12
  19. package/dist/hooks/useTeleport.d.ts +11 -9
  20. package/dist/hooks/useTeleport.js +62 -14
  21. package/dist/hooks/useXR.js +5 -5
  22. package/dist/hooks/useXROrigin.svelte.d.ts +10 -0
  23. package/dist/hooks/useXROrigin.svelte.js +11 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +3 -0
  26. package/dist/internal/inputSources.svelte.d.ts +84 -0
  27. package/dist/internal/inputSources.svelte.js +91 -0
  28. package/dist/internal/setupHeadset.svelte.js +18 -6
  29. package/dist/internal/setupInputSources.d.ts +4 -0
  30. package/dist/internal/setupInputSources.js +319 -0
  31. package/dist/internal/state.svelte.d.ts +10 -12
  32. package/dist/internal/state.svelte.js +9 -3
  33. package/dist/lib/getXRSessionOptions.d.ts +1 -1
  34. package/dist/lib/getXRSessionOptions.js +8 -7
  35. package/dist/lib/toggleXRSession.d.ts +1 -1
  36. package/dist/lib/toggleXRSession.js +22 -7
  37. package/dist/plugins/pointerControls/compute.js +14 -5
  38. package/dist/plugins/pointerControls/context.d.ts +3 -3
  39. package/dist/plugins/pointerControls/context.js +12 -6
  40. package/dist/plugins/pointerControls/index.d.ts +4 -3
  41. package/dist/plugins/pointerControls/index.js +63 -31
  42. package/dist/plugins/pointerControls/plugin.svelte.js +0 -5
  43. package/dist/plugins/pointerControls/setup.svelte.js +92 -78
  44. package/dist/plugins/pointerControls/types.d.ts +16 -3
  45. package/dist/plugins/pointerControls/types.js +2 -1
  46. package/dist/plugins/teleportControls/compute.d.ts +1 -1
  47. package/dist/plugins/teleportControls/compute.js +11 -4
  48. package/dist/plugins/teleportControls/context.d.ts +4 -4
  49. package/dist/plugins/teleportControls/context.js +1 -4
  50. package/dist/plugins/teleportControls/index.js +8 -8
  51. package/dist/plugins/teleportControls/setup.svelte.js +10 -9
  52. package/dist/plugins/touchControls/compute.d.ts +3 -0
  53. package/dist/plugins/touchControls/compute.js +13 -0
  54. package/dist/plugins/touchControls/context.d.ts +12 -0
  55. package/dist/plugins/touchControls/context.js +27 -0
  56. package/dist/plugins/touchControls/hook.d.ts +5 -0
  57. package/dist/plugins/touchControls/hook.js +26 -0
  58. package/dist/plugins/touchControls/index.d.ts +33 -0
  59. package/dist/plugins/touchControls/index.js +41 -0
  60. package/dist/plugins/touchControls/plugin.svelte.d.ts +1 -0
  61. package/dist/plugins/touchControls/plugin.svelte.js +24 -0
  62. package/dist/plugins/touchControls/setup.svelte.d.ts +2 -0
  63. package/dist/plugins/touchControls/setup.svelte.js +247 -0
  64. package/dist/plugins/touchControls/types.d.ts +62 -0
  65. package/dist/plugins/touchControls/types.js +11 -0
  66. package/dist/types.d.ts +1 -1
  67. package/package.json +3 -2
  68. package/dist/internal/setupControllers.d.ts +0 -2
  69. package/dist/internal/setupControllers.js +0 -68
  70. package/dist/internal/setupHands.d.ts +0 -2
  71. package/dist/internal/setupHands.js +0 -67
  72. package/dist/internal/useHandTrackingState.d.ts +0 -5
  73. package/dist/internal/useHandTrackingState.js +0 -20
@@ -1,5 +1,6 @@
1
- import { session, referenceSpaceType, xr } from '../internal/state.svelte.js';
1
+ import { lastSessionRequest, referenceSpaceType, session, xr } from '../internal/state.svelte.js';
2
2
  import { getXRSessionOptions } from './getXRSessionOptions.js';
3
+ let pending;
3
4
  /**
4
5
  * Starts / ends an XR session.
5
6
  *
@@ -8,26 +9,40 @@ import { getXRSessionOptions } from './getXRSessionOptions.js';
8
9
  * @param force Whether this button should only enter / exit an `XRSession`. Default is to toggle both ways
9
10
  * @returns
10
11
  */
11
- export const toggleXRSession = async (sessionMode, sessionInit, force) => {
12
+ export const toggleXRSession = (sessionMode, sessionInit, force) => {
13
+ if (pending !== undefined)
14
+ return pending;
15
+ pending = run(sessionMode, sessionInit, force).finally(() => {
16
+ pending = undefined;
17
+ });
18
+ return pending;
19
+ };
20
+ const run = async (sessionMode, sessionInit, force) => {
12
21
  const currentSession = session.current;
13
22
  const hasSession = currentSession !== undefined;
14
23
  if (force === 'enter' && hasSession)
15
24
  return currentSession;
16
25
  if (force === 'exit' && !hasSession)
17
26
  return;
18
- // Exit a session if entered
27
+ // Exit a session if entered. `session.current` is cleared by XR.svelte's
28
+ // `handleSessionEnd` when the 'end' event fires — don't duplicate that here.
19
29
  if (hasSession) {
20
30
  await currentSession.end();
21
- session.current = undefined;
22
31
  return;
23
32
  }
24
- if (xr.current === undefined) {
33
+ const manager = xr.current;
34
+ if (manager === undefined) {
25
35
  throw new Error('An <XR> component was not created when attempting to toggle a session.');
26
36
  }
27
37
  // Otherwise enter a session
28
38
  const options = getXRSessionOptions(referenceSpaceType.current, sessionInit);
29
- const nextSession = await navigator.xr.requestSession(sessionMode, options);
30
- await xr.current.setSession(nextSession);
39
+ const nextSession = await navigator.xr?.requestSession(sessionMode, options);
40
+ if (nextSession === undefined) {
41
+ throw new Error('A session was not able to be created.');
42
+ }
43
+ await manager.setSession(nextSession);
44
+ lastSessionRequest.mode = sessionMode;
45
+ lastSessionRequest.sessionInit = sessionInit;
31
46
  session.current = nextSession;
32
47
  return nextSession;
33
48
  };
@@ -1,10 +1,19 @@
1
1
  import { Vector3 } from 'three';
2
- import { controllers } from '../../hooks/useController.svelte.js';
2
+ import { getControllerState, getHandState } from '../../internal/inputSources.svelte.js';
3
+ const origin = new Vector3();
3
4
  const forward = new Vector3();
4
- export const defaultComputeFunction = (context, handContext) => {
5
- const targetRay = controllers[handContext.hand]?.targetRay;
5
+ export const defaultComputeFunction = (_context, handContext) => {
6
+ const state = handContext.sourceType === 'controller'
7
+ ? getControllerState(handContext.hand)
8
+ : getHandState(handContext.hand);
9
+ const targetRay = state?.targetRay;
6
10
  if (targetRay === undefined)
7
11
  return;
8
- forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
- context.raycaster.set(targetRay.position, forward);
12
+ // Read origin/direction from matrixWorld so the ray is in real world space,
13
+ // even when an ancestor (e.g. <XROrigin>) has a non-identity transform.
14
+ // Force an update because this runs before the frame's scene.updateMatrixWorld.
15
+ targetRay.updateWorldMatrix(true, false);
16
+ origin.setFromMatrixPosition(targetRay.matrixWorld);
17
+ forward.set(0, 0, -1).transformDirection(targetRay.matrixWorld);
18
+ handContext.raycaster.set(origin, forward);
10
19
  };
@@ -1,7 +1,7 @@
1
1
  import type { Object3D } from 'three';
2
- import type { ControlsContext, HandContext } from './types.js';
3
- export declare const getHandContext: (hand: "left" | "right") => HandContext;
4
- export declare const setHandContext: (hand: "left" | "right", context: HandContext) => void;
2
+ import type { ControlsContext, HandContext, PointerSourceType } from './types.js';
3
+ export declare const getHandContext: (hand: "left" | "right", sourceType: PointerSourceType) => HandContext;
4
+ export declare const setHandContext: (hand: "left" | "right", sourceType: PointerSourceType, context: HandContext) => void;
5
5
  export declare const getControlsContext: () => ControlsContext;
6
6
  export declare const setControlsContext: (context: ControlsContext) => void;
7
7
  interface InternalContext {
@@ -1,14 +1,20 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  const handContextKeys = {
3
- left: Symbol('pointer-controls-context-left'),
4
- right: Symbol('pointer-controls-context-right')
3
+ left: {
4
+ controller: Symbol('pointer-controls-context-left-controller'),
5
+ hand: Symbol('pointer-controls-context-left-hand')
6
+ },
7
+ right: {
8
+ controller: Symbol('pointer-controls-context-right-controller'),
9
+ hand: Symbol('pointer-controls-context-right-hand')
10
+ }
5
11
  };
6
12
  const contextKey = Symbol('pointer-controls-context');
7
- export const getHandContext = (hand) => {
8
- return getContext(handContextKeys[hand]);
13
+ export const getHandContext = (hand, sourceType) => {
14
+ return getContext(handContextKeys[hand][sourceType]);
9
15
  };
10
- export const setHandContext = (hand, context) => {
11
- setContext(handContextKeys[hand], context);
16
+ export const setHandContext = (hand, sourceType, context) => {
17
+ setContext(handContextKeys[hand][sourceType], context);
12
18
  };
13
19
  export const getControlsContext = () => {
14
20
  return getContext(contextKey);
@@ -1,5 +1,6 @@
1
+ import { type CurrentWritable } from '@threlte/core';
1
2
  import { type ComputeFunction } from './compute.js';
2
- import type { FilterFunction } from './types.js';
3
+ import type { FilterFunction, IntersectionEvent } from './types.js';
3
4
  export type PointerControlsOptions = {
4
5
  enabled?: boolean;
5
6
  /**
@@ -22,6 +23,6 @@ export type PointerControlsOptions = {
22
23
  fixedStep?: number;
23
24
  };
24
25
  export declare const pointerControls: (handedness: "left" | "right", options?: PointerControlsOptions) => {
25
- enabled: import("@threlte/core").CurrentWritable<boolean>;
26
- hovered: Map<string, import("./types.js").IntersectionEvent>;
26
+ enabled: CurrentWritable<boolean>;
27
+ hovered: Map<string, IntersectionEvent>;
27
28
  };
@@ -4,45 +4,77 @@ import { defaultComputeFunction } from './compute.js';
4
4
  import { injectPointerControlsPlugin } from './plugin.svelte.js';
5
5
  import { setupPointerControls } from './setup.svelte.js';
6
6
  import { getControlsContext, getHandContext, setControlsContext, setHandContext, setInternalContext } from './context.js';
7
- import { pointerState } from '../../internal/state.svelte.js';
8
- let controlsCounter = 0;
7
+ import { pointerIntersection, pointerState } from '../../internal/state.svelte.js';
8
+ const aggregateStates = new Map();
9
9
  export const pointerControls = (handedness, options) => {
10
10
  if (getControlsContext() === undefined) {
11
11
  injectPointerControlsPlugin();
12
12
  setInternalContext();
13
- setControlsContext({
14
- interactiveObjects: [],
15
- raycaster: new Raycaster(),
16
- compute: options?.compute ?? defaultComputeFunction,
17
- filter: options?.filter
18
- });
13
+ setControlsContext({ interactiveObjects: [] });
19
14
  }
20
15
  const context = getControlsContext();
21
- if (getHandContext(handedness) === undefined) {
22
- const enabled = options?.enabled ?? true;
23
- const ctx = {
24
- hand: handedness,
25
- enabled: currentWritable(enabled),
26
- pointer: currentWritable(new Vector3()),
27
- pointerOverTarget: currentWritable(false),
28
- lastEvent: undefined,
29
- initialClick: [0, 0, 0],
30
- initialHits: [],
31
- hovered: new Map()
32
- };
33
- setHandContext(handedness, ctx);
34
- setupPointerControls(context, ctx, options?.fixedStep);
35
- }
36
- const handContext = getHandContext(handedness);
37
- observe.pre(() => [handContext.enabled], ([enabled]) => {
38
- controlsCounter += enabled ? 1 : -1;
39
- pointerState[handedness].enabled = controlsCounter > 0;
16
+ const aggregateState = aggregateStates.get(handedness) ??
17
+ (() => {
18
+ const state = {
19
+ enabled: currentWritable(options?.enabled ?? true),
20
+ hovered: new Map()
21
+ };
22
+ aggregateStates.set(handedness, state);
23
+ return state;
24
+ })();
25
+ const { enabled, hovered } = aggregateState;
26
+ let controllerContext = getHandContext(handedness, 'controller');
27
+ let handContext = getHandContext(handedness, 'hand');
28
+ const syncSharedState = () => {
29
+ hovered.clear();
30
+ for (const [id, event] of controllerContext.hovered) {
31
+ hovered.set(`controller:${id}`, event);
32
+ }
33
+ for (const [id, event] of handContext.hovered) {
34
+ hovered.set(`hand:${id}`, event);
35
+ }
36
+ // Shared handedness-level pointer visuals are currently controller-only:
37
+ // <Controller /> renders the cursor/ray from these globals, while hand
38
+ // pointer events are dispatched independently without a matching visual.
39
+ pointerState[handedness].hovering = controllerContext.pointerOverTarget.current;
40
+ pointerIntersection[handedness] = controllerContext.currentIntersection;
41
+ };
42
+ const createContext = (sourceType) => ({
43
+ hand: handedness,
44
+ sourceType,
45
+ enabled,
46
+ pointer: currentWritable(new Vector3()),
47
+ pointerOverTarget: currentWritable(false),
48
+ lastEvent: undefined,
49
+ initialClick: [0, 0, 0],
50
+ initialHits: [],
51
+ hovered: new Map(),
52
+ currentIntersection: undefined,
53
+ raycaster: new Raycaster(),
54
+ syncSharedState,
55
+ compute: options?.compute ?? defaultComputeFunction,
56
+ filter: options?.filter
40
57
  });
41
- observe.pre(() => [handContext.pointerOverTarget], ([hovering]) => {
42
- pointerState[handedness].hovering = hovering;
58
+ const setupContexts = [];
59
+ if (controllerContext === undefined) {
60
+ controllerContext = createContext('controller');
61
+ setHandContext(handedness, 'controller', controllerContext);
62
+ setupContexts.push(controllerContext);
63
+ }
64
+ if (handContext === undefined) {
65
+ handContext = createContext('hand');
66
+ setHandContext(handedness, 'hand', handContext);
67
+ setupContexts.push(handContext);
68
+ }
69
+ for (const setupContext of setupContexts) {
70
+ setupPointerControls(context, setupContext, options?.fixedStep);
71
+ }
72
+ observe.pre(() => [enabled], ([nextEnabled]) => {
73
+ pointerState[handedness].enabled = nextEnabled;
43
74
  });
75
+ syncSharedState();
44
76
  return {
45
- enabled: handContext.enabled,
46
- hovered: handContext.hovered
77
+ enabled,
78
+ hovered
47
79
  };
48
80
  };
@@ -5,11 +5,6 @@ export const injectPointerControlsPlugin = () => {
5
5
  injectPlugin('threlte-pointer-controls', (args) => {
6
6
  if (!isInstanceOf(args.ref, 'Object3D'))
7
7
  return;
8
- const hasEventHandlers = Object.entries(args.props).some(([key, value]) => {
9
- return value !== undefined && events.includes(key);
10
- });
11
- if (!hasEventHandlers)
12
- return;
13
8
  const { addInteractiveObject, removeInteractiveObject } = usePointerControls();
14
9
  $effect.pre(() => {
15
10
  const ref = args.ref;
@@ -1,21 +1,36 @@
1
1
  import { Vector3 } from 'three';
2
- import { observe } from '@threlte/core';
2
+ import { fromStore } from 'svelte/store';
3
3
  import { getInternalContext } from './context.js';
4
- import { controllers } from '../../hooks/useController.svelte.js';
5
- import { useHand } from '../../hooks/useHand.svelte.js';
4
+ import { addSubscriber } from '../../internal/inputSources.svelte.js';
6
5
  import { useFixed } from '../../internal/useFixed.js';
7
- import { isPresenting, pointerIntersection } from '../../internal/state.svelte.js';
6
+ import { isPresenting } from '../../internal/state.svelte.js';
7
+ // Hover identity must match the dedup key used in `getHits`, otherwise the ID
8
+ // changes mid-hover (e.g. the hit's face index changes as the ray sweeps a
9
+ // plain mesh) and the object flickers between pointerout/pointerenter every
10
+ // frame.
8
11
  const getIntersectionId = (intersection) => {
9
- return `${(intersection.eventObject || intersection.object).uuid}|${intersection.index}|${intersection.instanceId}`;
12
+ const target = intersection.eventObject ?? intersection.object;
13
+ if (intersection.instanceId !== undefined) {
14
+ return `${target.uuid}|${intersection.instanceId}`;
15
+ }
16
+ if (intersection.object.isPoints) {
17
+ return `${target.uuid}|${intersection.index}`;
18
+ }
19
+ return target.uuid;
10
20
  };
11
21
  const EPSILON = 0.0001;
22
+ // Starts high enough to stay clear of browser-assigned DOM pointerIds in the
23
+ // same session. Incremented per setupPointerControls call so each hand — and
24
+ // each reconnect — gets a distinct id.
25
+ let nextPointerId = 1001;
12
26
  export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) => {
13
27
  const handedness = handContext.hand;
14
- const controller = $derived(controllers[handedness]);
15
- const hand = useHand(handedness);
28
+ const pointerId = nextPointerId++;
29
+ const enabled = fromStore(handContext.enabled);
16
30
  const { dispatchers } = getInternalContext();
17
31
  let hits = [];
18
- const lastPosition = new Vector3();
32
+ const lastRayOrigin = new Vector3();
33
+ const lastRayDirection = new Vector3();
19
34
  const handlePointerDown = (event) => {
20
35
  // Save initial coordinates on pointer-down
21
36
  const [hit] = hits;
@@ -29,24 +44,18 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
29
44
  handleEvent('onpointerup', event);
30
45
  };
31
46
  const handleClick = (event) => {
32
- // If a click yields no results, pass it back to the user as a miss
33
- // Missed events have to come first in order to establish user-land side-effect clean up
34
- if (hits.length === 0) {
35
- pointerMissed(context.interactiveObjects, event);
36
- }
37
47
  handleEvent('onclick', event);
38
48
  };
39
49
  function cancelPointer(intersections) {
40
50
  if (handContext.hovered.size === 0)
41
51
  return;
52
+ const currentIds = new Set();
53
+ for (const hit of intersections) {
54
+ currentIds.add(getIntersectionId(hit));
55
+ }
42
56
  const toRemove = [];
43
57
  for (const [id, hoveredObj] of handContext.hovered) {
44
- // When no objects were hit or the hovered object wasn't found underneath the cursor
45
- // we call pointerout and delete the object from the hovered elements map
46
- if (intersections.length === 0 ||
47
- !intersections.some((hit) => hit.object === hoveredObj.object &&
48
- hit.index === hoveredObj.index &&
49
- hit.instanceId === hoveredObj.instanceId)) {
58
+ if (!currentIds.has(id)) {
50
59
  toRemove.push([id, hoveredObj]);
51
60
  }
52
61
  }
@@ -64,10 +73,11 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
64
73
  if (handContext.hovered.size === 0) {
65
74
  handContext.pointerOverTarget.set(false);
66
75
  }
76
+ handContext.syncSharedState();
67
77
  }
68
78
  const getHits = () => {
69
79
  const intersections = [];
70
- const rawHits = context.raycaster.intersectObjects(context.interactiveObjects, true);
80
+ const rawHits = handContext.raycaster.intersectObjects(context.interactiveObjects, true);
71
81
  const seen = new Set();
72
82
  // Deduplicate hits by object. When recursive=true, intersectObjects searches
73
83
  // each registered object's full subtree, so a child that is itself registered
@@ -87,8 +97,9 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
87
97
  seen.add(key);
88
98
  return true;
89
99
  });
90
- const filtered = context.filter === undefined ? hits : context.filter(hits, context, handContext);
91
- pointerIntersection[handedness] = filtered[0];
100
+ const filtered = handContext.filter === undefined ? hits : handContext.filter(hits, context, handContext);
101
+ handContext.currentIntersection = filtered[0];
102
+ handContext.syncSharedState();
92
103
  // Bubble up the events, find the event source (eventObject)
93
104
  for (const hit of filtered) {
94
105
  let eventObject = hit.object;
@@ -104,17 +115,23 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
104
115
  };
105
116
  function pointerMissed(objects, event) {
106
117
  for (const object of objects) {
107
- dispatchers.get(object)?.pointermissed?.(event);
118
+ dispatchers.get(object)?.onpointermissed?.(event);
108
119
  }
109
120
  }
110
121
  function processHits() {
111
- context.compute(context, handContext);
122
+ handContext.compute(context, handContext);
112
123
  return getHits();
113
124
  }
114
125
  const handleEvent = (name, event) => {
115
126
  const isPointerMove = name === 'onpointermove';
116
127
  const isClickEvent = name === 'onclick' || name === 'oncontextmenu';
117
- // Take care of unhover
128
+ // Fire pointermissed for objects that were not under the pointer at pointerdown.
129
+ // Must come before the dispatch loop so user-land cleanup runs first.
130
+ if (isClickEvent) {
131
+ pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
132
+ }
133
+ // Update hover state before dispatch so that pointerout/pointerleave fire
134
+ // before pointerover/pointerenter on newly hit objects.
118
135
  if (isPointerMove)
119
136
  cancelPointer(hits);
120
137
  let stopped = false;
@@ -127,6 +144,8 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
127
144
  stopped,
128
145
  ...hit,
129
146
  intersections: hits,
147
+ handedness,
148
+ pointerId,
130
149
  stopPropagation() {
131
150
  stopped = true;
132
151
  intersectionEvent.stopped = true;
@@ -140,7 +159,7 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
140
159
  delta: 0,
141
160
  nativeEvent: event,
142
161
  pointer: handContext.pointer.current,
143
- ray: context.raycaster.ray
162
+ ray: handContext.raycaster.ray
144
163
  };
145
164
  if (isPointerMove) {
146
165
  // Move event ...
@@ -157,6 +176,7 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
157
176
  events.onpointerover?.(intersectionEvent);
158
177
  events.onpointerenter?.(intersectionEvent);
159
178
  handContext.pointerOverTarget.set(true);
179
+ handContext.syncSharedState();
160
180
  }
161
181
  else if (hoveredItem.stopped) {
162
182
  // If the object was previously hovered and stopped, we shouldn't allow other items to proceed
@@ -166,15 +186,11 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
166
186
  // Call pointer move
167
187
  events.onpointermove?.(intersectionEvent);
168
188
  }
169
- else if ((!isClickEvent || handContext.initialHits.includes(hit.eventObject)) &&
170
- events[name] !== undefined) {
171
- // Missed events have to come first
172
- pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
173
- // Call the event
174
- events[name]?.(intersectionEvent);
175
- }
176
- else if (isClickEvent && handContext.initialHits.includes(hit.eventObject)) {
177
- pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
189
+ else if (events[name] !== undefined) {
190
+ // All other events
191
+ if (!isClickEvent || handContext.initialHits.includes(hit.eventObject)) {
192
+ events[name]?.(intersectionEvent);
193
+ }
178
194
  }
179
195
  if (stopped)
180
196
  break dispatchEvents;
@@ -182,61 +198,59 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
182
198
  };
183
199
  const { start, stop } = useFixed(() => {
184
200
  hits = processHits();
185
- const targetRay = controller?.targetRay;
186
- if (targetRay === undefined)
187
- return;
188
- if (targetRay.position.distanceTo(lastPosition) > EPSILON) {
201
+ const ray = handContext.raycaster.ray;
202
+ if (ray.origin.distanceToSquared(lastRayOrigin) > EPSILON * EPSILON ||
203
+ 1 - ray.direction.dot(lastRayDirection) > EPSILON) {
189
204
  handleEvent('onpointermove');
190
205
  }
191
- lastPosition.copy(targetRay.position);
206
+ lastRayOrigin.copy(ray.origin);
207
+ lastRayDirection.copy(ray.direction);
192
208
  }, {
193
209
  fixedStep,
194
210
  autoStart: false
195
211
  });
196
- observe.pre(() => [controller, handContext.enabled], ([controller, $enabled]) => {
197
- if (controller === undefined)
198
- return;
199
- const removeHandlers = () => {
200
- controller.targetRay.removeEventListener('selectstart', handlePointerDown);
201
- controller.targetRay.removeEventListener('selectend', handlePointerUp);
202
- controller.targetRay.removeEventListener('select', handleClick);
203
- };
204
- if ($enabled) {
205
- controller.targetRay.addEventListener('selectstart', handlePointerDown);
206
- controller.targetRay.addEventListener('selectend', handlePointerUp);
207
- controller.targetRay.addEventListener('select', handleClick);
208
- return removeHandlers;
212
+ $effect.pre(() => {
213
+ if (isPresenting.current && enabled.current) {
214
+ start();
209
215
  }
210
216
  else {
211
- removeHandlers();
212
- return;
217
+ stop();
218
+ hits = [];
219
+ handContext.currentIntersection = undefined;
220
+ cancelPointer([]);
221
+ handContext.syncSharedState();
213
222
  }
214
223
  });
215
- observe.pre(() => [hand, handContext.enabled], ([input, enabled]) => {
216
- if (input === undefined)
224
+ $effect.pre(() => {
225
+ if (handContext.sourceType !== 'controller')
217
226
  return;
218
- const removeHandlers = () => {
219
- input.hand.removeEventListener('pinchstart', handlePointerDown);
220
- input.hand.removeEventListener('pinchend', handlePointerUp);
221
- input.hand.removeEventListener('pinchend', handleClick);
222
- };
223
- if (enabled) {
224
- input.hand.addEventListener('pinchstart', handlePointerDown);
225
- input.hand.addEventListener('pinchend', handlePointerUp);
226
- input.hand.addEventListener('pinchend', handleClick);
227
- return removeHandlers;
228
- }
229
- else {
230
- removeHandlers();
227
+ if (!enabled.current)
231
228
  return;
232
- }
229
+ return addSubscriber({
230
+ type: 'controller',
231
+ handedness,
232
+ callbacks: {
233
+ onselectstart: handlePointerDown,
234
+ onselectend: handlePointerUp,
235
+ onselect: handleClick
236
+ }
237
+ });
233
238
  });
234
- observe.pre(() => [isPresenting.current, handContext.enabled], ([isPresenting, $enabled]) => {
235
- if (isPresenting && $enabled) {
236
- start();
237
- }
238
- else {
239
- stop();
240
- }
239
+ $effect.pre(() => {
240
+ if (handContext.sourceType !== 'hand')
241
+ return;
242
+ if (!enabled.current)
243
+ return;
244
+ return addSubscriber({
245
+ type: 'hand',
246
+ handedness,
247
+ callbacks: {
248
+ onpinchstart: handlePointerDown,
249
+ onpinchend: ((event) => {
250
+ handlePointerUp(event);
251
+ handleClick(event);
252
+ })
253
+ }
254
+ });
241
255
  });
242
256
  };
@@ -1,6 +1,7 @@
1
1
  import type { Intersection as ThreeIntersection, Object3D, Vector3, Ray, Raycaster, Event } from 'three';
2
2
  import type { CurrentWritable } from '@threlte/core';
3
3
  import type { ComputeFunction } from './compute.js';
4
+ export type PointerSourceType = 'controller' | 'hand';
4
5
  export type Properties<T> = Pick<T, {
5
6
  [K in keyof T]: T[K] extends (_: any) => any ? never : K;
6
7
  }[keyof T]>;
@@ -13,6 +14,11 @@ export interface IntersectionEvent extends Intersection {
13
14
  eventObject: Object3D;
14
15
  /** An array of intersections */
15
16
  intersections: Intersection[];
17
+ /** Which hand dispatched this event. Each controller/hand fires enter/leave/etc. independently. */
18
+ handedness: 'left' | 'right';
19
+ /** Stable identifier for this pointer source. Mirrors DOM PointerEvent.pointerId so downstream
20
+ * consumers that key per-pointer state by id can distinguish hands / reconnects. */
21
+ pointerId: number;
16
22
  /** Normalized event coordinates */
17
23
  pointer: Vector3;
18
24
  /** Delta between first click and this event */
@@ -29,12 +35,11 @@ export interface IntersectionEvent extends Intersection {
29
35
  export type FilterFunction = (items: Intersection[], state: ControlsContext, handState: HandContext) => Intersection[];
30
36
  export type ControlsContext = {
31
37
  interactiveObjects: Object3D[];
32
- raycaster: Raycaster;
33
- compute: ComputeFunction;
34
- filter?: FilterFunction | undefined;
35
38
  };
36
39
  export type HandContext = {
37
40
  hand: 'left' | 'right';
41
+ /** Physical XR source this runtime tracks for the handedness. */
42
+ sourceType: PointerSourceType;
38
43
  enabled: CurrentWritable<boolean>;
39
44
  pointer: CurrentWritable<Vector3>;
40
45
  pointerOverTarget: CurrentWritable<boolean>;
@@ -42,6 +47,14 @@ export type HandContext = {
42
47
  initialClick: [x: number, y: number, z: number];
43
48
  initialHits: Object3D[];
44
49
  hovered: Map<string, IntersectionEvent>;
50
+ currentIntersection: Intersection | undefined;
51
+ /** Per-hand raycaster — keeps `intersectionEvent.ray` consistent across the
52
+ * tick even when the other hand also raycasts. */
53
+ raycaster: Raycaster;
54
+ /** Syncs aggregate handedness-level state after this source mutates hover or hit state. */
55
+ syncSharedState: () => void;
56
+ compute: ComputeFunction;
57
+ filter?: FilterFunction | undefined;
45
58
  };
46
59
  export interface PointerCaptureTarget {
47
60
  intersection: Intersection;
@@ -7,5 +7,6 @@ export const events = [
7
7
  'onpointerout',
8
8
  'onpointerenter',
9
9
  'onpointerleave',
10
- 'onpointermove'
10
+ 'onpointermove',
11
+ 'onpointermissed'
11
12
  ];
@@ -1,3 +1,3 @@
1
1
  import type { Context, HandContext } from './context.js';
2
2
  export type ComputeFunction = (context: Context, handContext: HandContext) => void;
3
- export declare const defaultComputeFunction: (context: Context, handContext: HandContext) => void;
3
+ export declare const defaultComputeFunction: (_context: Context, handContext: HandContext) => void;
@@ -1,10 +1,17 @@
1
1
  import { Vector3 } from 'three';
2
2
  import { controllers } from '../../hooks/useController.svelte.js';
3
+ import { hands } from '../../hooks/useHand.svelte.js';
4
+ const origin = new Vector3();
3
5
  const forward = new Vector3();
4
- export const defaultComputeFunction = (context, handContext) => {
5
- const targetRay = controllers[handContext.hand]?.targetRay;
6
+ export const defaultComputeFunction = (_context, handContext) => {
7
+ const targetRay = controllers[handContext.hand]?.targetRay ?? hands[handContext.hand]?.targetRay;
6
8
  if (targetRay === undefined)
7
9
  return;
8
- forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
- context.raycaster.set(targetRay.position, forward);
10
+ // Read origin/direction from matrixWorld so the ray is in real world space,
11
+ // even when an ancestor (e.g. <XROrigin>) has a non-identity transform.
12
+ // Force an update because this runs before the frame's scene.updateMatrixWorld.
13
+ targetRay.updateWorldMatrix(true, false);
14
+ origin.setFromMatrixPosition(targetRay.matrixWorld);
15
+ forward.set(0, 0, -1).transformDirection(targetRay.matrixWorld);
16
+ handContext.raycaster.set(origin, forward);
10
17
  };
@@ -1,6 +1,5 @@
1
1
  import { type Mesh, Raycaster, type Intersection } from 'three';
2
2
  import type { CurrentWritable } from '@threlte/core';
3
- import type { TeleportControlsOptions } from './index.js';
4
3
  export type ComputeFunction = (context: Context, handContext: HandContext) => void;
5
4
  export type TeleportEvents = Record<string, (arg: unknown) => void>;
6
5
  export interface Context {
@@ -8,8 +7,6 @@ export interface Context {
8
7
  surfaces: Map<string, Mesh>;
9
8
  blockers: Map<string, Mesh>;
10
9
  dispatchers: WeakMap<Mesh, Record<string, (arg: unknown) => void>>;
11
- raycaster: Raycaster;
12
- compute: ComputeFunction;
13
10
  addBlocker: (mesh: Mesh) => void;
14
11
  removeBlocker: (mesh: Mesh) => void;
15
12
  addSurface: (mesh: Mesh, events: TeleportEvents) => void;
@@ -20,8 +17,11 @@ export interface HandContext {
20
17
  enabled: CurrentWritable<boolean>;
21
18
  active: CurrentWritable<boolean>;
22
19
  hovered: CurrentWritable<Intersection | undefined>;
20
+ /** Per-hand raycaster — keeps intersection state isolated between hands. */
21
+ raycaster: Raycaster;
22
+ compute: ComputeFunction;
23
23
  }
24
24
  export declare const getHandContext: (hand: "left" | "right") => HandContext;
25
25
  export declare const setHandContext: (hand: "left" | "right", context: HandContext) => void;
26
26
  export declare const useTeleportControls: () => Context;
27
- export declare const createTeleportContext: (compute: TeleportControlsOptions["compute"]) => Context;
27
+ export declare const createTeleportContext: () => Context;