@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.
- package/dist/components/Controller.svelte +7 -3
- package/dist/components/Hand.svelte +12 -6
- package/dist/components/XR.svelte +23 -10
- package/dist/components/XR.svelte.d.ts +3 -1
- package/dist/components/internal/Cursor.svelte +5 -10
- package/dist/components/internal/PointerCursor.svelte +18 -4
- package/dist/components/internal/TeleportCursor.svelte +4 -1
- package/dist/hooks/useHandJoint.svelte.js +11 -3
- package/dist/internal/setupControllers.js +8 -3
- package/dist/internal/setupHands.js +6 -3
- package/dist/internal/setupHeadset.svelte.js +3 -3
- package/dist/lib/toggleXRSession.js +2 -2
- package/dist/plugins/pointerControls/compute.js +4 -0
- package/dist/plugins/pointerControls/hook.js +2 -0
- package/dist/plugins/pointerControls/index.js +1 -3
- package/dist/plugins/pointerControls/plugin.svelte.js +1 -10
- package/dist/plugins/pointerControls/setup.svelte.js +40 -46
- package/dist/plugins/pointerControls/types.d.ts +2 -0
- package/dist/plugins/pointerControls/types.js +2 -1
- package/dist/plugins/teleportControls/compute.js +4 -0
- package/dist/plugins/teleportControls/context.js +4 -0
- package/dist/plugins/teleportControls/index.js +1 -4
- package/package.json +3 -2
|
@@ -75,7 +75,8 @@
|
|
|
75
75
|
const handedness = $derived<'left' | 'right'>(left ? 'left' : right ? 'right' : (hand ?? 'left'))
|
|
76
76
|
|
|
77
77
|
$effect.pre(() => {
|
|
78
|
-
|
|
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[
|
|
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
|
|
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,
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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,
|
|
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
|
|
32
|
-
float
|
|
33
|
-
float alpha = 1.0 -
|
|
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
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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: () =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|
|
@@ -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
|
-
|
|
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
|
+
"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.
|
|
31
|
+
"@threlte/core": "8.5.9",
|
|
32
|
+
"@threlte/extras": "9.14.9"
|
|
32
33
|
},
|
|
33
34
|
"peerDependencies": {
|
|
34
35
|
"svelte": ">=5",
|