Skip to content

Commit 38c9e7e

Browse files
committed
feat(xr): adapt to the #69 event API + pointer capture for select/squeeze
Rebased onto next-cleanup (post-#69): the superseded event.element/extra dispatch primitive is dropped (it's in core now as currentObject + extra), and the XR event field follows the rename (element → currentObject). Adds pointer capture to the controller source: start events (onXRSelectStart/onXRSqueezeStart) are capturable — a handler may call setPointerCapture() to grab the hit object — and the paired end event delivers to that captured object (the live ray reprojected onto the drag plane), then releases it, since XR has no OS lostpointercapture sink. Captures register in the global captureRegistry, so reactive hasPointerCapture() works for XR too.
1 parent ebe74ad commit 38c9e7e

2 files changed

Lines changed: 68 additions & 15 deletions

File tree

src/xr/events.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { onCleanup, runWithOwner } from "solid-js"
22
import type { Intersection, Object3D } from "three"
3+
import { captureRegistry } from "../pointer-capture.ts"
34
import { Pointer } from "../pointers.ts"
45
import { ControllerRaycaster } from "../raycasters.tsx"
56
import type { Context, Plugin, ThreeEvent } from "../types.ts"
@@ -10,7 +11,6 @@ export type XRThreeEvent = ThreeEvent<XRInputSourceEvent> & {
1011
controller: Object3D
1112
inputSource: XRInputSource | undefined
1213
handedness: XRHandedness | undefined
13-
element: Object3D | undefined
1414
intersection: Intersection
1515
}
1616

@@ -29,10 +29,10 @@ type XRLike = {
2929
}
3030

3131
const PAIRS = [
32-
["selectstart", "onXRSelectStart"],
33-
["selectend", "onXRSelectEnd"],
34-
["squeezestart", "onXRSqueezeStart"],
35-
["squeezeend", "onXRSqueezeEnd"],
32+
["selectstart", "onXRSelectStart", "start"],
33+
["selectend", "onXRSelectEnd", "end"],
34+
["squeezestart", "onXRSqueezeStart", "start"],
35+
["squeezeend", "onXRSqueezeEnd", "end"],
3636
] as const
3737

3838
/**
@@ -41,8 +41,11 @@ const PAIRS = [
4141
* the ray AND which dispatches select/squeeze events) gets a `Pointer` + a
4242
* `ControllerRaycaster`; the four start/end events dispatch the matching handler,
4343
* enriched with `{ controller, inputSource, handedness }`. Bubbling + canvas-level
44-
* come from `Pointer.dispatch`. `sessionend` (or `connect`'s disconnect) tears the
45-
* controllers down. Replaces the controller wiring previously baked into core.
44+
* come from `Pointer.dispatch`. Start events are capturable — a handler may call
45+
* `setPointerCapture()` to grab the hit object for the gesture; the paired end event
46+
* then delivers to that captured object (reprojected) and releases it. `sessionend`
47+
* (or `connect`'s disconnect) tears the controllers down. Replaces the controller
48+
* wiring previously baked into core.
4649
*/
4750
export class XRControllerSource {
4851
constructor(
@@ -57,15 +60,25 @@ export class XRControllerSource {
5760
const wire = () => {
5861
for (let index = 0; index < this.count; index++) {
5962
const controller = this.xr.getController(index)
60-
const pointer = new Pointer(this.context, new ControllerRaycaster(controller))
61-
const listeners = PAIRS.map(([native, handler]) => {
63+
const pointer = new Pointer(
64+
this.context,
65+
new ControllerRaycaster(controller),
66+
undefined,
67+
captureRegistry,
68+
)
69+
const listeners = PAIRS.map(([native, handler, phase]) => {
6270
const listener = (event: ControllerEvent) => {
6371
const inputSource = event.data
64-
pointer.dispatch(handler, new Event(native), {
65-
controller,
66-
inputSource,
67-
handedness: inputSource?.handedness,
68-
})
72+
// Start events are capturable (a handler may call `setPointerCapture()`),
73+
// mirroring `onPointerDown`. XR has no OS `lostpointercapture`, so the
74+
// source releases the capture on the paired end event.
75+
pointer.dispatch(
76+
handler,
77+
new Event(native),
78+
{ controller, inputSource, handedness: inputSource?.handedness },
79+
phase === "start",
80+
)
81+
if (phase === "end") pointer.release()
6982
}
7083
return [native, listener] as const
7184
})

tests/core/xr-events.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as THREE from "three"
22
import { assertType, describe, expect, it, vi } from "vitest"
33
import { createT } from "../../src/create-t.tsx"
4+
import { hasPointerCapture } from "../../src/pointer-capture.ts"
45
import { test as renderThree } from "../../src/testing/index.tsx"
56
import { XRControllerSource, type XRThreeEvent, xrEvents } from "../../src/xr/events.ts"
67
import { meta } from "../../src/utils.ts"
@@ -47,7 +48,7 @@ describe("XRControllerSource", () => {
4748
expect(start).toHaveBeenCalledTimes(1)
4849
expect(payload!.controller).toBe(controller)
4950
expect(payload!.handedness).toBe("left")
50-
expect(payload!.element).toBe(mesh)
51+
expect(payload!.currentObject).toBe(mesh)
5152
expect(payload!.intersection.object).toBe(mesh)
5253

5354
controller.dispatchEvent({ type: "squeezeend", data: { handedness: "left" } } as any)
@@ -60,6 +61,45 @@ describe("XRControllerSource", () => {
6061
disconnect()
6162
})
6263

64+
it("a captured select delivers selectend to the grabbed mesh off-ray, then releases", () => {
65+
const end = vi.fn()
66+
const mesh = meta(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial()), {
67+
props: {
68+
onXRSelectStart: (event: any) => event.setPointerCapture(), // grab the hit
69+
onXRSelectEnd: end,
70+
},
71+
}) as unknown as THREE.Object3D
72+
mesh.updateMatrixWorld()
73+
74+
const controller = new THREE.Object3D()
75+
controller.position.set(0.5, 0.3, 5) // aimed at the plane (−z)
76+
controller.updateMatrixWorld()
77+
const xr = makeFakeXR(index => (index === 0 ? controller : new THREE.Object3D()))
78+
const context = {
79+
gl: { xr },
80+
eventRegistry: [mesh],
81+
props: {},
82+
scene: new THREE.Scene(),
83+
camera: new THREE.PerspectiveCamera(),
84+
} as any
85+
86+
const disconnect = new XRControllerSource(context, xr as any, 1).connect()
87+
xr.dispatch("sessionstart")
88+
89+
controller.dispatchEvent({ type: "selectstart", data: { handedness: "right" } } as any) // captures
90+
expect(hasPointerCapture(mesh)).toBe(true)
91+
92+
// Aim the controller away from the mesh — the live ray no longer hits it.
93+
controller.position.set(100, 100, 5)
94+
controller.updateMatrixWorld()
95+
96+
controller.dispatchEvent({ type: "selectend", data: { handedness: "right" } } as any)
97+
expect(end).toHaveBeenCalledTimes(1) // still delivered to the captured mesh
98+
expect(hasPointerCapture(mesh)).toBe(false) // released on end (no OS sink in XR)
99+
100+
disconnect()
101+
})
102+
63103
it("xrEvents() registers a handler-bearing mesh and wires the source once per ctx", () => {
64104
const start = vi.fn()
65105
const controller = new THREE.Object3D()

0 commit comments

Comments
 (0)