@threlte/xr 1.5.3 → 1.5.5

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.
@@ -75,7 +75,8 @@
75
75
  const handedness = $derived<'left' | 'right'>(left ? 'left' : right ? 'right' : (hand ?? 'left'))
76
76
 
77
77
  $effect.pre(() => {
78
- controllerEvents[handedness] = {
78
+ const key = handedness
79
+ controllerEvents[key] = {
79
80
  onconnected,
80
81
  ondisconnected,
81
82
  onselect,
@@ -87,7 +88,7 @@
87
88
  }
88
89
 
89
90
  return () => {
90
- controllerEvents[handedness] = undefined
91
+ controllerEvents[key] = undefined
91
92
  }
92
93
  })
93
94
 
@@ -116,7 +117,10 @@
116
117
  {/if}
117
118
 
118
119
  {#if targetRay}
119
- <T is={targetRay}>
120
+ <T
121
+ is={targetRay}
122
+ attach={scene}
123
+ >
120
124
  {@render targetRaySnippet?.()}
121
125
 
122
126
  {#if hasPointerControls || hasTeleportControls}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Group } from 'three'
3
- import { T, useThrelte, useTask } from '@threlte/core'
3
+ import { T, useThrelte, useTask, useStage } from '@threlte/core'
4
4
  import type { XRHandEvents } from '../types.js'
5
5
  import { isHandTracking, handEvents } from '../internal/state.svelte.js'
6
6
  import { hands } from '../hooks/useHand.svelte.js'
@@ -45,12 +45,13 @@
45
45
  wrist
46
46
  }: Props = $props()
47
47
 
48
- const { scene, renderer, scheduler, renderStage } = useThrelte()
48
+ const { scene, renderer, renderStage } = useThrelte()
49
49
 
50
50
  const handedness = $derived<'left' | 'right'>(left ? 'left' : right ? 'right' : (hand ?? 'left'))
51
51
 
52
52
  $effect.pre(() => {
53
- handEvents[handedness] = {
53
+ const key = handedness
54
+ handEvents[key] = {
54
55
  onconnected,
55
56
  ondisconnected,
56
57
  onpinchend,
@@ -58,10 +59,12 @@
58
59
  }
59
60
 
60
61
  return () => {
61
- handEvents[handedness] = undefined
62
+ handEvents[key] = undefined
62
63
  }
63
64
  })
64
65
 
66
+ const stage = useStage(Symbol('xr-hand-stage'), { before: renderStage })
67
+
65
68
  const group = new Group()
66
69
 
67
70
  /**
@@ -90,7 +93,7 @@
90
93
  group.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w)
91
94
  },
92
95
  {
93
- stage: scheduler.createStage(Symbol('xr-hand-stage'), { before: renderStage }),
96
+ stage,
94
97
  running: () =>
95
98
  isHandTracking.current &&
96
99
  (wrist !== undefined || children !== undefined) &&
@@ -114,7 +117,10 @@
114
117
  </T>
115
118
 
116
119
  {#if targetRay !== undefined}
117
- <T is={xrHand.targetRay}>
120
+ <T
121
+ is={xrHand.targetRay}
122
+ attach={scene}
123
+ >
118
124
  {@render targetRay()}
119
125
  </T>
120
126
  {/if}
@@ -21,7 +21,7 @@ This should be placed within a Threlte `<Canvas />`.
21
21
  import type { EventListener, WebXRManager, Event as ThreeEvent } from 'three'
22
22
  import type { XRHandModelFactory } from 'three/examples/jsm/webxr/XRHandModelFactory.js'
23
23
  import type { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js'
24
- import type { Snippet } from 'svelte'
24
+ import { untrack, type Snippet } from 'svelte'
25
25
  import { useThrelte } from '@threlte/core'
26
26
  import {
27
27
  isHandTracking,
@@ -78,6 +78,9 @@ This should be placed within a Threlte `<Canvas />`.
78
78
 
79
79
  /** Called when available inputsources change */
80
80
  oninputsourceschange?: (event: XRSessionEvent) => void
81
+
82
+ /** Called when the session frame rate changes. */
83
+ onframeratechange?: (event: XRSessionEvent) => void
81
84
  }
82
85
 
83
86
  let {
@@ -88,6 +91,7 @@ This should be placed within a Threlte `<Canvas />`.
88
91
  onsessionend,
89
92
  onvisibilitychange,
90
93
  oninputsourceschange,
94
+ onframeratechange,
91
95
  fallback,
92
96
  children,
93
97
  handFactory,
@@ -96,8 +100,6 @@ This should be placed within a Threlte `<Canvas />`.
96
100
 
97
101
  const { renderer, renderMode } = useThrelte()
98
102
 
99
- let originalRenderMode = $renderMode
100
-
101
103
  setupRaf()
102
104
  setupHeadset()
103
105
  setupControllers(controllerFactory)
@@ -105,12 +107,19 @@ This should be placed within a Threlte `<Canvas />`.
105
107
 
106
108
  const handleSessionStart: EventListener<object, 'sessionstart', WebXRManager> = (event) => {
107
109
  isPresenting.current = true
110
+ const currentSession = renderer.xr.getSession()
111
+ if (currentSession !== null) {
112
+ isHandTracking.current = Array.from(currentSession.inputSources).some(
113
+ (source) => source.hand !== undefined
114
+ )
115
+ }
108
116
  onsessionstart?.(event)
109
117
  }
110
118
 
111
119
  const handleSessionEnd = (event: XRSessionEvent) => {
112
120
  onsessionend?.(event)
113
121
  isPresenting.current = false
122
+ isHandTracking.current = false
114
123
  session.current = undefined
115
124
  }
116
125
 
@@ -119,12 +128,12 @@ This should be placed within a Threlte `<Canvas />`.
119
128
  }
120
129
 
121
130
  const handleInputSourcesChange = (event: XRInputSourcesChangeEvent) => {
122
- isHandTracking.current = Object.values(event.session.inputSources).some((source) => source.hand)
131
+ isHandTracking.current = Array.from(event.session.inputSources).some((source) => source.hand)
123
132
  oninputsourceschange?.(event)
124
133
  }
125
134
 
126
135
  const handleFramerateChange = (event: XRSessionEvent) => {
127
- onvisibilitychange?.(event)
136
+ onframeratechange?.(event)
128
137
  }
129
138
 
130
139
  $effect(() => {
@@ -148,11 +157,15 @@ This should be placed within a Threlte `<Canvas />`.
148
157
  })
149
158
 
150
159
  $effect.pre(() => {
151
- if (isPresenting.current) {
152
- originalRenderMode = renderMode.current
153
- renderMode.set('always')
154
- } else {
155
- renderMode.set(originalRenderMode)
160
+ if (!isPresenting.current) return
161
+
162
+ // Capture the mode from before we forced 'always' so it survives
163
+ // any manual renderMode changes made during the session.
164
+ const saved = untrack(() => renderMode.current)
165
+ renderMode.set('always')
166
+
167
+ return () => {
168
+ renderMode.set(saved)
156
169
  }
157
170
  })
158
171
 
@@ -1,7 +1,7 @@
1
1
  import type { WebXRManager, Event as ThreeEvent } from 'three';
2
2
  import type { XRHandModelFactory } from 'three/examples/jsm/webxr/XRHandModelFactory.js';
3
3
  import type { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js';
4
- import type { Snippet } from 'svelte';
4
+ import { type Snippet } from 'svelte';
5
5
  interface Props {
6
6
  /**
7
7
  * Enables foveated rendering. Default is `1`, the three.js default.
@@ -36,6 +36,8 @@ interface Props {
36
36
  onvisibilitychange?: (event: XRSessionEvent) => void;
37
37
  /** Called when available inputsources change */
38
38
  oninputsourceschange?: (event: XRSessionEvent) => void;
39
+ /** Called when the session frame rate changes. */
40
+ onframeratechange?: (event: XRSessionEvent) => void;
39
41
  }
40
42
  /**
41
43
  * `<XR />` is a WebXR manager that configures your scene for XR rendering and interaction.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Color, DoubleSide, RawShaderMaterial, type ColorRepresentation } from 'three'
2
+ import { Color, DoubleSide, ShaderMaterial, type ColorRepresentation } from 'three'
3
3
  import { T } from '@threlte/core'
4
4
 
5
5
  interface Props {
@@ -11,10 +11,6 @@
11
11
  const { color = new Color('white'), size = 0.03, thickness = 0.035 }: Props = $props()
12
12
 
13
13
  const vertexShader = `
14
- uniform mat4 projectionMatrix;
15
- uniform mat4 modelViewMatrix;
16
- attribute vec2 uv;
17
- attribute vec3 position;
18
14
  varying vec2 vUv;
19
15
  void main() {
20
16
  vUv = uv;
@@ -23,14 +19,13 @@
23
19
  `
24
20
 
25
21
  const fragmentShader = `
26
- precision mediump float;
27
22
  uniform float thickness;
28
23
  uniform vec3 color;
29
24
  varying vec2 vUv;
30
25
  void main() {
31
- float radius = 0.1;
32
- float dist = length(vUv - vec2(0.5));
33
- float alpha = 1.0 - step(thickness, abs(distance(vUv, vec2(0.5)) - 0.25));
26
+ float d = abs(distance(vUv, vec2(0.5)) - 0.25);
27
+ float edge = fwidth(d);
28
+ float alpha = 1.0 - smoothstep(thickness - edge, thickness + edge, d);
34
29
  gl_FragColor = vec4(color, alpha);
35
30
  }
36
31
  `
@@ -40,7 +35,7 @@
40
35
  color: { value: color }
41
36
  }
42
37
 
43
- const shaderMaterial = new RawShaderMaterial({
38
+ const shaderMaterial = new ShaderMaterial({
44
39
  vertexShader,
45
40
  fragmentShader,
46
41
  uniforms,
@@ -11,6 +11,7 @@
11
11
 
12
12
  <script lang="ts">
13
13
  import { T, useTask, useThrelte } from '@threlte/core'
14
+ import { untrack } from 'svelte'
14
15
  import { pointerIntersection, pointerState } from '../../internal/state.svelte.js'
15
16
  import Cursor from './Cursor.svelte'
16
17
  import type { Snippet } from 'svelte'
@@ -28,6 +29,8 @@
28
29
 
29
30
  const ref = new Group()
30
31
 
32
+ const SURFACE_OFFSET = 0.002
33
+
31
34
  useTask(
32
35
  () => {
33
36
  if (intersection === undefined) {
@@ -43,17 +46,28 @@
43
46
 
44
47
  normalMatrix.getNormalMatrix(object.matrixWorld)
45
48
  worldNormal.copy(face.normal).applyMatrix3(normalMatrix).normalize()
46
- ref.lookAt(vec3.addVectors(point, worldNormal))
49
+
50
+ // Float the reticle just above the surface so it doesn't z-fight
51
+ // with the coplanar face underneath.
52
+ ref.position.addScaledVector(worldNormal, SURFACE_OFFSET)
53
+
54
+ ref.lookAt(vec3.addVectors(ref.position, worldNormal))
47
55
  },
48
56
  {
49
57
  running: () => hovering && intersection !== undefined
50
58
  }
51
59
  )
52
60
 
61
+ // Snap to the hit point on hover entry so the reticle doesn't visibly
62
+ // fly in from its previous location. `intersection` is read untracked
63
+ // so this only reruns on hover transitions, not every frame.
53
64
  $effect.pre(() => {
54
- if (hovering && intersection) {
55
- ref.position.copy(intersection.point)
56
- }
65
+ if (!hovering) return
66
+ untrack(() => {
67
+ if (intersection) {
68
+ ref.position.copy(intersection.point)
69
+ }
70
+ })
57
71
  })
58
72
  </script>
59
73
 
@@ -40,7 +40,10 @@
40
40
  if (face) {
41
41
  normalMatrix.getNormalMatrix(object.matrixWorld)
42
42
  worldNormal.copy(face.normal).applyMatrix3(normalMatrix).normalize()
43
- ref.lookAt(vec3.addVectors(point, worldNormal))
43
+ // lookAt from the lerped position (matches PointerCursor) — using the
44
+ // raw hit point here causes orientation to wobble while position is
45
+ // still easing toward the target.
46
+ ref.lookAt(vec3.addVectors(ref.position, worldNormal))
44
47
  }
45
48
  },
46
49
  {
@@ -1,5 +1,6 @@
1
1
  import { useTask, useThrelte } from '@threlte/core';
2
2
  import { hands } from './useHand.svelte.js';
3
+ import { isPresenting } from '../internal/state.svelte.js';
3
4
  import { toCurrentReadable } from './currentReadable.svelte.js';
4
5
  /**
5
6
  * Provides a reference to a requested hand joint, once available.
@@ -10,11 +11,18 @@ export const useHandJoint = (handedness, joint) => {
10
11
  let jointSpace = $state.raw();
11
12
  useTask(() => {
12
13
  const space = xrhand?.hand.joints[joint];
13
- // The joint radius is a good indicator that the joint is ready
14
+ // The joint radius is a good indicator that the joint is ready.
15
+ // Re-check each frame so we pick up reconnects and clear on disconnect.
14
16
  if (space?.jointRadius !== undefined) {
15
- jointSpace = space;
17
+ if (jointSpace !== space) {
18
+ jointSpace = space;
19
+ invalidate();
20
+ }
21
+ }
22
+ else if (jointSpace !== undefined) {
23
+ jointSpace = undefined;
16
24
  invalidate();
17
25
  }
18
- }, { running: () => jointSpace === undefined });
26
+ }, { running: () => isPresenting.current });
19
27
  return toCurrentReadable(() => jointSpace);
20
28
  };
@@ -9,12 +9,12 @@ export const setupControllers = (factory) => {
9
9
  const hasHands = useHandTrackingState();
10
10
  const targetRaySpaces = [xr.getController(0), xr.getController(1)];
11
11
  const indexMap = new Map();
12
+ const modelFactory = factory ?? new XRControllerModelFactory();
12
13
  targetRaySpaces.forEach((targetRay, index) => {
13
- const model = (factory ?? new XRControllerModelFactory()).createControllerModel(targetRay);
14
14
  indexMap.set(targetRay, {
15
15
  targetRay,
16
16
  grip: xr.getControllerGrip(index),
17
- model
17
+ model: modelFactory.createControllerModel(targetRay)
18
18
  });
19
19
  });
20
20
  onMount(() => {
@@ -25,8 +25,13 @@ export const setupControllers = (factory) => {
25
25
  controllerEvents[data.handedness]?.[`on${event.type}`]?.(event);
26
26
  };
27
27
  function handleConnected(event) {
28
- const { model, targetRay, grip } = indexMap.get(this);
29
28
  const { data: inputSource } = event;
29
+ // The targetRaySpace 'connected' event fires for both controller and
30
+ // hand-tracking input sources. The controllers slot represents a physical
31
+ // controller — setupHands handles the hand-tracking side.
32
+ if (inputSource.hand)
33
+ return;
34
+ const { model, targetRay, grip } = indexMap.get(this);
30
35
  controllers[event.data.handedness] = {
31
36
  inputSource,
32
37
  targetRay,
@@ -9,12 +9,12 @@ export const setupHands = (factory) => {
9
9
  const hasHands = useHandTrackingState();
10
10
  const handSpaces = [xr.getHand(0), xr.getHand(1)];
11
11
  const map = new Map();
12
+ const modelFactory = factory ?? new XRHandModelFactory();
12
13
  handSpaces.forEach((handSpace, index) => {
13
- const model = (factory ?? new XRHandModelFactory()).createHandModel(handSpace, 'mesh');
14
14
  map.set(handSpace, {
15
15
  hand: handSpace,
16
16
  targetRay: xr.getController(index),
17
- model
17
+ model: modelFactory.createHandModel(handSpace, 'mesh')
18
18
  });
19
19
  });
20
20
  onMount(() => {
@@ -42,7 +42,10 @@ export const setupHands = (factory) => {
42
42
  }
43
43
  const handleDisconnected = (event) => {
44
44
  dispatch(event);
45
- hands[event.data.handedness] = undefined;
45
+ const { handedness } = event.data;
46
+ if (handedness === 'left' || handedness === 'right') {
47
+ hands[handedness] = undefined;
48
+ }
46
49
  };
47
50
  for (const handSpace of handSpaces) {
48
51
  handSpace.addEventListener('connected', handleConnected);
@@ -1,11 +1,11 @@
1
1
  import { Group } from 'three';
2
- import { useThrelte, useTask } from '@threlte/core';
2
+ import { useThrelte, useTask, useStage } from '@threlte/core';
3
3
  import { isPresenting } from './state.svelte.js';
4
4
  export const headset = new Group();
5
5
  export const setupHeadset = () => {
6
- const { renderer, camera, scheduler, renderStage } = useThrelte();
6
+ const { renderer, camera, renderStage } = useThrelte();
7
+ const stage = useStage(Symbol('xr-headset-stage'), { before: renderStage });
7
8
  const { xr } = renderer;
8
- const stage = scheduler.createStage(Symbol('xr-headset-stage'), { before: renderStage });
9
9
  useTask(() => {
10
10
  const space = xr.getReferenceSpace();
11
11
  if (space === null)
@@ -15,10 +15,10 @@ export const toggleXRSession = async (sessionMode, sessionInit, force) => {
15
15
  return currentSession;
16
16
  if (force === 'exit' && !hasSession)
17
17
  return;
18
- // Exit a session if entered
18
+ // Exit a session if entered. `session.current` is cleared by XR.svelte's
19
+ // `handleSessionEnd` when the 'end' event fires — don't duplicate that here.
19
20
  if (hasSession) {
20
21
  await currentSession.end();
21
- session.current = undefined;
22
22
  return;
23
23
  }
24
24
  if (xr.current === undefined) {
@@ -5,6 +5,10 @@ export const defaultComputeFunction = (context, handContext) => {
5
5
  const targetRay = controllers[handContext.hand]?.targetRay;
6
6
  if (targetRay === undefined)
7
7
  return;
8
+ // `<Controller>` attaches targetRay to the scene root so local === world;
9
+ // we can read `.position`/`.quaternion` directly without a matrixWorld
10
+ // roundtrip (which would force-recompose the matrix three.js writes from
11
+ // the XR pose and introduce a drift against the visible render).
8
12
  forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
13
  context.raycaster.set(targetRay.position, forward);
10
14
  };
@@ -15,6 +15,8 @@ export const usePointerControls = () => {
15
15
  };
16
16
  const removeInteractiveObject = (object) => {
17
17
  const index = context.interactiveObjects.indexOf(object);
18
+ if (index === -1)
19
+ return;
18
20
  context.interactiveObjects.splice(index, 1);
19
21
  dispatchers.delete(object);
20
22
  };
@@ -5,7 +5,6 @@ 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
7
  import { pointerState } from '../../internal/state.svelte.js';
8
- let controlsCounter = 0;
9
8
  export const pointerControls = (handedness, options) => {
10
9
  if (getControlsContext() === undefined) {
11
10
  injectPointerControlsPlugin();
@@ -35,8 +34,7 @@ export const pointerControls = (handedness, options) => {
35
34
  }
36
35
  const handContext = getHandContext(handedness);
37
36
  observe.pre(() => [handContext.enabled], ([enabled]) => {
38
- controlsCounter += enabled ? 1 : -1;
39
- pointerState[handedness].enabled = controlsCounter > 0;
37
+ pointerState[handedness].enabled = enabled;
40
38
  });
41
39
  observe.pre(() => [handContext.pointerOverTarget], ([hovering]) => {
42
40
  pointerState[handedness].hovering = hovering;
@@ -1,15 +1,10 @@
1
- import { injectPlugin, isInstanceOf, observe } from '@threlte/core';
1
+ import { injectPlugin, isInstanceOf } from '@threlte/core';
2
2
  import { usePointerControls } from './hook.js';
3
3
  import { events } from './types.js';
4
4
  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;
@@ -22,10 +17,6 @@ export const injectPointerControlsPlugin = () => {
22
17
  removeInteractiveObject(ref);
23
18
  };
24
19
  });
25
- observe.pre(() => [args.ref], ([ref]) => {
26
- addInteractiveObject(ref, args.props);
27
- return () => removeInteractiveObject(ref);
28
- });
29
20
  return {
30
21
  pluginProps: events
31
22
  };
@@ -5,8 +5,19 @@ import { controllers } from '../../hooks/useController.svelte.js';
5
5
  import { useHand } from '../../hooks/useHand.svelte.js';
6
6
  import { useFixed } from '../../internal/useFixed.js';
7
7
  import { isPresenting, pointerIntersection } from '../../internal/state.svelte.js';
8
+ // Hover identity must match the dedup key used in `getHits`, otherwise the ID
9
+ // changes mid-hover (e.g. the hit's face index changes as the ray sweeps a
10
+ // plain mesh) and the object flickers between pointerout/pointerenter every
11
+ // frame.
8
12
  const getIntersectionId = (intersection) => {
9
- return `${(intersection.eventObject || intersection.object).uuid}|${intersection.index}|${intersection.instanceId}`;
13
+ const target = intersection.eventObject ?? intersection.object;
14
+ if (intersection.instanceId !== undefined) {
15
+ return `${target.uuid}|${intersection.instanceId}`;
16
+ }
17
+ if (intersection.object.isPoints) {
18
+ return `${target.uuid}|${intersection.index}`;
19
+ }
20
+ return target.uuid;
10
21
  };
11
22
  const EPSILON = 0.0001;
12
23
  export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) => {
@@ -29,24 +40,18 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
29
40
  handleEvent('onpointerup', event);
30
41
  };
31
42
  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
43
  handleEvent('onclick', event);
38
44
  };
39
45
  function cancelPointer(intersections) {
40
46
  if (handContext.hovered.size === 0)
41
47
  return;
48
+ const currentIds = new Set();
49
+ for (const hit of intersections) {
50
+ currentIds.add(getIntersectionId(hit));
51
+ }
42
52
  const toRemove = [];
43
53
  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)) {
54
+ if (!currentIds.has(id)) {
50
55
  toRemove.push([id, hoveredObj]);
51
56
  }
52
57
  }
@@ -114,7 +119,13 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
114
119
  const handleEvent = (name, event) => {
115
120
  const isPointerMove = name === 'onpointermove';
116
121
  const isClickEvent = name === 'onclick' || name === 'oncontextmenu';
117
- // Take care of unhover
122
+ // Fire pointermissed for objects that were not under the pointer at pointerdown.
123
+ // Must come before the dispatch loop so user-land cleanup runs first.
124
+ if (isClickEvent) {
125
+ pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
126
+ }
127
+ // Update hover state before dispatch so that pointerout/pointerleave fire
128
+ // before pointerover/pointerenter on newly hit objects.
118
129
  if (isPointerMove)
119
130
  cancelPointer(hits);
120
131
  let stopped = false;
@@ -127,6 +138,7 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
127
138
  stopped,
128
139
  ...hit,
129
140
  intersections: hits,
141
+ handedness,
130
142
  stopPropagation() {
131
143
  stopped = true;
132
144
  intersectionEvent.stopped = true;
@@ -166,15 +178,11 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
166
178
  // Call pointer move
167
179
  events.onpointermove?.(intersectionEvent);
168
180
  }
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);
181
+ else if (events[name] !== undefined) {
182
+ // All other events
183
+ if (!isClickEvent || handContext.initialHits.includes(hit.eventObject)) {
184
+ events[name]?.(intersectionEvent);
185
+ }
178
186
  }
179
187
  if (stopped)
180
188
  break dispatchEvents;
@@ -194,42 +202,28 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
194
202
  autoStart: false
195
203
  });
196
204
  observe.pre(() => [controller, handContext.enabled], ([controller, $enabled]) => {
197
- if (controller === undefined)
205
+ if (controller === undefined || !$enabled)
198
206
  return;
199
- const removeHandlers = () => {
207
+ controller.targetRay.addEventListener('selectstart', handlePointerDown);
208
+ controller.targetRay.addEventListener('selectend', handlePointerUp);
209
+ controller.targetRay.addEventListener('select', handleClick);
210
+ return () => {
200
211
  controller.targetRay.removeEventListener('selectstart', handlePointerDown);
201
212
  controller.targetRay.removeEventListener('selectend', handlePointerUp);
202
213
  controller.targetRay.removeEventListener('select', handleClick);
203
214
  };
204
- if ($enabled) {
205
- controller.targetRay.addEventListener('selectstart', handlePointerDown);
206
- controller.targetRay.addEventListener('selectend', handlePointerUp);
207
- controller.targetRay.addEventListener('select', handleClick);
208
- return removeHandlers;
209
- }
210
- else {
211
- removeHandlers();
212
- return;
213
- }
214
215
  });
215
216
  observe.pre(() => [hand, handContext.enabled], ([input, enabled]) => {
216
- if (input === undefined)
217
+ if (input === undefined || !enabled)
217
218
  return;
218
- const removeHandlers = () => {
219
+ input.hand.addEventListener('pinchstart', handlePointerDown);
220
+ input.hand.addEventListener('pinchend', handlePointerUp);
221
+ input.hand.addEventListener('pinchend', handleClick);
222
+ return () => {
219
223
  input.hand.removeEventListener('pinchstart', handlePointerDown);
220
224
  input.hand.removeEventListener('pinchend', handlePointerUp);
221
225
  input.hand.removeEventListener('pinchend', handleClick);
222
226
  };
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();
231
- return;
232
- }
233
227
  });
234
228
  observe.pre(() => [isPresenting.current, handContext.enabled], ([isPresenting, $enabled]) => {
235
229
  if (isPresenting && $enabled) {
@@ -13,6 +13,8 @@ export interface IntersectionEvent extends Intersection {
13
13
  eventObject: Object3D;
14
14
  /** An array of intersections */
15
15
  intersections: Intersection[];
16
+ /** Which hand dispatched this event. Each controller/hand fires enter/leave/etc. independently. */
17
+ handedness: 'left' | 'right';
16
18
  /** Normalized event coordinates */
17
19
  pointer: Vector3;
18
20
  /** Delta between first click and this event */
@@ -7,5 +7,6 @@ export const events = [
7
7
  'onpointerout',
8
8
  'onpointerenter',
9
9
  'onpointerleave',
10
- 'onpointermove'
10
+ 'onpointermove',
11
+ 'onpointermissed'
11
12
  ];
@@ -5,6 +5,10 @@ export const defaultComputeFunction = (context, handContext) => {
5
5
  const targetRay = controllers[handContext.hand]?.targetRay;
6
6
  if (targetRay === undefined)
7
7
  return;
8
+ // `<Controller>` attaches targetRay to the scene root so local === world;
9
+ // we can read `.position`/`.quaternion` directly without a matrixWorld
10
+ // roundtrip (which would force-recompose the matrix three.js writes from
11
+ // the XR pose and introduce a drift against the visible render).
8
12
  forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
13
  context.raycaster.set(targetRay.position, forward);
10
14
  };
@@ -27,6 +27,8 @@ export const createTeleportContext = (compute) => {
27
27
  };
28
28
  const removeSurface = (mesh) => {
29
29
  const index = context.interactiveObjects.indexOf(mesh);
30
+ if (index === -1)
31
+ return;
30
32
  context.interactiveObjects.splice(index, 1);
31
33
  context.surfaces.delete(mesh.uuid);
32
34
  context.dispatchers.delete(mesh);
@@ -41,6 +43,8 @@ export const createTeleportContext = (compute) => {
41
43
  };
42
44
  const removeBlocker = (mesh) => {
43
45
  const index = context.interactiveObjects.indexOf(mesh);
46
+ if (index === -1)
47
+ return;
44
48
  context.interactiveObjects.splice(index, 1);
45
49
  context.blockers.delete(mesh.uuid);
46
50
  };
@@ -4,7 +4,6 @@ import { injectTeleportControlsPlugin } from './plugin.svelte.js';
4
4
  import { setHandContext } from './context.js';
5
5
  import { setupTeleportControls } from './setup.svelte.js';
6
6
  import { teleportState } from '../../internal/state.svelte.js';
7
- let controlsCounter = 0;
8
7
  export const teleportControls = (handedness, options) => {
9
8
  if (useTeleportControls() === undefined) {
10
9
  injectTeleportControlsPlugin();
@@ -13,7 +12,6 @@ export const teleportControls = (handedness, options) => {
13
12
  const context = useTeleportControls();
14
13
  if (getHandContext(handedness) === undefined) {
15
14
  const enabled = options?.enabled ?? true;
16
- controlsCounter += enabled ? 1 : -1;
17
15
  const ctx = {
18
16
  hand: handedness,
19
17
  active: currentWritable(false),
@@ -25,8 +23,7 @@ export const teleportControls = (handedness, options) => {
25
23
  }
26
24
  const handContext = getHandContext(handedness);
27
25
  observe.pre(() => [handContext.enabled], ([enabled]) => {
28
- controlsCounter += enabled ? 1 : -1;
29
- teleportState[handedness].enabled = controlsCounter > 0;
26
+ teleportState[handedness].enabled = enabled;
30
27
  });
31
28
  observe.pre(() => [handContext.active], ([hovering]) => {
32
29
  teleportState[handedness].hovering = hovering;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threlte/xr",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "author": "Micheal Parks <michealparks1989@gmail.com> (https://parks.lol)",
5
5
  "license": "MIT",
6
6
  "description": "Tools to more easily create VR and AR experiences with Threlte",
@@ -28,7 +28,8 @@
28
28
  "typescript-eslint": "^8.32.0",
29
29
  "vite": "^7.1.4",
30
30
  "vite-plugin-mkcert": "^1.17.5",
31
- "@threlte/core": "8.5.8"
31
+ "@threlte/core": "8.5.9",
32
+ "@threlte/extras": "9.14.9"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "svelte": ">=5",