From 811ca2a2531804aa5d3f99d1ecd1ea70d2d10e60 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 20:02:50 +0200 Subject: [PATCH 01/32] feat(events): Pointer.dispatch exposes event.element + merges extra fields --- src/pointers.ts | 9 +++++++-- src/types.ts | 10 +++++++++- tests/core/pointer.test.tsx | 13 +++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index 1303ced3..ae47223f 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -147,21 +147,26 @@ export class Pointer { * Bubble a "default"-style gesture to an arbitrary handler name (plugin-extensible: * the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source * can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring - * `stopPropagation`, then fires canvas-level if unstopped. + * `stopPropagation`, then fires canvas-level if unstopped. `extra` is merged onto the + * event (plugin sources use it for rich fields, e.g. the XR controller payload), and + * `event.element` exposes the node a handler is firing on. */ - dispatch(handler: string, nativeEvent: Event) { + dispatch(handler: string, nativeEvent: Event, extra?: Record) { const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const event: any = createThreeEvent(nativeEvent, { intersections }) + if (extra) Object.assign(event, extra) for (const intersection of intersections) { event.currentIntersection = intersection let node: Object3D | null = intersection.object while (node && !event.stopped) { + event.element = node ;(getMeta(node)?.props as any)?.[handler]?.(event) node = node.parent } } if (!event.stopped) { delete event.currentIntersection + event.element = undefined ;(this.context.props as Record)[handler]?.(event) } } diff --git a/src/types.ts b/src/types.ts index d0c33876..417801d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -378,7 +378,15 @@ export type ThreeEvent< }, > = Intersect< [ - { nativeEvent: TEvent }, + { + nativeEvent: TEvent + /** + * The node a bubbled handler is currently firing on (the ancestor reached + * while walking up the hit chain), or `undefined` for the canvas-level + * dispatch. Set by `Pointer.dispatch`; plugin sources (e.g. XR) read it. + */ + element?: Object3D + }, When< TConfig["stoppable"], { diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index aa18e0db..1e3ddd16 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -75,4 +75,17 @@ describe("Pointer dispatch", () => { expect(meshMissed).toHaveBeenCalledTimes(1) expect(canvasMissed).toHaveBeenCalledTimes(1) }) + + it("dispatch sets event.element to the bubbling node and merges extra fields", () => { + const seen: any[] = [] + const parent = eventful({ onPing: (e: any) => seen.push({ element: e.element, k: e.k }) }) + const child = eventful({ onPing: (e: any) => seen.push({ element: e.element, k: e.k }) }) + ;(child as any).parent = parent + const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) + + ;(pointer as any).dispatch("onPing", new Event("x"), { k: 42 }) + expect(seen[0].element).toBe(child) // handler on child sees child + expect(seen[1].element).toBe(parent) // bubbled handler on parent sees parent + expect(seen.every(s => s.k === 42)).toBe(true) // extra merged onto every dispatch + }) }) From 60c7e040b4fd2c78a0bed5c2d0ab23394f6c778b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:49:39 +0200 Subject: [PATCH 02/32] refactor(raycaster): factor aim() out of cast() and expose ray on PointerRaycaster --- src/pointers.ts | 10 +++++++--- src/raycasters.tsx | 21 ++++++++++++++++++--- tests/core/raycaster-cast.test.tsx | 17 +++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index ae47223f..b36bf996 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -1,15 +1,19 @@ -import type { Intersection, Object3D } from "three" +import { Plane, Vector3, type Intersection, type Object3D, type Ray } from "three" import type { Context, Meta, Prettify, ThreeEvent } from "./types.ts" import { getMeta } from "./utils.ts" /** * The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against - * a registry, and (for the click-missed phase) re-cast a single object. The real - * `EventRaycaster` (which extends three's `Raycaster`) satisfies this structurally. + * a registry, (for the click-missed phase) re-cast a single object, and — for + * pointer capture — `aim` the live `ray` without casting (to reproject onto the + * captured object's plane). The real `EventRaycaster` (which extends three's + * `Raycaster`) satisfies this structurally. */ export type PointerRaycaster = { cast(registry: Object3D[], context: Context): Intersection>[] intersectObject(object: Object3D, recursive?: boolean): Intersection[] + aim(context: Context): void + ray: Ray } /** Creates a `ThreeEvent` (intersection excluded) from a native `MouseEvent` | `PointerEvent` | `WheelEvent`. */ diff --git a/src/raycasters.tsx b/src/raycasters.tsx index 0e6333c1..6bd24a20 100644 --- a/src/raycasters.tsx +++ b/src/raycasters.tsx @@ -12,6 +12,12 @@ export interface EventRaycaster extends Raycaster { * for screen pointers, `matrixWorld` for an XR controller). */ cast(registry: Object3D[], context: Context): Intersection>[] + /** + * Position `this.ray` for the current pointer without intersecting anything — + * the aiming half of `cast`. Pointer capture calls this to reproject the live + * ray onto the captured object's plane. + */ + aim(context: Context): void } /** Screen-ray family: aimed from a 2D cursor position in NDC. */ @@ -49,8 +55,11 @@ export class CursorRaycaster extends Raycaster implements ScreenRaycaster { setCursor(ndc: Vector2) { this.pointer.copy(ndc) } - cast(registry: Object3D[], context: Context) { + aim(context: Context) { this.setFromCamera(this.pointer, context.camera) + } + cast(registry: Object3D[], context: Context) { + this.aim(context) return castRegistry(this, registry) } } @@ -60,8 +69,11 @@ export class CenterRaycaster extends Raycaster implements ScreenRaycaster { setCursor(_ndc: Vector2) { /* centre is fixed — ignore the cursor */ } - cast(registry: Object3D[], context: Context) { + aim(context: Context) { this.setFromCamera(CENTER, context.camera) + } + cast(registry: Object3D[], context: Context) { + this.aim(context) return castRegistry(this, registry) } } @@ -74,13 +86,16 @@ export class ControllerRaycaster extends Raycaster implements EventRaycaster { constructor(public space: Object3D) { super() } - cast(registry: Object3D[], _context: Context) { + aim(_context: Context) { this.space.updateMatrixWorld() const origin = new Vector3().setFromMatrixPosition(this.space.matrixWorld) const direction = new Vector3(0, 0, -1) .applyQuaternion(new Quaternion().setFromRotationMatrix(this.space.matrixWorld)) .normalize() this.ray.set(origin, direction) + } + cast(registry: Object3D[], context: Context) { + this.aim(context) return castRegistry(this, registry) } } diff --git a/tests/core/raycaster-cast.test.tsx b/tests/core/raycaster-cast.test.tsx index 62b3dd86..a2a6dd4c 100644 --- a/tests/core/raycaster-cast.test.tsx +++ b/tests/core/raycaster-cast.test.tsx @@ -64,3 +64,20 @@ describe("EventRaycaster.cast", () => { expect(rc.cast([mesh], ctx(camera()))).toHaveLength(0) }) }) + +describe("aim()", () => { + it("positions the ray from the camera + cursor without casting the registry", () => { + const raycaster = new CursorRaycaster() + const camera = new PerspectiveCamera() + camera.position.set(0, 0, 5) + camera.updateMatrixWorld() + const context = { camera } as any + + raycaster.setCursor(new Vector2(0, 0)) // dead centre + raycaster.aim(context) + + // Ray now originates at the camera and points toward -z (into the scene). + expect(raycaster.ray.origin.z).toBeCloseTo(5) + expect(raycaster.ray.direction.z).toBeLessThan(0) + }) +}) From 71118bccbc1f63afccce4554266dc45644b4ab40 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:51:14 +0200 Subject: [PATCH 03/32] feat(pointers): capture state + capture/release/dropCapture with an optional OS sink --- src/pointers.ts | 41 +++++++++++++++++++++++ tests/core/pointer.test.tsx | 65 ++++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index b36bf996..6a2538e1 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -54,6 +54,9 @@ export function createThreeEvent< > } +/** The OS-level half of pointer capture, injected per source (DOM canvas vs XR). */ +export type PointerCaptureSink = { capture(): void; release(): void } + /** * One pointer's dispatch + per-pointer state, decoupled from the DOM. A * `*PointerManager` owns the source (canvas / XR controller) and the raycaster, @@ -69,12 +72,50 @@ export function createThreeEvent< export class Pointer { private hovered = new Set() private hoveredCanvas = false + private captured: { object: Object3D; plane: Plane; intersection: Intersection } | null = null constructor( private context: Context, private raycaster: PointerRaycaster, + private sink?: PointerCaptureSink, ) {} + /** Whether this pointer currently holds `object` captured. */ + hasCaptured(object: Object3D): boolean { + return this.captured?.object === object + } + + /** + * Capture this pointer to `object`: build the drag plane from the hit point and + * world-space normal (camera-facing if the hit has no face), then engage the + * OS sink. Subsequent move/up reproject the live ray onto this plane and deliver + * exclusively to `object`'s chain until released. + */ + capture(object: Object3D, intersection: Intersection) { + if (!object) return + const normal = new Vector3() + if (intersection.face) { + normal.copy(intersection.face.normal).transformDirection(object.matrixWorld) + } else { + this.context.camera.getWorldDirection(normal).negate() + } + const plane = new Plane().setFromNormalAndCoplanarPoint(normal, intersection.point) + this.captured = { object, plane, intersection } + this.sink?.capture() + } + + /** Release a held capture and notify the OS sink. Idempotent. */ + release() { + if (!this.captured) return + this.captured = null + this.sink?.release() + } + + /** Clear capture state only, without notifying the sink (the OS already released). */ + dropCapture() { + this.captured = null + } + /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ move(nativeEvent: Event) { const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 1e3ddd16..bcee1149 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest" -import { Object3D } from "three" +import { Object3D, Ray, Vector3 } from "three" import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" import { meta } from "../../src/utils.ts" @@ -9,11 +9,29 @@ function eventful(handlers: Record) { function ctx(eventRegistry: Object3D[], props: Record = {}) { return { eventRegistry, props } as any } -// Fake raycaster: `cast` hits whatever `state.target` is; phase-2 re-cast finds nothing. -function fakeRaycaster(state: { target?: Object3D }): PointerRaycaster { +// Fake raycaster: `cast` hits whatever `state.target` is (with a point+face so +// capture() can build a plane); phase-2 re-cast finds nothing; `aim` is a no-op +// (tests position `ray` directly). +function fakeRaycaster(state: { + target?: Object3D + point?: Vector3 + normal?: Vector3 +}): PointerRaycaster { return { - cast: () => (state.target ? [{ object: state.target, distance: 1 } as any] : []), + cast: () => + state.target + ? [ + { + object: state.target, + distance: 1, + point: (state.point ?? new Vector3()).clone(), + face: { normal: (state.normal ?? new Vector3(0, 0, 1)).clone() }, + } as any, + ] + : [], intersectObject: () => [], + aim: () => {}, + ray: new Ray(), } } @@ -89,3 +107,42 @@ describe("Pointer dispatch", () => { expect(seen.every(s => s.k === 42)).toBe(true) // extra merged onto every dispatch }) }) + +// A minimal capture sink that records calls. +function spySink() { + return { capture: vi.fn(), release: vi.fn() } +} + +describe("Pointer capture lifecycle", () => { + it("capture() stores the object and calls the sink; release() clears it and calls the sink", () => { + const mesh = eventful({}) + const sink = spySink() + const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) + + pointer.capture(mesh, { point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) + expect(sink.capture).toHaveBeenCalledTimes(1) + expect(pointer.hasCaptured(mesh)).toBe(true) + + pointer.release() + expect(sink.release).toHaveBeenCalledTimes(1) + expect(pointer.hasCaptured(mesh)).toBe(false) + }) + + it("dropCapture() clears state WITHOUT calling the sink's release", () => { + const mesh = eventful({}) + const sink = spySink() + const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) + + pointer.capture(mesh, { point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) + pointer.dropCapture() + expect(pointer.hasCaptured(mesh)).toBe(false) + expect(sink.release).not.toHaveBeenCalled() + }) + + it("capture(null) is a no-op", () => { + const sink = spySink() + const pointer = new Pointer(ctx([]), fakeRaycaster({}), sink) + pointer.capture(null as any, {} as any) + expect(sink.capture).not.toHaveBeenCalled() + }) +}) From 92095014d63e42da324f5e67b6a59aa499f7e7c3 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:52:36 +0200 Subject: [PATCH 04/32] feat(pointers): exclusive-but-bubbling capture dispatch with plane-reprojected data --- src/pointers.ts | 59 ++++++++++++++++++++++++++++--- tests/core/pointer.test.tsx | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index 6a2538e1..5157f64d 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -116,6 +116,32 @@ export class Pointer { this.captured = null } + /** + * The forced intersection for a captured pointer: intersect the live ray with + * the stored plane for a fresh `point`/`distance`, keeping the original hit's + * `face`/`uv`/`object`. Falls back to the stored hit if the ray is parallel. + */ + private reproject(captured: { + object: Object3D + plane: Plane + intersection: Intersection + }): Intersection { + this.raycaster.aim(this.context) + const point = this.raycaster.ray.intersectPlane(captured.plane, new Vector3()) + if (!point) return captured.intersection + const distance = this.raycaster.ray.origin.distanceTo(point) + return { ...captured.intersection, point, distance } + } + + /** Attach the capture methods to a capturable event (down/up/move). */ + private attachCapture(event: any) { + event.setPointerCapture = () => { + if (event.element) this.capture(event.element, event.currentIntersection) + } + event.releasePointerCapture = () => this.release() + event.hasPointerCapture = () => !!event.element && this.hasCaptured(event.element) + } + /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ move(nativeEvent: Event) { const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) @@ -179,10 +205,10 @@ export class Pointer { } down(nativeEvent: Event) { - this.dispatch("onPointerDown", nativeEvent) + this.dispatch("onPointerDown", nativeEvent, undefined, true) } up(nativeEvent: Event) { - this.dispatch("onPointerUp", nativeEvent) + this.dispatch("onPointerUp", nativeEvent, undefined, true) } wheel(nativeEvent: Event) { this.dispatch("onWheel", nativeEvent) @@ -194,12 +220,37 @@ export class Pointer { * can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring * `stopPropagation`, then fires canvas-level if unstopped. `extra` is merged onto the * event (plugin sources use it for rich fields, e.g. the XR controller payload), and - * `event.element` exposes the node a handler is firing on. + * `event.element` exposes the node a handler is firing on. When this pointer + * holds a capture, delivery is exclusive to the captured object's chain (the + * registry is not raycast) but still bubbles to the canvas-level handler; the + * intersection is the live ray reprojected onto the captured plane. */ - dispatch(handler: string, nativeEvent: Event, extra?: Record) { + dispatch(handler: string, nativeEvent: Event, extra?: Record, capturable = false) { + const captured = this.captured + if (captured) { + const intersection = this.reproject(captured) + const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) + if (extra) Object.assign(event, extra) + if (capturable) this.attachCapture(event) + event.currentIntersection = intersection + let node: Object3D | null = captured.object + while (node && !event.stopped) { + event.element = node + ;(getMeta(node)?.props as any)?.[handler]?.(event) + node = node.parent + } + if (!event.stopped) { + delete event.currentIntersection + event.element = undefined + ;(this.context.props as Record)[handler]?.(event) + } + return + } + const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const event: any = createThreeEvent(nativeEvent, { intersections }) if (extra) Object.assign(event, extra) + if (capturable) this.attachCapture(event) for (const intersection of intersections) { event.currentIntersection = intersection let node: Object3D | null = intersection.object diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index bcee1149..bab387d8 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -145,4 +145,74 @@ describe("Pointer capture lifecycle", () => { pointer.capture(null as any, {} as any) expect(sink.capture).not.toHaveBeenCalled() }) + + it("delivers onPointerUp exclusively to the captured object after the ray moves off it", () => { + const capturedUp = vi.fn() + const otherUp = vi.fn() + const captured = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerUp: capturedUp, + }) + const other = eventful({ onPointerUp: otherUp }) + const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + + pointer.down(new Event("pointerdown")) // captures `captured` + state.target = other // ray now hits `other` + pointer.up(new Event("pointerup")) + + expect(capturedUp).toHaveBeenCalledTimes(1) + expect(otherUp).not.toHaveBeenCalled() + }) + + it("bubbles a captured up to the canvas-level handler unless stopped", () => { + const canvasUp = vi.fn() + const captured = eventful({ onPointerDown: (e: any) => e.setPointerCapture() }) + const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([captured], { onPointerUp: canvasUp }), fakeRaycaster(state)) + + pointer.down(new Event("pointerdown")) + state.target = undefined + pointer.up(new Event("pointerup")) + + expect(canvasUp).toHaveBeenCalledTimes(1) // canvas-level still fires during capture + }) + + it("reprojects the live ray onto the captured plane for a fresh point", () => { + let seenPoint: Vector3 | undefined + const captured = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerUp: (e: any) => (seenPoint = e.intersection.point.clone()), + }) + // Plane: z = 0, normal +z, coplanar point (0,0,0). + const state = { target: captured as Object3D, point: new Vector3(0, 0, 0), normal: new Vector3(0, 0, 1) } + const raycaster = fakeRaycaster(state) + const pointer = new Pointer(ctx([captured]), raycaster) + + pointer.down(new Event("pointerdown")) + state.target = undefined + raycaster.ray.set(new Vector3(1, 2, 5), new Vector3(0, 0, -1)) // aims at (1,2,0) + pointer.up(new Event("pointerup")) + + expect(seenPoint?.x).toBeCloseTo(1) + expect(seenPoint?.y).toBeCloseTo(2) + expect(seenPoint?.z).toBeCloseTo(0) + }) + + it("releasePointerCapture() in onPointerUp restores normal delivery", () => { + const otherUp = vi.fn() + const captured = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerUp: (e: any) => e.releasePointerCapture(), + }) + const other = eventful({ onPointerUp: otherUp }) + const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + + pointer.down(new Event("pointerdown")) + pointer.up(new Event("pointerup")) // captured up, then releases + state.target = other + pointer.up(new Event("pointerup")) // no longer captured → normal delivery + expect(otherUp).toHaveBeenCalledTimes(1) + }) }) From 58ae204f1143deb35f888882451297df9c8f935d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:54:36 +0200 Subject: [PATCH 05/32] =?UTF-8?q?feat(pointers):=20captured=20move()=20?= =?UTF-8?q?=E2=80=94=20frozen=20hover,=20exclusive=20delivery,=20canvas=20?= =?UTF-8?q?move=20still=20fires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pointers.ts | 29 +++++++++++++++++++++++++- tests/core/pointer.test.tsx | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/pointers.ts b/src/pointers.ts index 5157f64d..1a45d0e1 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -144,6 +144,29 @@ export class Pointer { /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ move(nativeEvent: Event) { + const captured = this.captured + if (captured) { + // Exclusive-but-bubbling: deliver onPointerMove to the captured chain only + // (no enter/leave on any object — hover frozen), then canvas-level if + // unstopped. Intersection is the live ray reprojected onto the captured plane. + const intersection = this.reproject(captured) + const moveEvent: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) + this.attachCapture(moveEvent) + moveEvent.currentIntersection = intersection + let node: Object3D | null = captured.object + while (node && !moveEvent.stopped) { + moveEvent.element = node + ;(getMeta(node)?.props as any)?.onPointerMove?.(moveEvent) + node = node.parent + } + if (!moveEvent.stopped) { + delete moveEvent.currentIntersection + moveEvent.element = undefined + ;(this.context.props as Record).onPointerMove?.(moveEvent) + } + return + } + const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const props = this.context.props as Record @@ -164,8 +187,10 @@ export class Pointer { props.onPointerEnter?.(enterEvent) } - // Phase #2 — Move (bubble up, stoppable). + // Phase #2 — Move (bubble up, stoppable). Capturable: a handler may start a + // drag by calling `setPointerCapture()` from here. const moveEvent: any = createThreeEvent(nativeEvent, { intersections }) + this.attachCapture(moveEvent) const moved = new Set() for (const intersection of intersections) { moveEvent.currentIntersection = intersection @@ -174,6 +199,7 @@ export class Pointer { moved.add(current) const meta = getMeta(current) if (meta) { + moveEvent.element = current ;(meta.props as any).onPointerMove?.(moveEvent) if (moveEvent.stopped) break } @@ -182,6 +208,7 @@ export class Pointer { } if (!moveEvent.stopped) { delete moveEvent.currentIntersection + moveEvent.element = undefined props.onPointerMove?.(moveEvent) } diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index bab387d8..0285173a 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -215,4 +215,45 @@ describe("Pointer capture lifecycle", () => { pointer.up(new Event("pointerup")) // no longer captured → normal delivery expect(otherUp).toHaveBeenCalledTimes(1) }) + + it("while captured, move fires only on the captured object — not on others under the ray", () => { + const capturedMove = vi.fn() + const otherEnter = vi.fn() + const captured = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerMove: capturedMove, + }) + const other = eventful({ onPointerEnter: otherEnter, onPointerMove: vi.fn() }) + const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + + pointer.down(new Event("pointerdown")) + state.target = other // ray now over `other` + pointer.move(new Event("pointermove")) + + expect(capturedMove).toHaveBeenCalledTimes(1) + expect(otherEnter).not.toHaveBeenCalled() // frozen hover — other objects stay quiet + }) + + it("while captured, canvas-level onPointerMove still fires unless stopped", () => { + const canvasMove = vi.fn() + const captured = eventful({ onPointerDown: (e: any) => e.setPointerCapture() }) + const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([captured], { onPointerMove: canvasMove }), fakeRaycaster(state)) + + pointer.down(new Event("pointerdown")) + state.target = undefined + pointer.move(new Event("pointermove")) + + expect(canvasMove).toHaveBeenCalledTimes(1) + }) + + it("can start a capture from onPointerMove", () => { + const mesh = eventful({ onPointerMove: (e: any) => e.setPointerCapture() }) + const state = { target: mesh as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + + pointer.move(new Event("pointermove")) + expect(pointer.hasCaptured(mesh)).toBe(true) + }) }) From 85abb43d8234ad502ce9e18be19e3e27b204f39a Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:58:23 +0200 Subject: [PATCH 06/32] feat(types): PointerCapture methods on onPointerDown/Up/Move --- src/types.ts | 25 ++++++++++++++++++++++--- tests/core/api-coverage.test.tsx | 16 +++++++++++++++- tests/core/pointer.test.tsx | 23 +++++++++++------------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/types.ts b/src/types.ts index 417801d8..bfd6136b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -405,6 +405,25 @@ export type ThreeEvent< ] > +export type PointerCapture = { + /** + * Capture this event's pointer to the node the handler is firing on + * (`event.element`). Subsequent move/up for this pointer deliver exclusively to + * that node's chain (still bubbling to the canvas-level handler) until released — + * even off-ray and, for the DOM source, off-canvas. Off-ray, `event.intersection` + * is reprojected onto the grabbed object's plane so `point` keeps tracking. + */ + setPointerCapture(): void + /** + * Release a capture started with `setPointerCapture`. Also released + * automatically on pointerup/cancel for the DOM source, and on the paired end + * event for XR. + */ + releasePointerCapture(): void + /** Whether this event's node currently holds the pointer capture. */ + hasPointerCapture(): boolean +} + type EventHandlersMap = { onClick: Prettify> onClickMissed: Prettify> @@ -412,9 +431,9 @@ type EventHandlersMap = { onDoubleClickMissed: Prettify> onContextMenu: Prettify> onContextMenuMissed: Prettify> - onPointerUp: Prettify> - onPointerDown: Prettify> - onPointerMove: Prettify> + onPointerUp: Prettify & PointerCapture> + onPointerDown: Prettify & PointerCapture> + onPointerMove: Prettify & PointerCapture> onPointerEnter: Prettify> onPointerLeave: Prettify> onWheel: Prettify> diff --git a/tests/core/api-coverage.test.tsx b/tests/core/api-coverage.test.tsx index a52cef63..4eacb783 100644 --- a/tests/core/api-coverage.test.tsx +++ b/tests/core/api-coverage.test.tsx @@ -1,8 +1,9 @@ import { createSignal } from "solid-js" import * as THREE from "three" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, assertType, beforeEach, describe, expect, it, vi } from "vitest" import { createEntity, + createT, CursorRaycaster, CenterRaycaster, getMeta, @@ -181,3 +182,16 @@ describe("CursorRaycaster / CenterRaycaster", () => { ) }) }) + +describe("pointer capture types", () => { + it("exposes pointer-capture methods on pointer events", () => { + const T = createT(THREE) + type MeshProps = Parameters[0] + const onPointerDown: NonNullable = event => { + assertType<() => void>(event.setPointerCapture) + assertType<() => void>(event.releasePointerCapture) + assertType<() => boolean>(event.hasPointerCapture) + } + void onPointerDown + }) +}) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 0285173a..d6447a7e 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -9,14 +9,13 @@ function eventful(handlers: Record) { function ctx(eventRegistry: Object3D[], props: Record = {}) { return { eventRegistry, props } as any } +// Mutable ray state a test tweaks between gestures. +type RayState = { target?: Object3D; point?: Vector3; normal?: Vector3 } + // Fake raycaster: `cast` hits whatever `state.target` is (with a point+face so // capture() can build a plane); phase-2 re-cast finds nothing; `aim` is a no-op // (tests position `ray` directly). -function fakeRaycaster(state: { - target?: Object3D - point?: Vector3 - normal?: Vector3 -}): PointerRaycaster { +function fakeRaycaster(state: RayState): PointerRaycaster { return { cast: () => state.target @@ -154,7 +153,7 @@ describe("Pointer capture lifecycle", () => { onPointerUp: capturedUp, }) const other = eventful({ onPointerUp: otherUp }) - const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) // captures `captured` @@ -168,7 +167,7 @@ describe("Pointer capture lifecycle", () => { it("bubbles a captured up to the canvas-level handler unless stopped", () => { const canvasUp = vi.fn() const captured = eventful({ onPointerDown: (e: any) => e.setPointerCapture() }) - const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([captured], { onPointerUp: canvasUp }), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) @@ -185,7 +184,7 @@ describe("Pointer capture lifecycle", () => { onPointerUp: (e: any) => (seenPoint = e.intersection.point.clone()), }) // Plane: z = 0, normal +z, coplanar point (0,0,0). - const state = { target: captured as Object3D, point: new Vector3(0, 0, 0), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(0, 0, 0), normal: new Vector3(0, 0, 1) } const raycaster = fakeRaycaster(state) const pointer = new Pointer(ctx([captured]), raycaster) @@ -206,7 +205,7 @@ describe("Pointer capture lifecycle", () => { onPointerUp: (e: any) => e.releasePointerCapture(), }) const other = eventful({ onPointerUp: otherUp }) - const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) @@ -224,7 +223,7 @@ describe("Pointer capture lifecycle", () => { onPointerMove: capturedMove, }) const other = eventful({ onPointerEnter: otherEnter, onPointerMove: vi.fn() }) - const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) @@ -238,7 +237,7 @@ describe("Pointer capture lifecycle", () => { it("while captured, canvas-level onPointerMove still fires unless stopped", () => { const canvasMove = vi.fn() const captured = eventful({ onPointerDown: (e: any) => e.setPointerCapture() }) - const state = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: captured as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([captured], { onPointerMove: canvasMove }), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) @@ -250,7 +249,7 @@ describe("Pointer capture lifecycle", () => { it("can start a capture from onPointerMove", () => { const mesh = eventful({ onPointerMove: (e: any) => e.setPointerCapture() }) - const state = { target: mesh as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const state: RayState = { target: mesh as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) pointer.move(new Event("pointermove")) From eddc31ebeee4e8b6812a58f22b4fc3b6a45950b9 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 11:59:40 +0200 Subject: [PATCH 07/32] feat(pointer-managers): DOM OS capture sink + lostpointercapture cleanup --- src/pointer-managers.ts | 15 ++++++++- tests/core/canvas-events.test.tsx | 55 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts index 8edf7797..d6127d0e 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -30,7 +30,13 @@ export class DOMPointerManager { private forId(id: number): Pointer { let pointer = this.pointers.get(id) if (!pointer) { - pointer = new Pointer(this.context, this.raycaster) + const canvas = this.context.canvas + pointer = new Pointer(this.context, this.raycaster, { + capture: () => canvas.setPointerCapture(id), + release: () => { + if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id) + }, + }) this.pointers.set(id, pointer) } return pointer @@ -85,12 +91,18 @@ export class DOMPointerManager { aim(event) this.primary.wheel(event) } + // The browser auto-releases capture on pointerup/cancel (and on explicit + // release), firing lostpointercapture — clear our matching capture state. + const onLostCapture = (event: PointerEvent) => { + this.pointers.get(event.pointerId)?.dropCapture() + } canvas.addEventListener("pointermove", onMove) canvas.addEventListener("pointerdown", onDown) canvas.addEventListener("pointerup", onUp) canvas.addEventListener("pointerleave", onLeaveOrCancel) canvas.addEventListener("pointercancel", onLeaveOrCancel) + canvas.addEventListener("lostpointercapture", onLostCapture) canvas.addEventListener("click", onClick) canvas.addEventListener("dblclick", onDoubleClick) canvas.addEventListener("contextmenu", onContextMenu) @@ -102,6 +114,7 @@ export class DOMPointerManager { canvas.removeEventListener("pointerup", onUp) canvas.removeEventListener("pointerleave", onLeaveOrCancel) canvas.removeEventListener("pointercancel", onLeaveOrCancel) + canvas.removeEventListener("lostpointercapture", onLostCapture) canvas.removeEventListener("click", onClick) canvas.removeEventListener("dblclick", onDoubleClick) canvas.removeEventListener("contextmenu", onContextMenu) diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 9d8d016d..64a72b8f 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -567,3 +567,58 @@ describe("canvas hover events", () => { }) }) + +/**********************************************************************************/ +/* */ +/* Pointer Capture */ +/* */ +/**********************************************************************************/ + +describe("pointer capture", () => { + const pointerAt = (type: string, x: number, y: number) => + new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true }) + + /** A 2×2 mesh that captures on pointerdown and reports move/up. */ + const CapturingMesh = (props: { onMove?: (e: any) => void; onUp?: (e: any) => void }) => ( + e.setPointerCapture()} + onPointerMove={(e: any) => props.onMove?.(e)} + onPointerUp={(e: any) => props.onUp?.(e)} + > + + + + ) + + it("keeps move/up on the captured mesh after the ray leaves it, and calls canvas.setPointerCapture", () => { + const onMove = vi.fn() + const onUp = vi.fn() + const { canvas } = test(() => ) + // Synthetic PointerEvents create no *active* pointer, so the real + // canvas.setPointerCapture(1) would throw InvalidStateError — mock it. We're + // testing our dispatch logic; events are fired directly at the canvas, so real + // OS routing isn't needed. + const captureSpy = vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures + expect(captureSpy).toHaveBeenCalledWith(1) + + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // ray now off the mesh + fireEvent(canvas, pointerAt("pointerup", MISS_X, MISS_Y)) + + expect(onMove).toHaveBeenCalledTimes(1) + expect(onUp).toHaveBeenCalledTimes(1) + }) + + it("lostpointercapture clears capture; the next move resumes normal hover", () => { + const onMove = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures + fireEvent(canvas, new PointerEvent("lostpointercapture", { pointerId: 1, bubbles: true })) + + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // off the mesh, no longer captured + expect(onMove).not.toHaveBeenCalled() + }) +}) From a76bbabab353e50ec64ca0ca2a9cc8929dad0c0e Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 12:15:30 +0200 Subject: [PATCH 08/32] refactor(pointers): dedup captured move via dispatch, honest capture guard, sink-throw rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review findings: - collapse move()'s captured branch into dispatch("onPointerMove", …, capturable) — removes a duplicate bubble loop; hover stays frozen since dispatch never touches this.hovered - widen capture()'s param to Object3D|null|undefined so the no-op guard is a real narrowing (was statically dead under the non-null type) - roll back captured state if the OS sink's setPointerCapture throws - name the Captured shape; note why the touch path needs no explicit release --- src/pointer-managers.ts | 2 ++ src/pointers.ts | 54 ++++++++++++++++++----------------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts index d6127d0e..acbf5ed2 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -64,6 +64,8 @@ export class DOMPointerManager { aim(event) this.forId(event.pointerId).up(event) // A lifted touch no longer exists — leave + drop it so it keeps no state. + // No explicit capture release needed: the browser auto-released on pointerup + // (firing lostpointercapture), and the dropped Pointer is unreachable anyway. if (event.pointerType === "touch") { this.pointers.get(event.pointerId)?.leave(event) this.pointers.delete(event.pointerId) diff --git a/src/pointers.ts b/src/pointers.ts index 1a45d0e1..9f9b3c2e 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -57,6 +57,12 @@ export function createThreeEvent< /** The OS-level half of pointer capture, injected per source (DOM canvas vs XR). */ export type PointerCaptureSink = { capture(): void; release(): void } +/** + * A held capture: the grabbed object, the drag plane the live ray reprojects onto, + * and the original hit (kept for its `face`/`uv`/`instanceId` while dragging). + */ +type Captured = { object: Object3D; plane: Plane; intersection: Intersection } + /** * One pointer's dispatch + per-pointer state, decoupled from the DOM. A * `*PointerManager` owns the source (canvas / XR controller) and the raycaster, @@ -72,7 +78,7 @@ export type PointerCaptureSink = { capture(): void; release(): void } export class Pointer { private hovered = new Set() private hoveredCanvas = false - private captured: { object: Object3D; plane: Plane; intersection: Intersection } | null = null + private captured: Captured | null = null constructor( private context: Context, @@ -89,9 +95,10 @@ export class Pointer { * Capture this pointer to `object`: build the drag plane from the hit point and * world-space normal (camera-facing if the hit has no face), then engage the * OS sink. Subsequent move/up reproject the live ray onto this plane and deliver - * exclusively to `object`'s chain until released. + * exclusively to `object`'s chain until released. A nullish `object` (the + * canvas-level dispatch has no `event.element`) is a no-op. */ - capture(object: Object3D, intersection: Intersection) { + capture(object: Object3D | null | undefined, intersection: Intersection) { if (!object) return const normal = new Vector3() if (intersection.face) { @@ -101,7 +108,14 @@ export class Pointer { } const plane = new Plane().setFromNormalAndCoplanarPoint(normal, intersection.point) this.captured = { object, plane, intersection } - this.sink?.capture() + // Engage OS capture only after state is set; if it throws (e.g. the pointer + // isn't active), roll back so capture state never outlives a failed sink. + try { + this.sink?.capture() + } catch (error) { + this.captured = null + throw error + } } /** Release a held capture and notify the OS sink. Idempotent. */ @@ -121,11 +135,7 @@ export class Pointer { * the stored plane for a fresh `point`/`distance`, keeping the original hit's * `face`/`uv`/`object`. Falls back to the stored hit if the ray is parallel. */ - private reproject(captured: { - object: Object3D - plane: Plane - intersection: Intersection - }): Intersection { + private reproject(captured: Captured): Intersection { this.raycaster.aim(this.context) const point = this.raycaster.ray.intersectPlane(captured.plane, new Vector3()) if (!point) return captured.intersection @@ -144,28 +154,10 @@ export class Pointer { /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ move(nativeEvent: Event) { - const captured = this.captured - if (captured) { - // Exclusive-but-bubbling: deliver onPointerMove to the captured chain only - // (no enter/leave on any object — hover frozen), then canvas-level if - // unstopped. Intersection is the live ray reprojected onto the captured plane. - const intersection = this.reproject(captured) - const moveEvent: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) - this.attachCapture(moveEvent) - moveEvent.currentIntersection = intersection - let node: Object3D | null = captured.object - while (node && !moveEvent.stopped) { - moveEvent.element = node - ;(getMeta(node)?.props as any)?.onPointerMove?.(moveEvent) - node = node.parent - } - if (!moveEvent.stopped) { - delete moveEvent.currentIntersection - moveEvent.element = undefined - ;(this.context.props as Record).onPointerMove?.(moveEvent) - } - return - } + // While captured, a move is just a captured `onPointerMove` dispatch: + // exclusive-but-bubbling delivery to the captured chain (no enter/leave on any + // object — hover frozen, since `dispatch` never touches `this.hovered`). + if (this.captured) return this.dispatch("onPointerMove", nativeEvent, undefined, true) const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const props = this.context.props as Record From 91ebbc780302b7317a197b99fe46a6791ea5fc21 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 5 Jun 2026 12:48:47 +0200 Subject: [PATCH 09/32] fix(events): release capture on unmount + remove canvas listeners on dispose - createEvents now registers the DOMPointerManager disconnect via onCleanup, so canvas listeners (incl. lostpointercapture) are removed when the Canvas owner disposes (was leaking across mount/unmount cycles) - when an object leaves the event registry (unmount / last handler removed), the manager releases any pointer capturing it, so a drag stops dispatching to a detached node instead of waiting for pointerup --- src/create-events.ts | 24 +++++++++++++-------- src/pointer-managers.ts | 14 +++++++++++- tests/core/canvas-events.test.tsx | 36 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/create-events.ts b/src/create-events.ts index 57a46413..0f14f11d 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -1,3 +1,4 @@ +import { onCleanup } from "solid-js" import { Object3D } from "three" import { DOMPointerManager } from "./pointer-managers.ts" import { CursorRaycaster, type ScreenRaycaster } from "./raycasters.tsx" @@ -32,6 +33,17 @@ export const isEventType = (type: string): type is EventName => * the XR layer through the same registry. */ export function createEvents(context: Context) { + // The screen pointer's ray strategy: the configured `raycaster` (default + // `CursorRaycaster`) when it's a screen raycaster, else a fresh one. + const candidate = context.raycaster + const screenRaycaster: ScreenRaycaster = + "setCursor" in candidate && "cast" in candidate + ? (candidate as ScreenRaycaster) + : new CursorRaycaster() + const manager = new DOMPointerManager(context, screenRaycaster) + // Remove the canvas listeners when the Canvas owner disposes. + onCleanup(manager.connect()) + // The single registry the pointer system raycasts; refcounted so an object // listening for several event types is listed exactly once. const refCounts = new Map() @@ -46,21 +58,15 @@ export function createEvents(context: Context) { refCounts.delete(object) const index = context.eventRegistry.indexOf(object) if (index !== -1) context.eventRegistry.splice(index, 1) + // The object is gone (unmount / last handler removed) — drop any active + // capture targeting it so a drag stops dispatching to a detached node. + manager.releaseCaptured(object) } else { refCounts.set(object, current - 1) } } } - // The screen pointer's ray strategy: the configured `raycaster` (default - // `CursorRaycaster`) when it's a screen raycaster, else a fresh one. - const candidate = context.raycaster - const screenRaycaster: ScreenRaycaster = - "setCursor" in candidate && "cast" in candidate - ? (candidate as ScreenRaycaster) - : new CursorRaycaster() - new DOMPointerManager(context, screenRaycaster).connect() - return { /** * Registers an `AugmentedElement` with the pointer-event system. diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts index acbf5ed2..d5f24a03 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -1,4 +1,4 @@ -import { Vector2 } from "three" +import { Vector2, type Object3D } from "three" import { Pointer } from "./pointers.ts" import type { ScreenRaycaster } from "./raycasters.tsx" import type { Context } from "./types.ts" @@ -27,6 +27,18 @@ export class DOMPointerManager { this.primary = new Pointer(context, raycaster) } + /** + * Release any pointer that currently holds `object` captured — called when the + * object leaves the event registry (unmount / last handler removed) so a drag + * doesn't keep dispatching to a detached node until the next pointerup. Uses + * `release()` so the OS-level canvas capture is dropped too. + */ + releaseCaptured(object: Object3D) { + for (const pointer of this.pointers.values()) { + if (pointer.hasCaptured(object)) pointer.release() + } + } + private forId(id: number): Pointer { let pointer = this.pointers.get(id) if (!pointer) { diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 64a72b8f..9d99251f 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -1,4 +1,5 @@ import { fireEvent } from "@solidjs/testing-library" +import { createSignal } from "solid-js" import * as THREE from "three" import { describe, expect, it, vi } from "vitest" import { createT } from "../../src/index.ts" @@ -621,4 +622,39 @@ describe("pointer capture", () => { fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // off the mesh, no longer captured expect(onMove).not.toHaveBeenCalled() }) + + it("releases capture when the captured mesh unmounts mid-drag (no dispatch to a detached node)", () => { + const onMove = vi.fn() + const [show, setShow] = createSignal(true) + const { canvas } = test(() => (show() ? : null)) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures the mesh + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // captured move reaches the mesh + expect(onMove).toHaveBeenCalledTimes(1) + + setShow(false) // unmount mid-drag → registry removal releases the capture + + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) + expect(onMove).toHaveBeenCalledTimes(1) // no further dispatch to the detached mesh + }) +}) + +/**********************************************************************************/ +/* */ +/* Listener Lifecycle */ +/* */ +/**********************************************************************************/ + +describe("listener lifecycle", () => { + it("removes its canvas listeners when the Canvas unmounts", () => { + const three = test(() => null) + const removeSpy = vi.spyOn(three.canvas, "removeEventListener") + + three.unmount() + + const removed = removeSpy.mock.calls.map(call => call[0]) + expect(removed).toContain("pointermove") + expect(removed).toContain("lostpointercapture") + }) }) From d2cc3fc491d5859d1e8309828179888b72e7f4e3 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 17:01:00 +0200 Subject: [PATCH 10/32] refactor(pointers): factor the bubble-walk into one helper shared by both dispatch paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captured and normal `dispatch` branches each rebuilt the same parent-chain walk — set `event.currentIntersection`/`event.element`, fire the handler honoring `stopPropagation`, then fire canvas-level on `!stopped`. Extract `bubble(event, handler, roots)` so that logic, including the canvas-level `event.element = undefined` reset, lives in one place instead of being mirrored across the two branches. `move()`'s Phase-2 loop is intentionally left separate: it dedups visited nodes via a Set (a shared parent fires `onPointerMove` once) whereas dispatch deliberately does not, so folding it in would change behavior. Pure refactor — dispatch semantics unchanged. --- src/pointers.ts | 60 ++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index 9f9b3c2e..e9fd652a 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -233,6 +233,32 @@ export class Pointer { this.dispatch("onWheel", nativeEvent) } + /** + * Bubble `handler` up each root's parent chain — setting `event.currentIntersection` + * for the chain and `event.element` for each node it fires on — honoring + * `stopPropagation`, then fire the canvas-level handler if nothing stopped it. Each + * `[intersection, root]` pairs the starting node (`root`) with the intersection to + * expose while walking it: the captured path passes a single pair rooted at the + * captured object, the normal path one pair per hit. + */ + private bubble(event: any, handler: string, roots: Array<[Intersection, Object3D]>) { + for (const [intersection, root] of roots) { + event.currentIntersection = intersection + let node: Object3D | null = root + while (node && !event.stopped) { + event.element = node + ;(getMeta(node)?.props as any)?.[handler]?.(event) + node = node.parent + } + if (event.stopped) break + } + if (!event.stopped) { + delete event.currentIntersection + event.element = undefined + ;(this.context.props as Record)[handler]?.(event) + } + } + /** * Bubble a "default"-style gesture to an arbitrary handler name (plugin-extensible: * the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source @@ -247,22 +273,13 @@ export class Pointer { dispatch(handler: string, nativeEvent: Event, extra?: Record, capturable = false) { const captured = this.captured if (captured) { + // Captured: exclusive delivery to the captured object's chain, with the live + // ray reprojected onto the captured plane. const intersection = this.reproject(captured) const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - event.currentIntersection = intersection - let node: Object3D | null = captured.object - while (node && !event.stopped) { - event.element = node - ;(getMeta(node)?.props as any)?.[handler]?.(event) - node = node.parent - } - if (!event.stopped) { - delete event.currentIntersection - event.element = undefined - ;(this.context.props as Record)[handler]?.(event) - } + this.bubble(event, handler, [[intersection, captured.object]]) return } @@ -270,20 +287,11 @@ export class Pointer { const event: any = createThreeEvent(nativeEvent, { intersections }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - for (const intersection of intersections) { - event.currentIntersection = intersection - let node: Object3D | null = intersection.object - while (node && !event.stopped) { - event.element = node - ;(getMeta(node)?.props as any)?.[handler]?.(event) - node = node.parent - } - } - if (!event.stopped) { - delete event.currentIntersection - event.element = undefined - ;(this.context.props as Record)[handler]?.(event) - } + this.bubble( + event, + handler, + intersections.map((intersection): [Intersection, Object3D] => [intersection, intersection.object]), + ) } /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ From 5a2dd737fb322ecc9f1764272af8e0442c8de83c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 17:05:41 +0200 Subject: [PATCH 11/32] fix(events): dispatch fires a shared ancestor once, not once per hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch` (onPointerDown/onPointerUp/onWheel and plugin gestures) walked each hit's parent chain without deduping, so an ancestor reachable from two hits along the ray fired the handler twice — while `move` and `click` already dedup via a visited Set. Give `bubble` the same Set so a shared ancestor fires once, on the closest hit's chain. Distinct hit leaves still each fire. --- src/pointers.ts | 7 +++++-- tests/core/pointer.test.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index e9fd652a..e587ca15 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -239,13 +239,16 @@ export class Pointer { * `stopPropagation`, then fire the canvas-level handler if nothing stopped it. Each * `[intersection, root]` pairs the starting node (`root`) with the intersection to * expose while walking it: the captured path passes a single pair rooted at the - * captured object, the normal path one pair per hit. + * captured object, the normal path one pair per hit. A node shared by several hits + * fires once (the closest hit's chain reaches it first), matching `move`/`click`. */ private bubble(event: any, handler: string, roots: Array<[Intersection, Object3D]>) { + const visited = new Set() for (const [intersection, root] of roots) { event.currentIntersection = intersection let node: Object3D | null = root - while (node && !event.stopped) { + while (node && !event.stopped && !visited.has(node)) { + visited.add(node) event.element = node ;(getMeta(node)?.props as any)?.[handler]?.(event) node = node.parent diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index d6447a7e..f54ab968 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -105,6 +105,33 @@ describe("Pointer dispatch", () => { expect(seen[1].element).toBe(parent) // bubbled handler on parent sees parent expect(seen.every(s => s.k === 42)).toBe(true) // extra merged onto every dispatch }) + + it("dispatch fires a shared ancestor once when two hits bubble through it", () => { + const groupDown = vi.fn() + const childADown = vi.fn() + const childBDown = vi.fn() + const group = eventful({ onPointerDown: groupDown }) + const childA = eventful({ onPointerDown: childADown }) + const childB = eventful({ onPointerDown: childBDown }) + ;(childA as any).parent = group + ;(childB as any).parent = group + // Both children are hit along the ray; their chains share `group`. + const raycaster = { + cast: () => [ + { object: childA, distance: 1, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, + { object: childB, distance: 2, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, + ], + intersectObject: () => [], + aim: () => {}, + ray: new Ray(), + } as any as PointerRaycaster + const pointer = new Pointer(ctx([childA, childB]), raycaster) + + pointer.down(new Event("pointerdown")) + expect(childADown).toHaveBeenCalledTimes(1) // each distinct hit still fires + expect(childBDown).toHaveBeenCalledTimes(1) + expect(groupDown).toHaveBeenCalledTimes(1) // shared ancestor fires once, not per-hit + }) }) // A minimal capture sink that records calls. From 984b55aeebc5de0f2e00520e41fb8088b322190a Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 17:31:58 +0200 Subject: [PATCH 12/32] fix(events): align move's stopPropagation with click/dispatch via bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move()'s Phase-2 deduped shared ancestors but, unlike click and dispatch, lacked the cross-hit `!stopped` guard: a `stopPropagation()` from a closer object's `onPointerMove` halted that chain but a deeper hit along the ray still fired. Route Phase-2 through `bubble`, which already stops all further hits once stopped — so a closer stop now suppresses deeper hits, matching click/dispatch (and R3F). This also removes the last hand-rolled copy of the bubble-walk: enter/move/down/up/wheel/click now share `bubble` (move keeps its own enter/leave phases). --- src/pointers.ts | 25 +++++-------------------- tests/core/pointer.test.tsx | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index e587ca15..6080363d 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -183,26 +183,11 @@ export class Pointer { // drag by calling `setPointerCapture()` from here. const moveEvent: any = createThreeEvent(nativeEvent, { intersections }) this.attachCapture(moveEvent) - const moved = new Set() - for (const intersection of intersections) { - moveEvent.currentIntersection = intersection - let current: Object3D | null = intersection.object - while (current && !moved.has(current)) { - moved.add(current) - const meta = getMeta(current) - if (meta) { - moveEvent.element = current - ;(meta.props as any).onPointerMove?.(moveEvent) - if (moveEvent.stopped) break - } - current = current.parent - } - } - if (!moveEvent.stopped) { - delete moveEvent.currentIntersection - moveEvent.element = undefined - props.onPointerMove?.(moveEvent) - } + this.bubble( + moveEvent, + "onPointerMove", + intersections.map((intersection): [Intersection, Object3D] => [intersection, intersection.object]), + ) // Phase #3 — Leave (objects hovered last time but not now). const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index f54ab968..ea52bfaf 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -132,6 +132,28 @@ describe("Pointer dispatch", () => { expect(childBDown).toHaveBeenCalledTimes(1) expect(groupDown).toHaveBeenCalledTimes(1) // shared ancestor fires once, not per-hit }) + + it("move stops a deeper hit when a closer onPointerMove stops propagation", () => { + const frontMove = vi.fn((event: any) => event.stopPropagation()) + const backMove = vi.fn() + const front = eventful({ onPointerMove: frontMove }) + const back = eventful({ onPointerMove: backMove }) + // Two stacked hits along the ray; the closer one stops propagation. + const raycaster = { + cast: () => [ + { object: front, distance: 1, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, + { object: back, distance: 2, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, + ], + intersectObject: () => [], + aim: () => {}, + ray: new Ray(), + } as any as PointerRaycaster + const pointer = new Pointer(ctx([front, back]), raycaster) + + pointer.move(new Event("pointermove")) + expect(frontMove).toHaveBeenCalledTimes(1) + expect(backMove).not.toHaveBeenCalled() // stop halts the deeper hit too + }) }) // A minimal capture sink that records calls. From d088517362434a39021902e9de3c3cd9f5506f76 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 17:36:38 +0200 Subject: [PATCH 13/32] docs(contributing): document the factory (create*) vs. class convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the rule that `create*` functions are the Solid-facing reactive glue while classes are the framework-agnostic, unit-testable core — so it doesn't have to be reverse-engineered from the code. --- CONTRIBUTING.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d0d746d..545d4178 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,31 @@ if (context.gl.xr) { Use full names: `raycaster`, not `rc`. When a name shadows an outer scope, prefix with `_` to disambiguate (`_raycaster`). +## Architecture + +### Factories (`create*`) vs. classes + +The rule: `create*` functions are the Solid-facing glue; classes are the +framework-agnostic core. They never overlap — a `create*` function holds Solid +reactivity, a class holds none. + +- **`create*` factories** (`createThree`, `createEvents`, `createXR`, `createT`) + follow Solid's `createSignal`/`createStore` convention: they run synchronously + inside a reactive owner and wire up `onCleanup`, effects, and context. Reach for + one whenever setup must bind to the reactive scope. +- **Classes** (`Pointer`, `DOMPointerManager`, the `*Raycaster`s, the data + structures) carry no Solid reactivity. Use a class for long-lived, + identity-bearing state behind a method API — especially when it must subclass a + three.js type (`extends Raycaster`), swap behind an interface + (`implements ScreenRaycaster`), or be `new`'d many times. + +Keep the core in classes so it stays unit-testable without a reactive runtime: +tests `new Pointer(...)` with a fake raycaster — no `Canvas`, no owner. The +`create*` layer is the thin seam that instantiates those classes inside Solid. + +(`createThreeEvent` is the lone lowercase "value factory": it returns a plain, +spreadable event object, not a reactive primitive.) + ## Tooling ### Package Management From 15059b7fdecb7a73d5c49daddb3e187fda2c8308 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 18:04:53 +0200 Subject: [PATCH 14/32] docs(events): document pointer capture (API reference + tour drag demo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointer capture shipped on this branch but was undocumented. Add an `## Pointer capture` section to the events overview (setPointerCapture/releasePointerCapture/hasPointerCapture, exclusive delivery, element-scoping, plane reprojection, auto-release), add the missing `event.element` row to the event-object table, and add a runnable drag demo to the pointer-events tour chapter — finally delivering on the "drag-to-rotate" the chapter already teased. --- site/src/routes/api/events/overview.mdx | 42 ++++++++++++++++++++++ site/src/routes/tour/04-pointer-events.mdx | 26 ++++++++++++++ site/src/snippets/04-drag.tsx | 39 ++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 site/src/snippets/04-drag.tsx diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 8e188fa1..cf0f747b 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -39,6 +39,7 @@ Every handler receives one event argument that combines the original DOM event w | `intersections` | `Intersection[]` | events that raycast | All hit intersections, sorted nearest-first. | | `intersection` | `Intersection` | events that raycast | Shorthand for `intersections[0]` — the closest hit overall. | | `currentIntersection` | `Intersection` | inside an object handler | The intersection for the current handler's object. Absent on canvas-level dispatch. | +| `element` | `Object3D` | inside an object handler | The node this handler is attached to. As the event bubbles, `element` walks up the ancestor chain while `currentIntersection` stays on the hit object. Absent on canvas-level dispatch. | | `stopped` | `boolean` | stoppable events only | Whether `stopPropagation()` has been called. | | `stopPropagation` | `() => void` | stoppable events only | Stops both raycast and tree propagation. See [Stoppable vs non-stoppable](#stoppable-vs-non-stoppable-events). | @@ -212,6 +213,47 @@ solid-three's hover handling differs from react-three-fiber in three ways: - **What can be stopped.** In solid-three, move can be stopped but enter and leave always fire (DOM-like). In react-three-fiber, every hover event can be stopped. - **Child to parent.** In solid-three, moving from a child to its parent fires only a leave on the child (DOM-like). In react-three-fiber, the parent receives both a leave and an immediate re-enter. +## Pointer capture + +By default a pointer event goes to whatever the ray currently hits. That breaks down for a drag: the moment the pointer slips off the object — or off the canvas entirely — the moves stop arriving, and the gesture dies mid-stroke. + +Pointer capture fixes this. Inside an `onPointerDown`, `onPointerMove`, or `onPointerUp` handler, three methods ride on the event: + +| Method | Description | +| --- | --- | +| `setPointerCapture()` | Capture this pointer to `event.element` (the node the handler is firing on). | +| `releasePointerCapture()` | Release a capture started with `setPointerCapture()`. | +| `hasPointerCapture()` | Whether this event's node currently holds the capture. | + +Once captured, every subsequent `onPointerMove` and `onPointerUp` for that pointer is delivered **exclusively** to the captured node's chain — still bubbling up to the canvas-level handler, but no longer raycasting against the rest of the scene. Delivery continues even when the pointer is off the object, and (for the DOM pointer source) off the canvas. Hover is frozen for the duration: no enter/leave fires while a capture is held. + +Capture is **element-scoped** — you capture the node whose handler is running. Calling `setPointerCapture()` from a canvas-level handler is a no-op, because there is no `element` there. + +While the pointer is off the ray, `event.intersection` is reprojected onto a plane through the original grab point, so `event.intersection.point` keeps tracking a sensible world-space position to drag toward. + +A capture releases when you call `releasePointerCapture()`, and automatically on `pointerup`/`pointercancel` (DOM source) or the paired end event (XR). It is also released if the captured object leaves the scene mid-drag, so a captured pointer never keeps dispatching to an unmounted node. + +```tsx +let grabbing = false + + { + event.stopPropagation() + event.setPointerCapture() // grab — moves now follow this mesh + grabbing = true + }} + onPointerMove={event => { + if (!grabbing) return + // event.intersection.point tracks the drag plane, even off the mesh + }} + onPointerUp={() => { + grabbing = false // capture auto-releases on pointerup + }} +/> +``` + +See the [pointer events tour](/tour/04-pointer-events#dragging) for a runnable drag demo. + ## Filtering with `raycastable` To keep an object from being hit while it still receives events that bubble up from its children, set `raycastable={false}`. See [`raycastable`](/api/events/raycastable) for the full prop. diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 5175148e..7853a41f 100644 --- a/site/src/routes/tour/04-pointer-events.mdx +++ b/site/src/routes/tour/04-pointer-events.mdx @@ -10,6 +10,8 @@ import clickMissedSnippet from "../../snippets/04-click-missed.tsx?raw" import clickMissedUrl from "../../snippets/04-click-missed.tsx?importChunkUrl" import stopPropagationSnippet from "../../snippets/04-stop-propagation.tsx?raw" import stopPropagationUrl from "../../snippets/04-stop-propagation.tsx?importChunkUrl" +import dragSnippet from "../../snippets/04-drag.tsx?raw" +import dragUrl from "../../snippets/04-drag.tsx?importChunkUrl" # Pointer events @@ -97,5 +99,29 @@ isn't enough (drag-to-rotate, paint-on-surface, world-space gizmos). The [events API reference](/api/events/overview) has the full event-object shape and the complete handler list. +## Dragging + +A click fires once. Hover fires as the ray crosses a mesh. A *drag* is +different: it needs every move to keep reaching the same object — even +after the pointer slides off it. Left to the default, the moves would +jump to whatever is under the pointer now, and the gesture would fall +apart the moment your aim drifts. + +`setPointerCapture()` solves it. Call it from `onPointerDown` and that +pointer is *captured*: every following `onPointerMove` and `onPointerUp` +goes to this mesh — on the ray or off it — until you let go. + + + +Grab the cube and drag. It follows the pointer, and keeps following even +when the pointer races ahead of it or leaves the canvas. On `pointerup` +the capture releases on its own. + +Two details make it feel right. `event.stopPropagation()` keeps the grab +from also reaching anything behind the cube. And while captured, +`event.intersection.point` is reprojected onto a plane through the spot +you grabbed — so reading it gives you a stable world-space point to +follow, instead of the empty space the ray now passes through. + So far every trigger has been a one-off — a click, a hover-in, a hover-out. The next chapter introduces the third kind: time. diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx new file mode 100644 index 00000000..7f5b81f7 --- /dev/null +++ b/site/src/snippets/04-drag.tsx @@ -0,0 +1,39 @@ +import * as THREE from "three" +import { createSignal } from "solid-js" +import { Canvas, createT } from "solid-three" + +const T = createT(THREE) + +export default () => { + const [position, setPosition] = createSignal<[number, number, number]>([0, 0, 0]) + const [dragging, setDragging] = createSignal(false) + // Offset from the mesh origin to the grabbed point, so it doesn't jump on grab. + let grabOffset = new THREE.Vector3() + + return ( + + { + event.stopPropagation() + event.setPointerCapture() // grab — moves now follow this mesh + grabOffset = new THREE.Vector3(...position()).sub(event.intersection.point) + setDragging(true) + }} + onPointerMove={event => { + if (!dragging()) return + // event.intersection.point tracks the drag plane, even off the mesh. + const next = event.intersection.point.clone().add(grabOffset) + setPosition([next.x, next.y, next.z]) + }} + onPointerUp={() => setDragging(false)} // capture auto-releases on pointerup + > + + + + + + + ) +} From 7e96f4d41278f60bbf32121bbb054d4d02f55772 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 22:00:24 +0200 Subject: [PATCH 15/32] docs(events): trim the tour drag section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the "two details" aside: the reprojection explanation was TMI for a tour (it lives in the events API reference), and the stopPropagation note described meshes stacked behind the cube — which the single-cube demo doesn't have. Also drop the now-unused stopPropagation() call from the demo so the shown code stays minimal. setPointerCapture is the single takeaway. --- site/src/routes/tour/04-pointer-events.mdx | 8 +++----- site/src/snippets/04-drag.tsx | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 7853a41f..f0f05418 100644 --- a/site/src/routes/tour/04-pointer-events.mdx +++ b/site/src/routes/tour/04-pointer-events.mdx @@ -117,11 +117,9 @@ Grab the cube and drag. It follows the pointer, and keeps following even when the pointer races ahead of it or leaves the canvas. On `pointerup` the capture releases on its own. -Two details make it feel right. `event.stopPropagation()` keeps the grab -from also reaching anything behind the cube. And while captured, -`event.intersection.point` is reprojected onto a plane through the spot -you grabbed — so reading it gives you a stable world-space point to -follow, instead of the empty space the ray now passes through. +One detail makes it feel right: the `event.stopPropagation()` in +`onPointerDown` keeps the press from also grabbing meshes stacked behind the +cube — you grab the front one, not the whole column. So far every trigger has been a one-off — a click, a hover-in, a hover-out. The next chapter introduces the third kind: time. diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx index 7f5b81f7..95ce3007 100644 --- a/site/src/snippets/04-drag.tsx +++ b/site/src/snippets/04-drag.tsx @@ -16,7 +16,6 @@ export default () => { position={position()} scale={dragging() ? 1.15 : 1} onPointerDown={event => { - event.stopPropagation() event.setPointerCapture() // grab — moves now follow this mesh grabOffset = new THREE.Vector3(...position()).sub(event.intersection.point) setDragging(true) From 9f506a07356c2e7bcdb900852d22787b865844af Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 22:42:57 +0200 Subject: [PATCH 16/32] fix(events): release pointer capture only on real removal, not a handler re-register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releaseCaptured` fired on any registry refcount→0, so a reactive handler (e.g. `onPointerMove={dragging() ? a : b}`) that's an object's only listener would tear down a live capture mid-drag: SolidJS re-runs the prop effect cleanup-then-body, dropping the count to 0 (→ `canvas.releasePointerCapture`) before re-registering. Defer the release to a microtask and run it only if the object is still gone, so a same-tick re-registration keeps the capture while a real unmount still releases. Microtasks drain before the next pointer event, so unmount timing is unaffected in practice. --- src/create-events.ts | 11 ++++++++--- tests/core/canvas-events.test.tsx | 30 ++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/create-events.ts b/src/create-events.ts index 0f14f11d..0b0c3aff 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -58,9 +58,14 @@ export function createEvents(context: Context) { refCounts.delete(object) const index = context.eventRegistry.indexOf(object) if (index !== -1) context.eventRegistry.splice(index, 1) - // The object is gone (unmount / last handler removed) — drop any active - // capture targeting it so a drag stops dispatching to a detached node. - manager.releaseCaptured(object) + // Drop any active capture on a gone object so a drag stops dispatching to a + // detached node. But a *reactive* handler (e.g. `onPointerMove={dragging() ? + // a : b}`) re-registers in the same tick — cleanup (refcount → 0) then body + // (→ 1) — which must NOT tear down a live capture mid-drag. Defer, and + // release only if the object is still gone (a real unmount), not re-added. + queueMicrotask(() => { + if (!refCounts.has(object)) manager.releaseCaptured(object) + }) } else { refCounts.set(object, current - 1) } diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 9d99251f..3e5d907c 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -623,7 +623,7 @@ describe("pointer capture", () => { expect(onMove).not.toHaveBeenCalled() }) - it("releases capture when the captured mesh unmounts mid-drag (no dispatch to a detached node)", () => { + it("releases capture when the captured mesh unmounts mid-drag (no dispatch to a detached node)", async () => { const onMove = vi.fn() const [show, setShow] = createSignal(true) const { canvas } = test(() => (show() ? : null)) @@ -633,11 +633,37 @@ describe("pointer capture", () => { fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // captured move reaches the mesh expect(onMove).toHaveBeenCalledTimes(1) - setShow(false) // unmount mid-drag → registry removal releases the capture + setShow(false) // unmount mid-drag → registry removal schedules the release + await Promise.resolve() // the release is deferred a microtask (drains before the next real event) fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) expect(onMove).toHaveBeenCalledTimes(1) // no further dispatch to the detached mesh }) + + it("keeps capture when a reactive handler re-registers mid-drag (not a real removal)", async () => { + const onMove = vi.fn() + const [flip, setFlip] = createSignal(false) + // The mesh's ONLY listener is a reactive onPointerMove (reads `flip()`, so flipping + // re-registers it: refcount 1 → 0 → 1 in one tick). It captures itself on first move. + const { canvas } = test(() => ( + (e.setPointerCapture(), onMove(e)))} + > + + + + )) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointermove", HIT_X, HIT_Y)) // captures the mesh + expect(onMove).toHaveBeenCalledTimes(1) + + setFlip(true) // re-registers the only handler — must NOT drop the live capture + await Promise.resolve() // drain the deferred release check (object was re-added → skip) + + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // off the mesh + expect(onMove).toHaveBeenCalledTimes(2) // still captured → second move reaches it + }) }) /**********************************************************************************/ From 5304709943d89ad1a5cb8fd943f910558f810fac Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 22:42:57 +0200 Subject: [PATCH 17/32] fix(events): build the capture plane from the hit leaf; swallow failed OS-sink captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two capture fixes in `Pointer`, plus a clarifying rename: - The drag-plane normal was transformed by `event.element`'s matrix, but `intersection.face.normal` lives in the hit leaf's local space. When a handler on an ancestor captures, that tilts the plane. Orient it with `intersection.object.matrixWorld` instead. - `capture()` re-threw a failing `sink.capture()`, so calling `setPointerCapture()` from a no-button hover-move surfaced `InvalidStateError` into the user's handler and aborted dispatch. Roll back and swallow it — a failed OS capture just means capture didn't engage. - Rename `Captured.object` → `element` (and the `capture()` param) so the capture target reads distinctly from the hit leaf (`intersection.object`); `Captured` is now an interface with per-field docs. Adds regression tests for the plane transform, the swallowed sink error, and that no `onPointerLeave` fires while captured. --- src/pointers.ts | 55 +++++++++++++++++---------- tests/core/pointer.test.tsx | 75 ++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index 6080363d..ec6b70b1 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -57,11 +57,22 @@ export function createThreeEvent< /** The OS-level half of pointer capture, injected per source (DOM canvas vs XR). */ export type PointerCaptureSink = { capture(): void; release(): void } -/** - * A held capture: the grabbed object, the drag plane the live ray reprojects onto, - * and the original hit (kept for its `face`/`uv`/`instanceId` while dragging). - */ -type Captured = { object: Object3D; plane: Plane; intersection: Intersection } +/** A held pointer capture. */ +interface Captured { + /** + * The captured target — the node whose handler called `setPointerCapture` + * (`event.element`). Captured move/up deliver exclusively to its chain; may be + * an ancestor of the hit leaf. + */ + element: Object3D + /** The drag plane the live ray reprojects onto each move. */ + plane: Plane + /** + * The original hit. Its `point` seeds the plane, and its + * `face`/`uv`/`instanceId`/`object` (the hit leaf) carry through while dragging. + */ + intersection: Intersection +} /** * One pointer's dispatch + per-pointer state, decoupled from the DOM. A @@ -88,33 +99,39 @@ export class Pointer { /** Whether this pointer currently holds `object` captured. */ hasCaptured(object: Object3D): boolean { - return this.captured?.object === object + return this.captured?.element === object } /** - * Capture this pointer to `object`: build the drag plane from the hit point and - * world-space normal (camera-facing if the hit has no face), then engage the - * OS sink. Subsequent move/up reproject the live ray onto this plane and deliver - * exclusively to `object`'s chain until released. A nullish `object` (the + * Capture this pointer to `element` (the node whose handler called + * `setPointerCapture`): build the drag plane from the hit point and world-space + * normal (camera-facing if the hit has no face), then engage the OS sink. + * Subsequent move/up reproject the live ray onto this plane and deliver + * exclusively to `element`'s chain until released. A nullish `element` (the * canvas-level dispatch has no `event.element`) is a no-op. + * + * The face normal is expressed in the hit leaf's local space, so it's oriented + * with `intersection.object`'s world matrix — which differs from `element` when + * a handler on an ancestor captures. */ - capture(object: Object3D | null | undefined, intersection: Intersection) { - if (!object) return + capture(element: Object3D | null | undefined, intersection: Intersection) { + if (!element) return const normal = new Vector3() if (intersection.face) { - normal.copy(intersection.face.normal).transformDirection(object.matrixWorld) + normal.copy(intersection.face.normal).transformDirection(intersection.object.matrixWorld) } else { this.context.camera.getWorldDirection(normal).negate() } const plane = new Plane().setFromNormalAndCoplanarPoint(normal, intersection.point) - this.captured = { object, plane, intersection } - // Engage OS capture only after state is set; if it throws (e.g. the pointer - // isn't active), roll back so capture state never outlives a failed sink. + this.captured = { element, plane, intersection } + // Engage the OS sink only after state is set. If it throws — e.g. the pointer + // isn't in an active-buttons state (a hover move with no button down) — roll + // back so capture never outlives a failed sink, and swallow the platform error + // rather than surfacing it into the user's handler. try { this.sink?.capture() - } catch (error) { + } catch { this.captured = null - throw error } } @@ -267,7 +284,7 @@ export class Pointer { const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - this.bubble(event, handler, [[intersection, captured.object]]) + this.bubble(event, handler, [[intersection, captured.element]]) return } diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index ea52bfaf..b1bfe6db 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -167,7 +167,7 @@ describe("Pointer capture lifecycle", () => { const sink = spySink() const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) - pointer.capture(mesh, { point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) + pointer.capture(mesh, { object: mesh, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) expect(sink.capture).toHaveBeenCalledTimes(1) expect(pointer.hasCaptured(mesh)).toBe(true) @@ -181,7 +181,7 @@ describe("Pointer capture lifecycle", () => { const sink = spySink() const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) - pointer.capture(mesh, { point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) + pointer.capture(mesh, { object: mesh, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any) pointer.dropCapture() expect(pointer.hasCaptured(mesh)).toBe(false) expect(sink.release).not.toHaveBeenCalled() @@ -194,6 +194,77 @@ describe("Pointer capture lifecycle", () => { expect(sink.capture).not.toHaveBeenCalled() }) + it("swallows a sink capture error and rolls back instead of surfacing it", () => { + const mesh = eventful({}) + const sink = { + capture: vi.fn(() => { + throw new DOMException("pointer not active", "InvalidStateError") + }), + release: vi.fn(), + } + const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) + expect(() => + pointer.capture(mesh, { + object: mesh, + point: new Vector3(), + face: { normal: new Vector3(0, 0, 1) }, + } as any), + ).not.toThrow() + expect(sink.capture).toHaveBeenCalledTimes(1) + expect(pointer.hasCaptured(mesh)).toBe(false) // rolled back, capture didn't engage + }) + + it("orients the drag plane with the hit leaf's transform, not event.element's", () => { + // Capture from a parent `element` while the hit leaf is a child rotated 90° about + // Y, so its local +Z face normal maps to world +X — the drag plane is x=0 (yz). + // The old code used `element`'s identity matrix → a z=0 plane. + let point: Vector3 | undefined + const element = eventful({ onPointerMove: (e: any) => (point = e.intersection.point) }) + const hitLeaf = new Object3D() + hitLeaf.rotation.y = Math.PI / 2 + hitLeaf.updateMatrixWorld() + const raycaster: PointerRaycaster = { + cast: () => [], + intersectObject: () => [], + aim: () => {}, + ray: new Ray(new Vector3(2, 1, 0), new Vector3(-1, 0, 0)), // toward -x, offset +1 in y + } + const pointer = new Pointer(ctx([element]), raycaster) + pointer.capture(element, { + object: hitLeaf, + point: new Vector3(), + face: { normal: new Vector3(0, 0, 1) }, + } as any) + pointer.move(new Event("pointermove")) // captured → reproject onto the plane + + // x=0 plane: ray (2,1,0)+t(-1,0,0) crosses at (0,1,0) → y === 1. + // The buggy z=0 plane is parallel to this ray → fallback to the grab point → y === 0. + expect(point?.y).toBeCloseTo(1) + }) + + it("does not fire onPointerLeave while captured; leave resumes after release", () => { + const move = vi.fn() + const leave = vi.fn() + const mesh = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerMove: move, + onPointerLeave: leave, + }) + const state: RayState = { target: mesh as Object3D, point: new Vector3(), normal: new Vector3(0, 0, 1) } + const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + + pointer.move(new Event("pointermove")) // hover onto the mesh + pointer.down(new Event("pointerdown")) // captures it + state.target = undefined // ray now off everything + pointer.move(new Event("pointermove")) // captured move — hover is frozen + expect(move).toHaveBeenCalled() + expect(leave).not.toHaveBeenCalled() // no leave while captured + + pointer.release() + pointer.move(new Event("pointermove")) // not captured; ray off → leave the still-hovered mesh + expect(leave).toHaveBeenCalledTimes(1) + }) + it("delivers onPointerUp exclusively to the captured object after the ray moves off it", () => { const capturedUp = vi.fn() const otherUp = vi.fn() From de7fa3b538a6f92d561beea6bb125b1736fa67e0 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 22:42:57 +0200 Subject: [PATCH 18/32] test(events): drop stale r3f-API pointer-capture todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `it.todo` stubs in `events.test.tsx` were ported from react-three-fiber (they call `target.setPointerCapture(pointerId)`, not our `event.setPointerCapture()`, and assert synchronous unmount release). Capture is now implemented and covered by the current-idiom suites — release-on-unmount and lostpointercapture in `canvas-events.test.tsx`, exclusive delivery / reprojection / no-leave-while-captured in `pointer.test.tsx` — so the stubs are superseded. --- tests/core/events.test.tsx | 139 ------------------------------------- 1 file changed, 139 deletions(-) diff --git a/tests/core/events.test.tsx b/tests/core/events.test.tsx index 6a987114..7e94324e 100644 --- a/tests/core/events.test.tsx +++ b/tests/core/events.test.tsx @@ -227,145 +227,6 @@ describe("events", () => { expect(handleClickRear).not.toHaveBeenCalled() }) - // TODO: implement pointer capture - - describe("web pointer capture", () => { - let handlePointerMove = vi.fn() - let handlePointerDown = vi.fn(ev => { - ;(ev.nativeEvent.target as any).setPointerCapture(ev.pointerId) - }) - let handlePointerUp = vi.fn(ev => - (ev.nativeEvent.target as any).releasePointerCapture(ev.pointerId), - ) - let handlePointerEnter = vi.fn() - let handlePointerLeave = vi.fn() - - beforeEach(() => { - handlePointerMove = vi.fn() - handlePointerDown = vi.fn(ev => { - ;(ev.nativeEvent.target as any).setPointerCapture(ev.pointerId) - }) - handlePointerUp = vi.fn(ev => - (ev.nativeEvent.target as any).releasePointerCapture(ev.pointerId), - ) - handlePointerEnter = vi.fn() - handlePointerLeave = vi.fn() - }) - - /* This component lets us unmount the event-handling object */ - function PointerCaptureTest(props: { hasMesh: boolean; manualRelease?: boolean }) { - return ( - - - - - - - ) - } - - const pointerId = 1234 - - it.todo("should release when the capture target is unmounted", async () => { - const [hasMesh, setHasMesh] = createSignal(true) - - // S3: we do not have a replacement for rerender - const { canvas } = test(() => ) - - canvas.setPointerCapture = vi.fn() - canvas.releasePointerCapture = vi.fn() - - // @ts-expect-error TODO: fix type-error - const down = new Event("pointerdown", { pointerId }) - Object.defineProperty(down, "offsetX", { get: () => 577 }) - Object.defineProperty(down, "offsetY", { get: () => 480 }) - - /* testing-utils/react's fireEvent wraps the event like React does, so it doesn't match how our event handlers are called in production, so we call dispatchEvent directly. */ - canvas.dispatchEvent(down) - - /* This should have captured the DOM pointer */ - expect(handlePointerDown).toHaveBeenCalledTimes(1) - expect(canvas.setPointerCapture).toHaveBeenCalledWith(pointerId) - expect(canvas.releasePointerCapture).not.toHaveBeenCalled() - - /* Now remove the T.Mesh */ - setHasMesh(false) - - expect(canvas.releasePointerCapture).toHaveBeenCalledWith(pointerId) - - // @ts-expect-error TODO: fix type-error - const move = new Event("pointerdown", { pointerId }) - Object.defineProperty(move, "offsetX", { get: () => 577 }) - Object.defineProperty(move, "offsetY", { get: () => 480 }) - - canvas.dispatchEvent(move) - - /* There should now be no pointer capture */ - expect(handlePointerMove).not.toHaveBeenCalled() - }) - - it.todo("should not leave when captured", async () => { - const { canvas } = test(() => ) - - canvas.setPointerCapture = vi.fn() - canvas.releasePointerCapture = vi.fn() - - // @ts-expect-error TODO: fix type-error - const moveIn = new Event("pointermove", { pointerId }) - Object.defineProperty(moveIn, "offsetX", { get: () => 577 }) - Object.defineProperty(moveIn, "offsetY", { get: () => 480 }) - - // @ts-expect-error TODO: fix type-error - const moveOut = new Event("pointermove", { pointerId }) - Object.defineProperty(moveOut, "offsetX", { get: () => -10000 }) - Object.defineProperty(moveOut, "offsetY", { get: () => -10000 }) - - /* testing-utils/react's fireEvent wraps the event like React does, so it doesn't match how our event handlers are called in production, so we call dispatchEvent directly. */ - canvas.dispatchEvent(moveIn) - expect(handlePointerEnter).toHaveBeenCalledTimes(1) - expect(handlePointerMove).toHaveBeenCalledTimes(1) - - // @ts-expect-error TODO: fix type-error - const down = new Event("pointerdown", { pointerId }) - Object.defineProperty(down, "offsetX", { get: () => 577 }) - Object.defineProperty(down, "offsetY", { get: () => 480 }) - - canvas.dispatchEvent(down) - - // If we move the pointer now, when it is captured, it should raise the onPointerMove event even though the pointer is not over the element, - // and NOT raise the onPointerLeave event. - canvas.dispatchEvent(moveOut) - expect(handlePointerMove).toHaveBeenCalledTimes(2) - expect(handlePointerLeave).not.toHaveBeenCalled() - - canvas.dispatchEvent(moveIn) - expect(handlePointerMove).toHaveBeenCalledTimes(3) - - // @ts-expect-error TODO: fix type-error - const up = new Event("pointerup", { pointerId }) - Object.defineProperty(up, "offsetX", { get: () => 577 }) - Object.defineProperty(up, "offsetY", { get: () => 480 }) - // @ts-expect-error TODO: fix type-error - const lostpointercapture = new Event("lostpointercapture", { pointerId }) - - canvas.dispatchEvent(up) - canvas.dispatchEvent(lostpointercapture) - - // The pointer is still over the element, so onPointerLeave should not have been called. - expect(handlePointerLeave).not.toHaveBeenCalled() - - // The element pointer should no longer be captured, so moving it away should call onPointerLeave. - canvas.dispatchEvent(moveOut) - expect(handlePointerEnter).toHaveBeenCalledTimes(1) - expect(handlePointerLeave).toHaveBeenCalledTimes(1) - }) - }) }) /**********************************************************************************/ From 034c5933b36a198ae5f01c6b037a394022cade01 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 22:47:36 +0200 Subject: [PATCH 19/32] fix(events): set event.element in click() so handlers can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `click()`'s bubble loop set `event.currentIntersection` but never `event.element`, so `onClick`/`onDoubleClick`/`onContextMenu` handlers reading the documented `event.element` always got `undefined` — unlike the `onPointer*` handlers, which go through `bubble()` and do set it. Set it per node (and clear it for the canvas-level dispatch), mirroring `bubble()`. --- src/pointers.ts | 2 ++ tests/core/pointer.test.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/pointers.ts b/src/pointers.ts index ec6b70b1..94155c8c 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -318,12 +318,14 @@ export class Pointer { while (node && !event.stopped && !visited.has(node)) { missed.delete(node) visited.add(node) + event.element = node ;(getMeta(node)?.props as any)?.[kind]?.(event) node = node.parent } } if (!event.stopped) { delete event.currentIntersection + event.element = undefined props[kind]?.(event) } diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index b1bfe6db..a1a8f136 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -82,6 +82,18 @@ describe("Pointer dispatch", () => { expect(click).toHaveBeenCalledTimes(1) }) + it("click sets event.element to the bubbling node", () => { + const seen: any[] = [] + const parent = eventful({ onClick: (e: any) => seen.push(e.element) }) + const child = eventful({ onClick: (e: any) => seen.push(e.element) }) + ;(child as any).parent = parent + const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) + + pointer.click("onClick", new MouseEvent("click")) + expect(seen[0]).toBe(child) // handler on child sees child + expect(seen[1]).toBe(parent) // bubbled handler on parent sees parent + }) + it("fires onClickMissed (mesh-level + canvas-level) when the click hits nothing", () => { const meshMissed = vi.fn() const canvasMissed = vi.fn() From cc63422913577ebbb62f379ef2ab978186575523 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 23:10:05 +0200 Subject: [PATCH 20/32] feat(events): a captured drag cancels its trailing click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser synthesizes a click/dblclick/contextmenu after every press+release, including at the end of a drag — and since these route through the capture-less `primary` pointer, a drag was firing a spurious click. Track pointers that moved while captured (a drag) and swallow the gesture's synthesized click/dblclick/contextmenu, re-arming on the next pointerdown. A captured press that doesn't move still clicks (it's a tap, not a drag). Adds `Pointer.capturing` so the manager can tell, per move, whether the pointer is captured. --- src/pointer-managers.ts | 19 +++++++++++++++- src/pointers.ts | 5 +++++ tests/core/canvas-events.test.tsx | 36 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts index d5f24a03..ac6474e1 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -19,6 +19,10 @@ type RayEvent = PointerEvent | MouseEvent | WheelEvent export class DOMPointerManager { private pointers = new Map() private primary: Pointer + /** Pointers that moved while captured this gesture — i.e. dragged. */ + private dragged = new Set() + /** A captured drag just ended; swallow its trailing click/dblclick/contextmenu. */ + private suppressClick = false constructor( private context: Context, @@ -66,15 +70,24 @@ export class DOMPointerManager { const onMove = (event: PointerEvent) => { aim(event) - this.forId(event.pointerId).move(event) + const pointer = this.forId(event.pointerId) + // Captured before this move → the pointer moved while captured: a drag. + if (pointer.capturing) this.dragged.add(event.pointerId) + pointer.move(event) } const onDown = (event: PointerEvent) => { aim(event) + // A fresh press starts a new gesture — re-arm clicks. + this.suppressClick = false + this.dragged.delete(event.pointerId) this.forId(event.pointerId).down(event) } const onUp = (event: PointerEvent) => { aim(event) this.forId(event.pointerId).up(event) + // A drag isn't a click: a gesture that moved while captured swallows the + // click/dblclick/contextmenu the browser synthesizes after this pointerup. + if (this.dragged.delete(event.pointerId)) this.suppressClick = true // A lifted touch no longer exists — leave + drop it so it keeps no state. // No explicit capture release needed: the browser auto-released on pointerup // (firing lostpointercapture), and the dropped Pointer is unreachable anyway. @@ -87,17 +100,21 @@ export class DOMPointerManager { // Always fire the canvas-level leave (a fresh pointer's leave does that even // with nothing hovered), matching the old per-session leave behavior. this.forId(event.pointerId).leave(event) + this.dragged.delete(event.pointerId) // a cancel ends the gesture without a click this.pointers.delete(event.pointerId) } const onClick = (event: MouseEvent) => { + if (this.suppressClick) return // trailing click of a captured drag aim(event) this.primary.click("onClick", event) } const onDoubleClick = (event: MouseEvent) => { + if (this.suppressClick) return aim(event) this.primary.click("onDoubleClick", event) } const onContextMenu = (event: MouseEvent) => { + if (this.suppressClick) return aim(event) this.primary.click("onContextMenu", event) } diff --git a/src/pointers.ts b/src/pointers.ts index 94155c8c..2f0fe735 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -102,6 +102,11 @@ export class Pointer { return this.captured?.element === object } + /** Whether this pointer currently holds any capture. */ + get capturing(): boolean { + return this.captured != null + } + /** * Capture this pointer to `element` (the node whose handler called * `setPointerCapture`): build the drag plane from the hit point and world-space diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 3e5d907c..c1b4f710 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -664,6 +664,42 @@ describe("pointer capture", () => { fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // off the mesh expect(onMove).toHaveBeenCalledTimes(2) // still captured → second move reaches it }) + + const clickAt = (x: number, y: number) => + new MouseEvent("click", { clientX: x, clientY: y, bubbles: true }) + + /** Captures on pointerdown and reports clicks. */ + const Draggable = (props: { onClick?: (e: any) => void }) => ( + e.setPointerCapture()} onClick={(e: any) => props.onClick?.(e)}> + + + + ) + + it("a captured drag suppresses the trailing click (a drag isn't a click)", () => { + const onClick = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // moved while captured → dragged + fireEvent(canvas, pointerAt("pointerup", MISS_X, MISS_Y)) + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) // browser-synthesized click + + expect(onClick).not.toHaveBeenCalled() + }) + + it("a captured press that doesn't move still clicks", () => { + const onClick = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures, no move + fireEvent(canvas, pointerAt("pointerup", HIT_X, HIT_Y)) + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + + expect(onClick).toHaveBeenCalledTimes(1) // a tap, not a drag + }) }) /**********************************************************************************/ From 6c201fe26e9237e0485881fab59526c993a7c1fe Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 23:27:55 +0200 Subject: [PATCH 21/32] docs(events): document event-object semantics (shared, read-only, sync capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Reading the event" note to the events overview: the event is one object reused across the bubble chain (like a DOM event), so its data (intersection, …) is shared scene data to be treated as read-only, and setPointerCapture() must be called synchronously in the handler because it captures event.element, which is cleared after dispatch. --- site/src/routes/api/events/overview.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index cf0f747b..40ff3806 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -88,6 +88,13 @@ This extra detail is what lets you go beyond "did they click it?". A few of the - `normal` — align a world-space gizmo to the surface; - `distance` — react to how far away the hit is. +### Reading the event + +The event object is created once per dispatch and **reused** as it bubbles up the chain — the same object is passed to every handler, just like a DOM event. Two things follow from that: + +- **Treat it as read-only.** `intersection`, `intersections`, and the rest are shared scene data, not per-handler copies. Mutating them affects the other handlers in the same dispatch (and, while a pointer is captured, persists across moves). Read from the event; don't write to it. +- **`setPointerCapture()` must be called synchronously**, inside the handler. It captures `event.element` (the node currently firing), which is cleared once the dispatch finishes — so calling it later (after an `await`, a timer, etc.) is a no-op. Start a capture in the handler itself: in `onPointerDown`, or in `onPointerMove` once you've crossed a drag threshold. + ## Reading the full hit stack Most handlers care only about the nearest hit, which is what `intersection` resolves to. For x-ray tools, measure-through-walls, or click-through selection, `intersections` is sorted nearest-first: From 9ff63c392503c07de1d4a6da43e470cdadaff68c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 23:29:14 +0200 Subject: [PATCH 22/32] docs(events): note reproject also falls back when the plane is behind the ray --- src/pointers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pointers.ts b/src/pointers.ts index 2f0fe735..d29af568 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -155,7 +155,8 @@ export class Pointer { /** * The forced intersection for a captured pointer: intersect the live ray with * the stored plane for a fresh `point`/`distance`, keeping the original hit's - * `face`/`uv`/`object`. Falls back to the stored hit if the ray is parallel. + * `face`/`uv`/`object`. Falls back to the stored hit when there's no forward + * intersection — the ray is parallel to, or points away from, the plane. */ private reproject(captured: Captured): Intersection { this.raycaster.aim(this.context) From ea271a1a41b28db607dbdde0b8c60f012d9cd406 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 01:04:58 +0200 Subject: [PATCH 23/32] =?UTF-8?q?feat(events):=20finalize=20the=20pointer?= =?UTF-8?q?=20event=20API=20=E2=80=94=20object/currentObject=20+=20async?= =?UTF-8?q?=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename event.element to event.currentObject (the 3D analogue of a DOM event's currentTarget — the node a bubbled handler is firing on, cleared after dispatch) and add event.object (= intersections[0].object, the closest hit, stable after dispatch like a DOM target). Make setPointerCapture accept an optional target: the no-arg form still captures currentObject synchronously, while passing a target starts a capture later (after an await or timer), synthesizing a camera-facing drag plane when there's no live hit. --- src/pointers.ts | 43 +++++++++++++++++++++++-------- src/types.ts | 31 +++++++++++++++-------- tests/core/pointer.test.tsx | 50 +++++++++++++++++++++++++++++-------- 3 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index d29af568..d53514b1 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -34,6 +34,7 @@ export function createThreeEvent< if (intersections) { event.intersections = intersections event.intersection = intersections[0] + event.object = intersections[0]?.object } return event as Prettify< @@ -61,7 +62,7 @@ export type PointerCaptureSink = { capture(): void; release(): void } interface Captured { /** * The captured target — the node whose handler called `setPointerCapture` - * (`event.element`). Captured move/up deliver exclusively to its chain; may be + * (`event.currentObject`). Captured move/up deliver exclusively to its chain; may be * an ancestor of the hit leaf. */ element: Object3D @@ -113,7 +114,7 @@ export class Pointer { * normal (camera-facing if the hit has no face), then engage the OS sink. * Subsequent move/up reproject the live ray onto this plane and deliver * exclusively to `element`'s chain until released. A nullish `element` (the - * canvas-level dispatch has no `event.element`) is a no-op. + * canvas-level dispatch has no `event.currentObject`) is a no-op. * * The face normal is expressed in the hit leaf's local space, so it's oriented * with `intersection.object`'s world matrix — which differs from `element` when @@ -168,11 +169,31 @@ export class Pointer { /** Attach the capture methods to a capturable event (down/up/move). */ private attachCapture(event: any) { - event.setPointerCapture = () => { - if (event.element) this.capture(event.element, event.currentIntersection) + // No-arg captures `event.currentObject` (the firing node) — sync-only, since it's + // cleared after dispatch (like a DOM event's `currentTarget`). Pass a `target` + // to capture it later: the caller holds the reference, so it works async. + event.setPointerCapture = (target?: Object3D) => { + const element = target ?? event.currentObject + if (!element) return + // Sync: the live hit. Async (event already past dispatch, `currentIntersection` + // gone): synthesize a contact at the target's centre. + const intersection = event.currentIntersection ?? this.syntheticHit(element) + this.capture(element, intersection) } event.releasePointerCapture = () => this.release() - event.hasPointerCapture = () => !!event.element && this.hasCaptured(event.element) + event.hasPointerCapture = (target?: Object3D) => { + const element = target ?? event.currentObject + return element != null && this.hasCaptured(element) + } + } + + /** + * A contact for an explicit/deferred capture with no live ray hit: the object's + * world-space centre, no face — so {@link capture} builds a camera-facing drag + * plane through it. Used by `setPointerCapture(target)` called after dispatch. + */ + private syntheticHit(object: Object3D): Intersection { + return { object, point: object.getWorldPosition(new Vector3()), distance: 0 } as Intersection } /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ @@ -243,7 +264,7 @@ export class Pointer { /** * Bubble `handler` up each root's parent chain — setting `event.currentIntersection` - * for the chain and `event.element` for each node it fires on — honoring + * for the chain and `event.currentObject` for each node it fires on — honoring * `stopPropagation`, then fire the canvas-level handler if nothing stopped it. Each * `[intersection, root]` pairs the starting node (`root`) with the intersection to * expose while walking it: the captured path passes a single pair rooted at the @@ -257,7 +278,7 @@ export class Pointer { let node: Object3D | null = root while (node && !event.stopped && !visited.has(node)) { visited.add(node) - event.element = node + event.currentObject = node ;(getMeta(node)?.props as any)?.[handler]?.(event) node = node.parent } @@ -265,7 +286,7 @@ export class Pointer { } if (!event.stopped) { delete event.currentIntersection - event.element = undefined + event.currentObject = undefined ;(this.context.props as Record)[handler]?.(event) } } @@ -276,7 +297,7 @@ export class Pointer { * can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring * `stopPropagation`, then fires canvas-level if unstopped. `extra` is merged onto the * event (plugin sources use it for rich fields, e.g. the XR controller payload), and - * `event.element` exposes the node a handler is firing on. When this pointer + * `event.currentObject` exposes the node a handler is firing on. When this pointer * holds a capture, delivery is exclusive to the captured object's chain (the * registry is not raycast) but still bubbles to the canvas-level handler; the * intersection is the live ray reprojected onto the captured plane. @@ -324,14 +345,14 @@ export class Pointer { while (node && !event.stopped && !visited.has(node)) { missed.delete(node) visited.add(node) - event.element = node + event.currentObject = node ;(getMeta(node)?.props as any)?.[kind]?.(event) node = node.parent } } if (!event.stopped) { delete event.currentIntersection - event.element = undefined + event.currentObject = undefined props[kind]?.(event) } diff --git a/src/types.ts b/src/types.ts index bfd6136b..aff10960 100644 --- a/src/types.ts +++ b/src/types.ts @@ -381,11 +381,12 @@ export type ThreeEvent< { nativeEvent: TEvent /** - * The node a bubbled handler is currently firing on (the ancestor reached + * The object a bubbled handler is currently firing on (the ancestor reached * while walking up the hit chain), or `undefined` for the canvas-level - * dispatch. Set by `Pointer.dispatch`; plugin sources (e.g. XR) read it. + * dispatch — the 3D analogue of a DOM event's `currentTarget`, so it's only + * valid during the handler. Set by `Pointer.dispatch`; plugin sources read it. */ - element?: Object3D + currentObject?: Object3D }, When< TConfig["stoppable"], @@ -400,6 +401,8 @@ export type ThreeEvent< currentIntersection: Intersection intersection: Intersection intersections: Intersection[] + /** The closest hit object — `intersections[0].object`. The 3D analogue of a DOM event's `target`; stable after dispatch. */ + object: Object3D } >, ] @@ -407,21 +410,27 @@ export type ThreeEvent< export type PointerCapture = { /** - * Capture this event's pointer to the node the handler is firing on - * (`event.element`). Subsequent move/up for this pointer deliver exclusively to - * that node's chain (still bubbling to the canvas-level handler) until released — - * even off-ray and, for the DOM source, off-canvas. Off-ray, `event.intersection` - * is reprojected onto the grabbed object's plane so `point` keeps tracking. + * Capture this event's pointer to `target`, or — with no argument — to the node + * the handler is firing on (`event.currentObject`). Subsequent move/up for this pointer + * deliver exclusively to that object's chain (still bubbling to the canvas-level + * handler) until released — even off-ray and, for the DOM source, off-canvas. + * Off-ray, `event.intersection` is reprojected onto the captured plane so `point` + * keeps tracking. + * + * The no-arg form must be called synchronously in the handler (`event.currentObject` is + * cleared after dispatch, like a DOM event's `currentTarget`). Pass `target` to + * start a capture later (after an `await`/timer); with no live hit it drags on a + * camera-facing plane through the target's centre. */ - setPointerCapture(): void + setPointerCapture(target?: Object3D): void /** * Release a capture started with `setPointerCapture`. Also released * automatically on pointerup/cancel for the DOM source, and on the paired end * event for XR. */ releasePointerCapture(): void - /** Whether this event's node currently holds the pointer capture. */ - hasPointerCapture(): boolean + /** Whether `target` (default: this event's node) currently holds the pointer capture. */ + hasPointerCapture(target?: Object3D): boolean } type EventHandlersMap = { diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index a1a8f136..b4ba6826 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest" -import { Object3D, Ray, Vector3 } from "three" +import { Object3D, PerspectiveCamera, Ray, Vector3 } from "three" import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" import { meta } from "../../src/utils.ts" @@ -82,10 +82,10 @@ describe("Pointer dispatch", () => { expect(click).toHaveBeenCalledTimes(1) }) - it("click sets event.element to the bubbling node", () => { + it("click sets event.currentObject to the bubbling node", () => { const seen: any[] = [] - const parent = eventful({ onClick: (e: any) => seen.push(e.element) }) - const child = eventful({ onClick: (e: any) => seen.push(e.element) }) + const parent = eventful({ onClick: (e: any) => seen.push(e.currentObject) }) + const child = eventful({ onClick: (e: any) => seen.push(e.currentObject) }) ;(child as any).parent = parent const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) @@ -105,16 +105,16 @@ describe("Pointer dispatch", () => { expect(canvasMissed).toHaveBeenCalledTimes(1) }) - it("dispatch sets event.element to the bubbling node and merges extra fields", () => { + it("dispatch sets event.currentObject to the bubbling node and merges extra fields", () => { const seen: any[] = [] - const parent = eventful({ onPing: (e: any) => seen.push({ element: e.element, k: e.k }) }) - const child = eventful({ onPing: (e: any) => seen.push({ element: e.element, k: e.k }) }) + const parent = eventful({ onPing: (e: any) => seen.push({ currentObject: e.currentObject, k: e.k }) }) + const child = eventful({ onPing: (e: any) => seen.push({ currentObject: e.currentObject, k: e.k }) }) ;(child as any).parent = parent const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) ;(pointer as any).dispatch("onPing", new Event("x"), { k: 42 }) - expect(seen[0].element).toBe(child) // handler on child sees child - expect(seen[1].element).toBe(parent) // bubbled handler on parent sees parent + expect(seen[0].currentObject).toBe(child) // handler on child sees child + expect(seen[1].currentObject).toBe(parent) // bubbled handler on parent sees parent expect(seen.every(s => s.k === 42)).toBe(true) // extra merged onto every dispatch }) @@ -226,7 +226,7 @@ describe("Pointer capture lifecycle", () => { expect(pointer.hasCaptured(mesh)).toBe(false) // rolled back, capture didn't engage }) - it("orients the drag plane with the hit leaf's transform, not event.element's", () => { + it("orients the drag plane with the hit leaf's transform, not event.currentObject's", () => { // Capture from a parent `element` while the hit leaf is a child rotated 90° about // Y, so its local +Z face normal maps to world +X — the drag plane is x=0 (yz). // The old code used `element`'s identity matrix → a z=0 plane. @@ -277,6 +277,36 @@ describe("Pointer capture lifecycle", () => { expect(leave).toHaveBeenCalledTimes(1) }) + it("setPointerCapture(target) captures after dispatch (async); the no-arg form doesn't", () => { + let captureWithTarget: (() => void) | undefined + let captureNoArg: (() => void) | undefined + const mesh = eventful({ + onPointerDown: (e: any) => { + // Stash the calls instead of capturing now — run them after dispatch. + captureWithTarget = () => e.setPointerCapture(mesh) + captureNoArg = () => e.setPointerCapture() + }, + }) + const camera = new PerspectiveCamera() + camera.updateMatrixWorld() // syntheticHit builds a camera-facing plane + const sink = spySink() + const pointer = new Pointer( + { eventRegistry: [mesh], props: {}, camera } as any, + fakeRaycaster({ target: mesh }), + sink, + ) + + pointer.down(new Event("pointerdown")) // handler only stashes + expect(pointer.hasCaptured(mesh)).toBe(false) + + captureNoArg!() // post-dispatch: event.currentObject is cleared → no-op + expect(pointer.hasCaptured(mesh)).toBe(false) + + captureWithTarget!() // explicit target survives → captures + expect(pointer.hasCaptured(mesh)).toBe(true) + expect(sink.capture).toHaveBeenCalledTimes(1) + }) + it("delivers onPointerUp exclusively to the captured object after the ray moves off it", () => { const capturedUp = vi.fn() const otherUp = vi.fn() From a89deb3f9c752faa5e12467dd028f883659e1fd8 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 01:04:58 +0200 Subject: [PATCH 24/32] docs(events): document object/currentObject and async setPointerCapture Update the Events API overview and refresh the README Event Handling section: drop the removed onMouse* handlers, document the full event object (intersections, object, currentObject, capture methods), and add a Pointer Capture section. --- README.md | 88 +++++++++++++++---------- site/src/routes/api/events/overview.mdx | 9 +-- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5d2889f6..9437fd04 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ - [Controlling Raycasting with raycastable](#controlling-raycasting-with-raycastable) - [Supported Events](#supported-events) - [Event Object](#event-object) + - [Pointer Capture](#pointer-capture) - [Event Propagation](#event-propagation) - [Missed Events](#missed-events) - [Hover Events](#hover-events) @@ -1163,45 +1164,66 @@ const MyTest = () => { ### Supported Events -- `onClick` - Fired when clicking on an object -- `onClickMissed` - Fired when a click doesn't hit any objects with onClick handlers -- `onContextMenu` - Fired when right-clicking on an object -- `onContextMenuMissed` - Fired when a right-click doesn't hit any objects with onContextMenu handlers -- `onDoubleClick` - Fired when double-clicking on an object -- `onDoubleClickMissed` - Fired when a double-click doesn't hit any objects with onDoubleClick handlers -- `onMouseDown` - Fired when mouse button is pressed -- `onMouseEnter` - Fired when mouse enters object -- `onMouseLeave` - Fired when mouse leaves object -- `onMouseMove` - Fired when mouse moves over object -- `onMouseUp` - Fired when mouse button is released -- `onPointerDown` - Fired when pointer is pressed -- `onPointerEnter` - Fired when pointer enters object -- `onPointerLeave` - Fired when pointer leaves object -- `onPointerMove` - Fired when pointer moves -- `onPointerUp` - Fired when pointer is released -- `onWheel` - Fired on mouse wheel events +**Pointer events** — carry the [pointer-capture API](#pointer-capture): + +- `onPointerDown` — pointer pressed on an object +- `onPointerUp` — pointer released +- `onPointerMove` — pointer moves over an object +- `onPointerEnter` — pointer enters an object (can't be stopped) +- `onPointerLeave` — pointer leaves an object (can't be stopped) + +**Mouse-semantic events:** + +- `onClick` — click on an object +- `onDoubleClick` — double-click on an object +- `onContextMenu` — right-click on an object +- `onWheel` — wheel scroll over an object + +**Missed events** — fire when the interaction didn't hit the handler's object or its descendants: + +- `onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed` ### Event Object -Event handlers receive an event object with the following properties: +Every handler receives one event object, created once per dispatch and reused as it bubbles up the chain — like a DOM event, treat it as read-only. -- **nativeEvent**: The original DOM event -- **stopped**: Whether propagation has been stopped (only for stoppable events) -- **stopPropagation**: Method to stop event propagation (only for stoppable events) +| Property | Type | Available on | Description | +| --- | --- | --- | --- | +| `nativeEvent` | DOM event | all events | The original DOM `PointerEvent` / `MouseEvent` / `WheelEvent`. | +| `intersections` | `Intersection[]` | raycasting events | All hits under the pointer, sorted nearest-first. | +| `intersection` | `Intersection` | raycasting events | Shorthand for `intersections[0]` — the closest hit. | +| `object` | `Object3D` | raycasting events | The closest hit object (`intersections[0].object`). Stable after dispatch — the 3D analogue of a DOM event's `target`. | +| `currentObject` | `Object3D` | inside an object handler | The object this handler is firing on; walks up the ancestor chain as the event bubbles, and is cleared after dispatch — the 3D analogue of `currentTarget`. | +| `currentIntersection` | `Intersection` | inside an object handler | The intersection for `currentObject`. Absent at the canvas level. | +| `stopped` | `boolean` | stoppable events | Whether `stopPropagation()` has been called. | +| `stopPropagation` | `() => void` | stoppable events | Stops both raycast and tree propagation. | +| `setPointerCapture` | `(target?: Object3D) => void` | pointer events | Capture the pointer to `target` (default: `currentObject`). See [Pointer Capture](#pointer-capture). | +| `releasePointerCapture` | `() => void` | pointer events | End a capture early. | +| `hasPointerCapture` | `(target?: Object3D) => boolean` | pointer events | Whether `target` currently holds the capture. | -
-Typescript Interface +Each `Intersection` is a standard [three.js `Intersection`](https://threejs.org/docs/#api/en/core/Raycaster.intersectObject): `object`, `point` (world-space `Vector3`), `distance`, plus `face`, `uv`, `normal`, and `instanceId` when available. + +### Pointer Capture + +Inside a pointer handler, `setPointerCapture()` routes every later move — and the release — for that pointer to the captured object, even once the pointer leaves it. This is the basis for drag interactions: ```tsx -interface Event { - nativeEvent: T - stopped?: boolean - stopPropagation?: () => void -} + e.setPointerCapture()} + onPointerMove={e => { + if (e.hasPointerCapture()) { + // dragging: e.intersection.point keeps tracking on a drag plane, + // even when the pointer moves off the mesh + } + }} +> + + + ``` -
+The no-arg form captures `currentObject` and must be called synchronously in the handler (`currentObject` is cleared after dispatch). To start a capture later — after an `await` or a timer — pass the object: `setPointerCapture(mesh)`. Capture releases automatically on pointer up / cancel; call `releasePointerCapture()` to end it early. ### Event Propagation @@ -1265,12 +1287,12 @@ Not all events in solid-three can be stopped with `stopPropagation()`. This desi **Non-stoppable events:** - `onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed` - [Missed Events](#missed-events) always fire for all registered handlers -- `onMouseEnter`, `onPointerEnter` - Enter events always fire [Hover Events](#hover-events-entermoveleave) -- `onMouseLeave`, `onPointerLeave` - Leave events always fire [Hover Events](#hover-events-entermoveleave) +- `onPointerEnter` - Enter events always fire [Hover Events](#hover-events-entermoveleave) +- `onPointerLeave` - Leave events always fire [Hover Events](#hover-events-entermoveleave) **Stoppable events:** -- All other events (`onClick`, `onMouseMove`, `onPointerMove`, `onMouseDown`, etc.) can be stopped with `stopPropagation()` +- All other events (`onClick`, `onPointerMove`, `onPointerDown`, etc.) can be stopped with `stopPropagation()` ### Missed Events @@ -1356,7 +1378,7 @@ This is useful for: ### Hover Events (Enter/Move/Leave) -solid-three handles hover events (`onMouseEnter`, `onMouseMove`, `onMouseLeave`, `onPointerEnter`, `onPointerMove`, `onPointerLeave`) with a specific scheduling approach: +solid-three handles hover events (`onPointerEnter`, `onPointerMove`, `onPointerLeave`) with a specific scheduling approach: **Event Scheduling:** diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 40ff3806..61aba7ef 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -38,8 +38,9 @@ Every handler receives one event argument that combines the original DOM event w | `nativeEvent` | `MouseEvent \| PointerEvent \| WheelEvent` | always | The original DOM event that triggered the handler. | | `intersections` | `Intersection[]` | events that raycast | All hit intersections, sorted nearest-first. | | `intersection` | `Intersection` | events that raycast | Shorthand for `intersections[0]` — the closest hit overall. | +| `object` | `Object3D` | events that raycast | The closest hit object (`intersections[0].object`) — stable after dispatch, the 3D analogue of a DOM event's `target`. | | `currentIntersection` | `Intersection` | inside an object handler | The intersection for the current handler's object. Absent on canvas-level dispatch. | -| `element` | `Object3D` | inside an object handler | The node this handler is attached to. As the event bubbles, `element` walks up the ancestor chain while `currentIntersection` stays on the hit object. Absent on canvas-level dispatch. | +| `currentObject` | `Object3D` | inside an object handler | The object this handler is firing on; as the event bubbles it walks up the ancestor chain (while `currentIntersection` stays on the hit) and is cleared after dispatch — the 3D analogue of a DOM event's `currentTarget`. | | `stopped` | `boolean` | stoppable events only | Whether `stopPropagation()` has been called. | | `stopPropagation` | `() => void` | stoppable events only | Stops both raycast and tree propagation. See [Stoppable vs non-stoppable](#stoppable-vs-non-stoppable-events). | @@ -93,7 +94,7 @@ This extra detail is what lets you go beyond "did they click it?". A few of the The event object is created once per dispatch and **reused** as it bubbles up the chain — the same object is passed to every handler, just like a DOM event. Two things follow from that: - **Treat it as read-only.** `intersection`, `intersections`, and the rest are shared scene data, not per-handler copies. Mutating them affects the other handlers in the same dispatch (and, while a pointer is captured, persists across moves). Read from the event; don't write to it. -- **`setPointerCapture()` must be called synchronously**, inside the handler. It captures `event.element` (the node currently firing), which is cleared once the dispatch finishes — so calling it later (after an `await`, a timer, etc.) is a no-op. Start a capture in the handler itself: in `onPointerDown`, or in `onPointerMove` once you've crossed a drag threshold. +- **`setPointerCapture()` captures `event.currentObject`** (the object currently firing), which is cleared after dispatch — so the no-arg form must be called synchronously in the handler. To start a capture later (after an `await`, a timer), pass the object explicitly: `setPointerCapture(mesh)`. (With no live hit, a deferred capture drags on a camera-facing plane through the object's center.) ## Reading the full hit stack @@ -228,13 +229,13 @@ Pointer capture fixes this. Inside an `onPointerDown`, `onPointerMove`, or `onPo | Method | Description | | --- | --- | -| `setPointerCapture()` | Capture this pointer to `event.element` (the node the handler is firing on). | +| `setPointerCapture(target?)` | Capture the pointer to `target`, or — with no argument — `event.currentObject` (the firing object; sync-only). | | `releasePointerCapture()` | Release a capture started with `setPointerCapture()`. | | `hasPointerCapture()` | Whether this event's node currently holds the capture. | Once captured, every subsequent `onPointerMove` and `onPointerUp` for that pointer is delivered **exclusively** to the captured node's chain — still bubbling up to the canvas-level handler, but no longer raycasting against the rest of the scene. Delivery continues even when the pointer is off the object, and (for the DOM pointer source) off the canvas. Hover is frozen for the duration: no enter/leave fires while a capture is held. -Capture is **element-scoped** — you capture the node whose handler is running. Calling `setPointerCapture()` from a canvas-level handler is a no-op, because there is no `element` there. +Capture is **object-scoped** — the no-arg form captures the object whose handler is running, so calling it from a canvas-level handler is a no-op (there's no `currentObject` there). Pass an explicit `target` to capture any object, including later/async. While the pointer is off the ray, `event.intersection` is reprojected onto a plane through the original grab point, so `event.intersection.point` keeps tracking a sensible world-space position to drag toward. From 98e53200243dba2a0c39408abcfd9805de44dba0 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 01:06:28 +0200 Subject: [PATCH 25/32] docs(site): guard the drag demo on hasPointerCapture, not a parallel signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onPointerMove guard now reads event.hasPointerCapture() — true only while the mesh holds the capture — instead of a separate dragging() check, reinforcing that the capture itself is the drag state. The signal stays for the reactive scale/color visuals, which can't read capture state. --- site/src/snippets/04-drag.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx index 95ce3007..7886f78d 100644 --- a/site/src/snippets/04-drag.tsx +++ b/site/src/snippets/04-drag.tsx @@ -21,7 +21,7 @@ export default () => { setDragging(true) }} onPointerMove={event => { - if (!dragging()) return + if (!event.hasPointerCapture()) return // the capture itself is the drag state // event.intersection.point tracks the drag plane, even off the mesh. const next = event.intersection.point.clone().add(grabOffset) setPosition([next.x, next.y, next.z]) From a6262187b1535b8404f2d9b698bec5493cf3a2a7 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 01:46:17 +0200 Subject: [PATCH 26/32] feat(events): reactive hasPointerCapture(object) for declarative drag state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a global, context-free reactive predicate — hasPointerCapture(object) — so visuals can react to a capture without maintaining a parallel dragging signal. Backed by a refcounted ReactiveMap (one object can be captured by more than one pointer at once), keyed by Object3D identity, which is unique across canvases. The core Pointer stays framework-agnostic: it records captures through an injected PointerCaptureRegistry, wired to the global reactive map in create-events. The predicate is nullish-tolerant, so a not-yet-mounted ref reads false. --- package.json | 1 + pnpm-lock.yaml | 17 +++++++-- src/create-events.ts | 3 +- src/index.ts | 1 + src/pointer-capture.ts | 57 +++++++++++++++++++++++++++++++ src/pointer-managers.ts | 20 +++++++---- src/pointers.ts | 20 +++++++++++ tests/core/canvas-events.test.tsx | 33 +++++++++++++++++- 8 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 src/pointer-capture.ts diff --git a/package.json b/package.json index 72381ae0..fb6460f7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ }, "dependencies": { "@bigmistqke/solid-whenever": "^0.1.0", + "@solid-primitives/map": "^0.7.3", "@solid-primitives/resize-observer": "^2.0.25", "debounce": "^2.1.0", "vite-tsconfig-paths": "^5.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14cefa5a..bd8bbd54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@bigmistqke/solid-whenever': specifier: ^0.1.0 version: 0.1.1(solid-js@1.9.13) + '@solid-primitives/map': + specifier: ^0.7.3 + version: 0.7.3(solid-js@1.9.13) '@solid-primitives/resize-observer': specifier: ^2.0.25 version: 2.1.5(solid-js@1.9.13) @@ -1876,6 +1879,11 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/map@0.7.3': + resolution: {integrity: sha512-2Ach52ANEWYUKFtlrKWljrCtAHJwXnfNEvNfQwA+80nS/Bdw9fSumWQiRJNoDQLN0k5iEggWRBHd6vC/uqYKcA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/media@2.3.5': resolution: {integrity: sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA==} peerDependencies: @@ -1964,7 +1972,7 @@ packages: solid-js: ^1.8.6 '@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152': - resolution: {integrity: sha512-h14+vdw26apDAi/l5HEG769B4RJ3NWjj92npNMM8VUpxaQwb1f9BDxjM9XrxwSa04tpZqs+7ZG8/uL+aEsIAwA==, tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152} + resolution: {tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152} version: 2.0.0-alpha.3 engines: {node: '>=22'} peerDependencies: @@ -1981,7 +1989,7 @@ packages: optional: true '@solidjs/vite-plugin-nitro-2@https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152': - resolution: {integrity: sha512-EA2P/X3tFngkEq6CE+/VUQP6d812BZx5IK0vaC00+tw+n4voXS11WKkNo/qkxZkkruszV6ulzG4qomJpoIlmcA==, tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152} + resolution: {tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152} version: 0.3.0 peerDependencies: vite: ^7 @@ -7910,6 +7918,11 @@ snapshots: '@solid-primitives/trigger': 1.2.3(solid-js@1.9.13) solid-js: 1.9.13 + '@solid-primitives/map@0.7.3(solid-js@1.9.13)': + dependencies: + '@solid-primitives/trigger': 1.2.3(solid-js@1.9.13) + solid-js: 1.9.13 + '@solid-primitives/media@2.3.5(solid-js@1.9.13)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13) diff --git a/src/create-events.ts b/src/create-events.ts index 0b0c3aff..de910d45 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -1,5 +1,6 @@ import { onCleanup } from "solid-js" import { Object3D } from "three" +import { captureRegistry } from "./pointer-capture.ts" import { DOMPointerManager } from "./pointer-managers.ts" import { CursorRaycaster, type ScreenRaycaster } from "./raycasters.tsx" import type { Context, EventName, Meta } from "./types.ts" @@ -40,7 +41,7 @@ export function createEvents(context: Context) { "setCursor" in candidate && "cast" in candidate ? (candidate as ScreenRaycaster) : new CursorRaycaster() - const manager = new DOMPointerManager(context, screenRaycaster) + const manager = new DOMPointerManager(context, screenRaycaster, captureRegistry) // Remove the canvas listeners when the Canvas owner disposes. onCleanup(manager.connect()) diff --git a/src/index.ts b/src/index.ts index da64ebb0..9e53df58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { createXR, useXR } from "./create-xr.tsx" export type { XRContext, XRState } from "./create-xr.tsx" export { useFrame, useLoader, useThree } from "./hooks.ts" export { plugin } from "./plugin.ts" +export { hasPointerCapture } from "./pointer-capture.ts" export { Pointer, createThreeEvent, type PointerRaycaster } from "./pointers.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" diff --git a/src/pointer-capture.ts b/src/pointer-capture.ts new file mode 100644 index 00000000..490f36b4 --- /dev/null +++ b/src/pointer-capture.ts @@ -0,0 +1,57 @@ +import { ReactiveMap } from "@solid-primitives/map" +import type { Object3D } from "three" +import type { PointerCaptureRegistry } from "./pointers.ts" + +/** + * Reactive mirror of which objects currently hold a pointer capture, kept in one + * global keyed by `Object3D` identity — an object lives in a single scene and is + * captured by a single pointer system, so the key can't collide across multiple + * ``es. This lets {@link hasPointerCapture} be a context-free reactive + * read instead of a hook. + * + * The value is a refcount: one object can be captured by more than one pointer at + * once (two touches grabbing the same mesh), so the entry survives until the last + * release. `ReactiveMap.has` tracks *presence*, so a reader only re-runs on the + * 0↔1 transitions — not on refcount bumps between live captures. + */ +const counts = new ReactiveMap() + +/** + * The capture registry handed to the framework-agnostic `Pointer` (via + * `DOMPointerManager`) so the core can record captures without importing any + * reactivity. Mirrors `Pointer`'s capture lifecycle: `add` on a successful + * capture, `delete` on release/drop. + */ +export const captureRegistry: PointerCaptureRegistry = { + add(object) { + counts.set(object, (counts.get(object) ?? 0) + 1) + }, + delete(object) { + const count = counts.get(object) + if (count === undefined) return + if (count <= 1) counts.delete(object) + else counts.set(object, count - 1) + }, +} + +/** + * Reactive predicate: whether `object` currently holds a pointer capture. Read it + * inside a tracking scope (a JSX prop, a memo, an effect) and it re-runs only when + * that object's capture status actually flips — the basis for declarative drag + * visuals without maintaining your own `dragging` signal: + * + * ```tsx + * // A signal ref, so the read re-subscribes once the object mounts. + * const [mesh, setMesh] = createSignal() + * event.setPointerCapture()} + * /> + * ``` + * + * A nullish `object` (an unmounted ref) reads `false`. For the imperative, + * in-handler check, use the event's own `event.hasPointerCapture()` instead. + */ +export function hasPointerCapture(object: Object3D | undefined | null): boolean { + return object != null && counts.has(object) +} diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts index ac6474e1..fceae036 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -1,5 +1,5 @@ import { Vector2, type Object3D } from "three" -import { Pointer } from "./pointers.ts" +import { Pointer, type PointerCaptureRegistry } from "./pointers.ts" import type { ScreenRaycaster } from "./raycasters.tsx" import type { Context } from "./types.ts" @@ -27,8 +27,9 @@ export class DOMPointerManager { constructor( private context: Context, private raycaster: ScreenRaycaster, + private captureRegistry?: PointerCaptureRegistry, ) { - this.primary = new Pointer(context, raycaster) + this.primary = new Pointer(context, raycaster, undefined, captureRegistry) } /** @@ -47,12 +48,17 @@ export class DOMPointerManager { let pointer = this.pointers.get(id) if (!pointer) { const canvas = this.context.canvas - pointer = new Pointer(this.context, this.raycaster, { - capture: () => canvas.setPointerCapture(id), - release: () => { - if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id) + pointer = new Pointer( + this.context, + this.raycaster, + { + capture: () => canvas.setPointerCapture(id), + release: () => { + if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id) + }, }, - }) + this.captureRegistry, + ) this.pointers.set(id, pointer) } return pointer diff --git a/src/pointers.ts b/src/pointers.ts index d53514b1..0f1756c4 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -58,6 +58,18 @@ export function createThreeEvent< /** The OS-level half of pointer capture, injected per source (DOM canvas vs XR). */ export type PointerCaptureSink = { capture(): void; release(): void } +/** + * A sink for capture-membership changes, injected by the Solid layer so this + * framework-agnostic class can feed a reactive `hasPointerCapture(object)` without + * importing any reactivity. `add` runs on a successful capture, `delete` on + * release/drop; the implementation refcounts (one object can be captured by more + * than one pointer). + */ +export interface PointerCaptureRegistry { + add(object: Object3D): void + delete(object: Object3D): void +} + /** A held pointer capture. */ interface Captured { /** @@ -96,6 +108,7 @@ export class Pointer { private context: Context, private raycaster: PointerRaycaster, private sink?: PointerCaptureSink, + private captureRegistry?: PointerCaptureRegistry, ) {} /** Whether this pointer currently holds `object` captured. */ @@ -138,18 +151,25 @@ export class Pointer { this.sink?.capture() } catch { this.captured = null + return } + // Record only a capture that actually took, so the reactive mirror never + // reports a rolled-back one. + this.captureRegistry?.add(element) } /** Release a held capture and notify the OS sink. Idempotent. */ release() { if (!this.captured) return + this.captureRegistry?.delete(this.captured.element) this.captured = null this.sink?.release() } /** Clear capture state only, without notifying the sink (the OS already released). */ dropCapture() { + if (!this.captured) return + this.captureRegistry?.delete(this.captured.element) this.captured = null } diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index c1b4f710..198c3120 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -2,7 +2,7 @@ import { fireEvent } from "@solidjs/testing-library" import { createSignal } from "solid-js" import * as THREE from "three" import { describe, expect, it, vi } from "vitest" -import { createT } from "../../src/index.ts" +import { createT, hasPointerCapture } from "../../src/index.ts" import { test } from "../../src/testing/index.tsx" const T = createT(THREE) @@ -700,6 +700,37 @@ describe("pointer capture", () => { expect(onClick).toHaveBeenCalledTimes(1) // a tap, not a drag }) + + it("hasPointerCapture(object) reactively drives a prop binding (the demo pattern)", async () => { + // A signal ref, so the binding re-subscribes once the mesh mounts — refs flow + // bottom-up, so a plain `let` ref read by a child/descendant would still be + // undefined when that binding first runs. + const [mesh, setMesh] = createSignal() + const { canvas } = test(() => ( + e.setPointerCapture()} + > + + + + )) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + await Promise.resolve() // let the ref land and the binding take its first read + + const before = mesh()?.visible + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures → true + await Promise.resolve() + const during = mesh()?.visible + fireEvent(canvas, pointerAt("pointerup", HIT_X, HIT_Y)) + // The browser auto-releases on pointerup, firing lostpointercapture → false. + fireEvent(canvas, new PointerEvent("lostpointercapture", { pointerId: 1, bubbles: true })) + await Promise.resolve() + const after = mesh()?.visible + + expect({ before, during, after }).toEqual({ before: false, during: true, after: false }) + }) }) /**********************************************************************************/ From 21c9d078dfb09c8686e77619f86d555b6ab44539 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 01:46:17 +0200 Subject: [PATCH 27/32] docs(site): derive the drag demo's state from hasPointerCapture Drop the demo's dragging signal: scale and color now read hasPointerCapture(mesh()) directly, with a signal ref so the child material binding re-subscribes once the mesh mounts. --- site/src/snippets/04-drag.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx index 7886f78d..1ea32f53 100644 --- a/site/src/snippets/04-drag.tsx +++ b/site/src/snippets/04-drag.tsx @@ -1,32 +1,34 @@ import * as THREE from "three" import { createSignal } from "solid-js" -import { Canvas, createT } from "solid-three" +import { Canvas, createT, hasPointerCapture } from "solid-three" const T = createT(THREE) export default () => { const [position, setPosition] = createSignal<[number, number, number]>([0, 0, 0]) - const [dragging, setDragging] = createSignal(false) + // A signal ref, so the visuals can ask `hasPointerCapture(mesh())` reactively. + const [mesh, setMesh] = createSignal() // Offset from the mesh origin to the grabbed point, so it doesn't jump on grab. let grabOffset = new THREE.Vector3() + // Drag state is derived from the capture itself — no signal to keep in sync. + const dragging = () => hasPointerCapture(mesh()) return ( { event.setPointerCapture() // grab — moves now follow this mesh grabOffset = new THREE.Vector3(...position()).sub(event.intersection.point) - setDragging(true) }} onPointerMove={event => { - if (!event.hasPointerCapture()) return // the capture itself is the drag state + if (!event.hasPointerCapture()) return // event.intersection.point tracks the drag plane, even off the mesh. const next = event.intersection.point.clone().add(grabOffset) setPosition([next.x, next.y, next.z]) }} - onPointerUp={() => setDragging(false)} // capture auto-releases on pointerup > From 11d306952209ef0fa0604daa14aa922364b0a1d9 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 02:05:22 +0200 Subject: [PATCH 28/32] refactor(events): hasPointerCapture demo/docs use a plain ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the renderer assigns an element's ref before mounting its children, a plain `let` ref read by a child binding resolves correctly — so the drag demo, the JSDoc example, and the integration test drop the signal ref. The test now mirrors the demo exactly: a child material binding keyed off the parent mesh's capture. --- site/src/snippets/04-drag.tsx | 7 +++---- src/pointer-capture.ts | 7 +++---- tests/core/canvas-events.test.tsx | 27 ++++++++++++--------------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx index 1ea32f53..8d6b7c38 100644 --- a/site/src/snippets/04-drag.tsx +++ b/site/src/snippets/04-drag.tsx @@ -6,17 +6,16 @@ const T = createT(THREE) export default () => { const [position, setPosition] = createSignal<[number, number, number]>([0, 0, 0]) - // A signal ref, so the visuals can ask `hasPointerCapture(mesh())` reactively. - const [mesh, setMesh] = createSignal() + let mesh: THREE.Mesh | undefined // Offset from the mesh origin to the grabbed point, so it doesn't jump on grab. let grabOffset = new THREE.Vector3() // Drag state is derived from the capture itself — no signal to keep in sync. - const dragging = () => hasPointerCapture(mesh()) + const dragging = () => hasPointerCapture(mesh) return ( { diff --git a/src/pointer-capture.ts b/src/pointer-capture.ts index 490f36b4..4e3fb831 100644 --- a/src/pointer-capture.ts +++ b/src/pointer-capture.ts @@ -41,10 +41,9 @@ export const captureRegistry: PointerCaptureRegistry = { * visuals without maintaining your own `dragging` signal: * * ```tsx - * // A signal ref, so the read re-subscribes once the object mounts. - * const [mesh, setMesh] = createSignal() - * event.setPointerCapture()} * /> * ``` diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 198c3120..56a63099 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -701,33 +701,30 @@ describe("pointer capture", () => { expect(onClick).toHaveBeenCalledTimes(1) // a tap, not a drag }) - it("hasPointerCapture(object) reactively drives a prop binding (the demo pattern)", async () => { - // A signal ref, so the binding re-subscribes once the mesh mounts — refs flow - // bottom-up, so a plain `let` ref read by a child/descendant would still be - // undefined when that binding first runs. - const [mesh, setMesh] = createSignal() + it("hasPointerCapture(object) reactively drives a child prop binding (the demo pattern)", async () => { + // A plain `let` ref read by a CHILD binding — the drag demo's exact shape (material + // color keyed off the mesh's capture). Works because the renderer assigns the ref + // before children mount; the predicate supplies the reactivity. + let mesh: THREE.Mesh | undefined + let material: THREE.MeshBasicMaterial | undefined const { canvas } = test(() => ( - e.setPointerCapture()} - > + e.setPointerCapture()}> - + )) vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) - await Promise.resolve() // let the ref land and the binding take its first read + await Promise.resolve() // let the refs land and the binding take its first read - const before = mesh()?.visible + const before = material?.wireframe fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures → true await Promise.resolve() - const during = mesh()?.visible + const during = material?.wireframe fireEvent(canvas, pointerAt("pointerup", HIT_X, HIT_Y)) // The browser auto-releases on pointerup, firing lostpointercapture → false. fireEvent(canvas, new PointerEvent("lostpointercapture", { pointerId: 1, bubbles: true })) await Promise.resolve() - const after = mesh()?.visible + const after = material?.wireframe expect({ before, during, after }).toEqual({ before: false, during: true, after: false }) }) From e099e8f5e596c5f4410edfe5ef12aa96cb7028a8 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 02:05:23 +0200 Subject: [PATCH 29/32] docs(events): document hasPointerCapture for reactive drag visuals Add the top-level hasPointerCapture(object) reactive predicate to the README capture section and the Events API overview: a context-free reactive read of an object's capture status, distinct from the event's imperative hasPointerCapture(). --- README.md | 15 +++++++++++++++ site/src/routes/api/events/overview.mdx | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 9437fd04..7ee6e46f 100644 --- a/README.md +++ b/README.md @@ -1225,6 +1225,21 @@ Inside a pointer handler, `setPointerCapture()` routes every later move — and The no-arg form captures `currentObject` and must be called synchronously in the handler (`currentObject` is cleared after dispatch). To start a capture later — after an `await` or a timer — pass the object: `setPointerCapture(mesh)`. Capture releases automatically on pointer up / cancel; call `releasePointerCapture()` to end it early. +For reactive visuals — scaling or recoloring a mesh while it's dragged — read the capture state declaratively with `hasPointerCapture(object)` instead of tracking your own `dragging` signal: + +```tsx +import { hasPointerCapture } from "solid-three" + +let mesh: THREE.Mesh | undefined + e.setPointerCapture()} +/> +``` + +`hasPointerCapture(object)` is a context-free reactive read — it re-runs only when that object's capture status flips, and a nullish `object` (an unmounted ref) reads `false`. It's distinct from the event's own `event.hasPointerCapture()`, which is the imperative, in-handler check. + ### Event Propagation solid-three implements a dual propagation system for events: diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 61aba7ef..d9d9dcb6 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -262,6 +262,23 @@ let grabbing = false See the [pointer events tour](/tour/04-pointer-events#dragging) for a runnable drag demo. +### Reacting to capture in your UI + +For visuals that should follow a drag — scaling or recoloring the grabbed object — read the capture state declaratively with the top-level `hasPointerCapture(object)` instead of tracking your own `grabbing` flag: + +```tsx +import { hasPointerCapture } from "solid-three" + +let mesh: THREE.Mesh | undefined + event.setPointerCapture()} +/> +``` + +`hasPointerCapture(object)` is a context-free reactive read — no `useThree`, callable anywhere — that re-runs only when that object's capture status flips. A nullish `object` (an unmounted ref) reads `false`. It's the reactive counterpart to the event's own `event.hasPointerCapture()`, which is the imperative, in-handler check. + ## Filtering with `raycastable` To keep an object from being hit while it still receives events that bubble up from its children, set `raycastable={false}`. See [`raycastable`](/api/events/raycastable) for the full prop. From 6d7bed5b1948ade95a6d14c2598646acc09b5b17 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 02:28:14 +0200 Subject: [PATCH 30/32] feat(events): drag-plane normal option + setPointerCapture({ object, normal }) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setPointerCapture now takes a single options object. `object` selects what to capture (default: the firing currentObject; pass it explicitly to capture async), and `normal` orients the drag plane through the grab point — to constrain a drag, e.g. { normal: new Vector3(0, 1, 0) } for ground-plane sliding. The default plane (hit surface, else camera-facing) is unchanged. Also rename the capture internals from `element` to `object`, so the 3D vocabulary (object / currentObject) is consistent throughout and `target` / `currentTarget` stay reserved for the DOM event. --- src/pointers.ts | 70 ++++++++++++++++++++----------------- src/types.ts | 31 +++++++++------- tests/core/pointer.test.tsx | 31 +++++++++++++--- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index 0f1756c4..e284f671 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -73,11 +73,11 @@ export interface PointerCaptureRegistry { /** A held pointer capture. */ interface Captured { /** - * The captured target — the node whose handler called `setPointerCapture` + * The captured object — the node whose handler called `setPointerCapture` * (`event.currentObject`). Captured move/up deliver exclusively to its chain; may be * an ancestor of the hit leaf. */ - element: Object3D + object: Object3D /** The drag plane the live ray reprojects onto each move. */ plane: Plane /** @@ -113,7 +113,7 @@ export class Pointer { /** Whether this pointer currently holds `object` captured. */ hasCaptured(object: Object3D): boolean { - return this.captured?.element === object + return this.captured?.object === object } /** Whether this pointer currently holds any capture. */ @@ -122,27 +122,33 @@ export class Pointer { } /** - * Capture this pointer to `element` (the node whose handler called - * `setPointerCapture`): build the drag plane from the hit point and world-space - * normal (camera-facing if the hit has no face), then engage the OS sink. - * Subsequent move/up reproject the live ray onto this plane and deliver - * exclusively to `element`'s chain until released. A nullish `element` (the + * Capture this pointer to `object` (the node whose handler called + * `setPointerCapture`): build the drag plane through the hit point and engage the + * OS sink. Subsequent move/up reproject the live ray onto this plane and deliver + * exclusively to `object`'s chain until released. A nullish `object` (the * canvas-level dispatch has no `event.currentObject`) is a no-op. * - * The face normal is expressed in the hit leaf's local space, so it's oriented - * with `intersection.object`'s world matrix — which differs from `element` when - * a handler on an ancestor captures. + * The plane normal is, in order: `normalOverride` (a caller-supplied world-space + * normal, to constrain the drag — e.g. `+Y` for ground sliding); else the hit + * face's normal (oriented by `intersection.object`'s world matrix, which differs + * from `object` when an ancestor captures); else camera-facing. */ - capture(element: Object3D | null | undefined, intersection: Intersection) { - if (!element) return + capture( + object: Object3D | null | undefined, + intersection: Intersection, + normalOverride?: Vector3, + ) { + if (!object) return const normal = new Vector3() - if (intersection.face) { + if (normalOverride) { + normal.copy(normalOverride).normalize() + } else if (intersection.face) { normal.copy(intersection.face.normal).transformDirection(intersection.object.matrixWorld) } else { this.context.camera.getWorldDirection(normal).negate() } const plane = new Plane().setFromNormalAndCoplanarPoint(normal, intersection.point) - this.captured = { element, plane, intersection } + this.captured = { object, plane, intersection } // Engage the OS sink only after state is set. If it throws — e.g. the pointer // isn't in an active-buttons state (a hover move with no button down) — roll // back so capture never outlives a failed sink, and swallow the platform error @@ -155,13 +161,13 @@ export class Pointer { } // Record only a capture that actually took, so the reactive mirror never // reports a rolled-back one. - this.captureRegistry?.add(element) + this.captureRegistry?.add(object) } /** Release a held capture and notify the OS sink. Idempotent. */ release() { if (!this.captured) return - this.captureRegistry?.delete(this.captured.element) + this.captureRegistry?.delete(this.captured.object) this.captured = null this.sink?.release() } @@ -169,7 +175,7 @@ export class Pointer { /** Clear capture state only, without notifying the sink (the OS already released). */ dropCapture() { if (!this.captured) return - this.captureRegistry?.delete(this.captured.element) + this.captureRegistry?.delete(this.captured.object) this.captured = null } @@ -189,28 +195,28 @@ export class Pointer { /** Attach the capture methods to a capturable event (down/up/move). */ private attachCapture(event: any) { - // No-arg captures `event.currentObject` (the firing node) — sync-only, since it's - // cleared after dispatch (like a DOM event's `currentTarget`). Pass a `target` - // to capture it later: the caller holds the reference, so it works async. - event.setPointerCapture = (target?: Object3D) => { - const element = target ?? event.currentObject - if (!element) return + // With no `object`, captures `event.currentObject` (the firing node) — sync-only, + // since it's cleared after dispatch (like a DOM event's `currentTarget`). Pass an + // `object` to capture it later: the caller holds the reference, so it works async. + event.setPointerCapture = (options?: { object?: Object3D; normal?: Vector3 }) => { + const object = options?.object ?? event.currentObject + if (!object) return // Sync: the live hit. Async (event already past dispatch, `currentIntersection` - // gone): synthesize a contact at the target's centre. - const intersection = event.currentIntersection ?? this.syntheticHit(element) - this.capture(element, intersection) + // gone): synthesize a contact at the object's centre. + const intersection = event.currentIntersection ?? this.syntheticHit(object) + this.capture(object, intersection, options?.normal) } event.releasePointerCapture = () => this.release() - event.hasPointerCapture = (target?: Object3D) => { - const element = target ?? event.currentObject - return element != null && this.hasCaptured(element) + event.hasPointerCapture = (object?: Object3D) => { + object ??= event.currentObject + return object != null && this.hasCaptured(object) } } /** * A contact for an explicit/deferred capture with no live ray hit: the object's * world-space centre, no face — so {@link capture} builds a camera-facing drag - * plane through it. Used by `setPointerCapture(target)` called after dispatch. + * plane through it. Used by `setPointerCapture({ object })` called after dispatch. */ private syntheticHit(object: Object3D): Intersection { return { object, point: object.getWorldPosition(new Vector3()), distance: 0 } as Intersection @@ -331,7 +337,7 @@ export class Pointer { const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - this.bubble(event, handler, [[intersection, captured.element]]) + this.bubble(event, handler, [[intersection, captured.object]]) return } diff --git a/src/types.ts b/src/types.ts index aff10960..55a85456 100644 --- a/src/types.ts +++ b/src/types.ts @@ -410,27 +410,32 @@ export type ThreeEvent< export type PointerCapture = { /** - * Capture this event's pointer to `target`, or — with no argument — to the node - * the handler is firing on (`event.currentObject`). Subsequent move/up for this pointer - * deliver exclusively to that object's chain (still bubbling to the canvas-level - * handler) until released — even off-ray and, for the DOM source, off-canvas. - * Off-ray, `event.intersection` is reprojected onto the captured plane so `point` - * keeps tracking. + * Capture this event's pointer to an object — by default the node the handler is + * firing on (`event.currentObject`). Subsequent move/up for this pointer deliver + * exclusively to that object's chain (still bubbling to the canvas-level handler) + * until released — even off-ray and, for the DOM source, off-canvas. Off-ray, + * `event.intersection` is reprojected onto the captured plane so `point` keeps tracking. * - * The no-arg form must be called synchronously in the handler (`event.currentObject` is - * cleared after dispatch, like a DOM event's `currentTarget`). Pass `target` to - * start a capture later (after an `await`/timer); with no live hit it drags on a - * camera-facing plane through the target's centre. + * Options: + * - `object` — capture this object instead of `event.currentObject`. Required to + * start a capture later (after an `await`/timer), since `currentObject` is cleared + * after dispatch (like a DOM event's `currentTarget`); with no live hit the drag + * plane is camera-facing through the object's centre. + * - `normal` — a world-space normal for the drag plane, through the grab point, + * instead of the default (the hit surface's normal, or camera-facing). Use it to + * constrain a drag, e.g. `{ normal: new Vector3(0, 1, 0) }` to slide on the ground. + * + * With no `object`, call it synchronously in the handler. */ - setPointerCapture(target?: Object3D): void + setPointerCapture(options?: { object?: Object3D; normal?: ThreeVector3 }): void /** * Release a capture started with `setPointerCapture`. Also released * automatically on pointerup/cancel for the DOM source, and on the paired end * event for XR. */ releasePointerCapture(): void - /** Whether `target` (default: this event's node) currently holds the pointer capture. */ - hasPointerCapture(target?: Object3D): boolean + /** Whether `object` (default: this event's `currentObject`) currently holds the pointer capture. */ + hasPointerCapture(object?: Object3D): boolean } type EventHandlersMap = { diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index b4ba6826..6c6e5429 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -254,6 +254,29 @@ describe("Pointer capture lifecycle", () => { expect(point?.y).toBeCloseTo(1) }) + it("uses an explicit `normal` for the drag plane instead of the hit face", () => { + // Override the plane normal to world +X (an x=0 plane). The hit face normal (+Z) + // alone would give a z=0 plane — parallel to the ray, so it'd fall back to the + // grab point (y === 0). Landing at y === 1 proves the override won. + let point: Vector3 | undefined + const mesh = eventful({ onPointerMove: (e: any) => (point = e.intersection.point) }) + const raycaster: PointerRaycaster = { + cast: () => [], + intersectObject: () => [], + aim: () => {}, + ray: new Ray(new Vector3(2, 1, 0), new Vector3(-1, 0, 0)), // toward -x, offset +1 in y + } + const pointer = new Pointer(ctx([mesh]), raycaster) + pointer.capture( + mesh, + { object: mesh, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any, + new Vector3(1, 0, 0), // explicit drag-plane normal → x=0 plane + ) + pointer.move(new Event("pointermove")) // captured → reproject onto the plane + + expect(point?.y).toBeCloseTo(1) + }) + it("does not fire onPointerLeave while captured; leave resumes after release", () => { const move = vi.fn() const leave = vi.fn() @@ -277,13 +300,13 @@ describe("Pointer capture lifecycle", () => { expect(leave).toHaveBeenCalledTimes(1) }) - it("setPointerCapture(target) captures after dispatch (async); the no-arg form doesn't", () => { - let captureWithTarget: (() => void) | undefined + it("setPointerCapture({ object }) captures after dispatch (async); the no-arg form doesn't", () => { + let captureWithObject: (() => void) | undefined let captureNoArg: (() => void) | undefined const mesh = eventful({ onPointerDown: (e: any) => { // Stash the calls instead of capturing now — run them after dispatch. - captureWithTarget = () => e.setPointerCapture(mesh) + captureWithObject = () => e.setPointerCapture({ object: mesh }) captureNoArg = () => e.setPointerCapture() }, }) @@ -302,7 +325,7 @@ describe("Pointer capture lifecycle", () => { captureNoArg!() // post-dispatch: event.currentObject is cleared → no-op expect(pointer.hasCaptured(mesh)).toBe(false) - captureWithTarget!() // explicit target survives → captures + captureWithObject!() // explicit object survives → captures expect(pointer.hasCaptured(mesh)).toBe(true) expect(sink.capture).toHaveBeenCalledTimes(1) }) From 8b04907bfe6d0135fc2fb4c66dfd82df4faaf092 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 02:28:14 +0200 Subject: [PATCH 31/32] docs(events): document the drag-plane normal option Update the README and Events API overview for setPointerCapture({ object, normal }): the options object and the normal that constrains the drag plane through the grab point. --- README.md | 6 ++++-- site/src/routes/api/events/overview.mdx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7ee6e46f..906c89cb 100644 --- a/README.md +++ b/README.md @@ -1198,7 +1198,7 @@ Every handler receives one event object, created once per dispatch and reused as | `currentIntersection` | `Intersection` | inside an object handler | The intersection for `currentObject`. Absent at the canvas level. | | `stopped` | `boolean` | stoppable events | Whether `stopPropagation()` has been called. | | `stopPropagation` | `() => void` | stoppable events | Stops both raycast and tree propagation. | -| `setPointerCapture` | `(target?: Object3D) => void` | pointer events | Capture the pointer to `target` (default: `currentObject`). See [Pointer Capture](#pointer-capture). | +| `setPointerCapture` | `(options?: { object?, normal? }) => void` | pointer events | Capture the pointer (default object: `currentObject`); `normal` sets the drag-plane orientation. See [Pointer Capture](#pointer-capture). | | `releasePointerCapture` | `() => void` | pointer events | End a capture early. | | `hasPointerCapture` | `(target?: Object3D) => boolean` | pointer events | Whether `target` currently holds the capture. | @@ -1223,7 +1223,9 @@ Inside a pointer handler, `setPointerCapture()` routes every later move — and ``` -The no-arg form captures `currentObject` and must be called synchronously in the handler (`currentObject` is cleared after dispatch). To start a capture later — after an `await` or a timer — pass the object: `setPointerCapture(mesh)`. Capture releases automatically on pointer up / cancel; call `releasePointerCapture()` to end it early. +The no-arg form captures `currentObject` and must be called synchronously in the handler (`currentObject` is cleared after dispatch). To start a capture later — after an `await` or a timer — name the object: `setPointerCapture({ object: mesh })`. Capture releases automatically on pointer up / cancel; call `releasePointerCapture()` to end it early. + +By default the drag plane is the hit surface (tangent), or camera-facing when there's no face — right for sliding along a surface. To constrain the drag instead, pass a world-space `normal` for the plane (built through the grab point): `setPointerCapture({ normal: new THREE.Vector3(0, 1, 0) })` slides on the ground (horizontal), regardless of where on the object you grabbed. For reactive visuals — scaling or recoloring a mesh while it's dragged — read the capture state declaratively with `hasPointerCapture(object)` instead of tracking your own `dragging` signal: diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index d9d9dcb6..48418871 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -94,7 +94,7 @@ This extra detail is what lets you go beyond "did they click it?". A few of the The event object is created once per dispatch and **reused** as it bubbles up the chain — the same object is passed to every handler, just like a DOM event. Two things follow from that: - **Treat it as read-only.** `intersection`, `intersections`, and the rest are shared scene data, not per-handler copies. Mutating them affects the other handlers in the same dispatch (and, while a pointer is captured, persists across moves). Read from the event; don't write to it. -- **`setPointerCapture()` captures `event.currentObject`** (the object currently firing), which is cleared after dispatch — so the no-arg form must be called synchronously in the handler. To start a capture later (after an `await`, a timer), pass the object explicitly: `setPointerCapture(mesh)`. (With no live hit, a deferred capture drags on a camera-facing plane through the object's center.) +- **`setPointerCapture()` captures `event.currentObject`** (the object currently firing), which is cleared after dispatch — so the no-arg form must be called synchronously in the handler. To start a capture later (after an `await`, a timer), name the object explicitly: `setPointerCapture({ object: mesh })`. (With no live hit, a deferred capture drags on a camera-facing plane through the object's center.) ## Reading the full hit stack @@ -229,7 +229,7 @@ Pointer capture fixes this. Inside an `onPointerDown`, `onPointerMove`, or `onPo | Method | Description | | --- | --- | -| `setPointerCapture(target?)` | Capture the pointer to `target`, or — with no argument — `event.currentObject` (the firing object; sync-only). | +| `setPointerCapture(options?)` | Capture the pointer to `options.object` (default: the firing `currentObject`; sync-only). Pass `options.normal` to orient the drag plane. | | `releasePointerCapture()` | Release a capture started with `setPointerCapture()`. | | `hasPointerCapture()` | Whether this event's node currently holds the capture. | @@ -239,6 +239,8 @@ Capture is **object-scoped** — the no-arg form captures the object whose handl While the pointer is off the ray, `event.intersection` is reprojected onto a plane through the original grab point, so `event.intersection.point` keeps tracking a sensible world-space position to drag toward. +The drag plane defaults to the hit surface (or camera-facing when there's no face). Pass an `options.normal` to constrain it — e.g. `{ normal: new Vector3(0, 1, 0) }` for ground-plane sliding — and it's built through the grab point so the object doesn't jump. + A capture releases when you call `releasePointerCapture()`, and automatically on `pointerup`/`pointercancel` (DOM source) or the paired end event (XR). It is also released if the captured object leaves the scene mid-drag, so a captured pointer never keeps dispatching to an unmounted node. ```tsx From fb4d22e0ccab9addb883796fb6ebf85bacb6c13d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 03:13:01 +0200 Subject: [PATCH 32/32] refactor(pointers): rename bubble() to propagate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper iterates the hit roots nearest-first (raycast propagation) and walks each one's ancestor chain (tree propagation), then fires canvas-level — it runs the whole propagation, not just the tree-phase bubble. "propagate" is the accurate umbrella term (and pairs with stopPropagation); "bubble" stays the verb for the tree phase in prose. --- src/pointers.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pointers.ts b/src/pointers.ts index e284f671..b5525648 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -253,7 +253,7 @@ export class Pointer { // drag by calling `setPointerCapture()` from here. const moveEvent: any = createThreeEvent(nativeEvent, { intersections }) this.attachCapture(moveEvent) - this.bubble( + this.propagate( moveEvent, "onPointerMove", intersections.map((intersection): [Intersection, Object3D] => [intersection, intersection.object]), @@ -289,7 +289,8 @@ export class Pointer { } /** - * Bubble `handler` up each root's parent chain — setting `event.currentIntersection` + * Propagate `handler` across the roots (nearest-first — raycast propagation) and up + * each root's parent chain (tree propagation) — setting `event.currentIntersection` * for the chain and `event.currentObject` for each node it fires on — honoring * `stopPropagation`, then fire the canvas-level handler if nothing stopped it. Each * `[intersection, root]` pairs the starting node (`root`) with the intersection to @@ -297,7 +298,7 @@ export class Pointer { * captured object, the normal path one pair per hit. A node shared by several hits * fires once (the closest hit's chain reaches it first), matching `move`/`click`. */ - private bubble(event: any, handler: string, roots: Array<[Intersection, Object3D]>) { + private propagate(event: any, handler: string, roots: Array<[Intersection, Object3D]>) { const visited = new Set() for (const [intersection, root] of roots) { event.currentIntersection = intersection @@ -318,9 +319,9 @@ export class Pointer { } /** - * Bubble a "default"-style gesture to an arbitrary handler name (plugin-extensible: + * Dispatch a "default"-style gesture to an arbitrary handler name (plugin-extensible: * the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source - * can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring + * can fire its own names, e.g. `onXRSelect`). Propagates along the hit chain honoring * `stopPropagation`, then fires canvas-level if unstopped. `extra` is merged onto the * event (plugin sources use it for rich fields, e.g. the XR controller payload), and * `event.currentObject` exposes the node a handler is firing on. When this pointer @@ -337,7 +338,7 @@ export class Pointer { const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - this.bubble(event, handler, [[intersection, captured.object]]) + this.propagate(event, handler, [[intersection, captured.object]]) return } @@ -345,7 +346,7 @@ export class Pointer { const event: any = createThreeEvent(nativeEvent, { intersections }) if (extra) Object.assign(event, extra) if (capturable) this.attachCapture(event) - this.bubble( + this.propagate( event, handler, intersections.map((intersection): [Intersection, Object3D] => [intersection, intersection.object]),