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 diff --git a/README.md b/README.md index 5d2889f6..906c89cb 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,83 @@ 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` | `(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. | -
-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 — 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: + +```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 @@ -1265,12 +1304,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 +1395,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/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/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 8e188fa1..48418871 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -38,7 +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. | +| `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). | @@ -87,6 +89,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()` 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 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: @@ -212,6 +221,66 @@ 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(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. | + +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 **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. + +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 +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. + +### 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. diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 5175148e..f0f05418 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,27 @@ 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. + +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 new file mode 100644 index 00000000..8d6b7c38 --- /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, hasPointerCapture } from "solid-three" + +const T = createT(THREE) + +export default () => { + const [position, setPosition] = createSignal<[number, number, number]>([0, 0, 0]) + 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) + + return ( + + { + event.setPointerCapture() // grab — moves now follow this mesh + grabOffset = new THREE.Vector3(...position()).sub(event.intersection.point) + }} + onPointerMove={event => { + 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]) + }} + > + + + + + + + ) +} diff --git a/src/create-events.ts b/src/create-events.ts index 57a46413..de910d45 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -1,4 +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" @@ -32,6 +34,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, captureRegistry) + // 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 +59,20 @@ export function createEvents(context: Context) { refCounts.delete(object) const index = context.eventRegistry.indexOf(object) if (index !== -1) context.eventRegistry.splice(index, 1) + // 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) } } } - // 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/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..4e3fb831 --- /dev/null +++ b/src/pointer-capture.ts @@ -0,0 +1,56 @@ +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 + * let mesh: Mesh | undefined + * 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 8edf7797..fceae036 100644 --- a/src/pointer-managers.ts +++ b/src/pointer-managers.ts @@ -1,5 +1,5 @@ -import { Vector2 } from "three" -import { Pointer } from "./pointers.ts" +import { Vector2, type Object3D } from "three" +import { Pointer, type PointerCaptureRegistry } from "./pointers.ts" import type { ScreenRaycaster } from "./raycasters.tsx" import type { Context } from "./types.ts" @@ -19,18 +19,46 @@ 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, private raycaster: ScreenRaycaster, + private captureRegistry?: PointerCaptureRegistry, ) { - this.primary = new Pointer(context, raycaster) + this.primary = new Pointer(context, raycaster, undefined, captureRegistry) + } + + /** + * 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) { - 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.captureRegistry, + ) this.pointers.set(id, pointer) } return pointer @@ -48,16 +76,27 @@ 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. if (event.pointerType === "touch") { this.pointers.get(event.pointerId)?.leave(event) this.pointers.delete(event.pointerId) @@ -67,17 +106,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) } @@ -85,12 +128,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 +151,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/src/pointers.ts b/src/pointers.ts index 1303ced3..b5525648 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`. */ @@ -30,6 +34,7 @@ export function createThreeEvent< if (intersections) { event.intersections = intersections event.intersection = intersections[0] + event.object = intersections[0]?.object } return event as Prettify< @@ -50,6 +55,38 @@ 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 { + /** + * 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. + */ + object: 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 * `*PointerManager` owns the source (canvas / XR controller) and the raycaster, @@ -65,14 +102,133 @@ export function createThreeEvent< export class Pointer { private hovered = new Set() private hoveredCanvas = false + private captured: Captured | null = null constructor( private context: Context, private raycaster: PointerRaycaster, + private sink?: PointerCaptureSink, + private captureRegistry?: PointerCaptureRegistry, ) {} + /** Whether this pointer currently holds `object` captured. */ + hasCaptured(object: Object3D): boolean { + return this.captured?.object === object + } + + /** Whether this pointer currently holds any capture. */ + get capturing(): boolean { + return this.captured != null + } + + /** + * 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 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( + object: Object3D | null | undefined, + intersection: Intersection, + normalOverride?: Vector3, + ) { + if (!object) return + const normal = new Vector3() + 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 = { 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 + // rather than surfacing it into the user's handler. + try { + 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(object) + } + + /** Release a held capture and notify the OS sink. Idempotent. */ + release() { + if (!this.captured) return + this.captureRegistry?.delete(this.captured.object) + 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.object) + 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 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) + 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) { + // 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 object's centre. + const intersection = event.currentIntersection ?? this.syntheticHit(object) + this.capture(object, intersection, options?.normal) + } + event.releasePointerCapture = () => this.release() + 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({ object })` 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. */ move(nativeEvent: Event) { + // 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 @@ -93,26 +249,15 @@ 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 }) - 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) { - ;(meta.props as any).onPointerMove?.(moveEvent) - if (moveEvent.stopped) break - } - current = current.parent - } - } - if (!moveEvent.stopped) { - delete moveEvent.currentIntersection - props.onPointerMove?.(moveEvent) - } + this.attachCapture(moveEvent) + this.propagate( + 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 }) @@ -134,38 +279,80 @@ 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) } /** - * 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. + * 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 + * expose while walking it: the captured path passes a single pair rooted at the + * 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`. */ - dispatch(handler: string, nativeEvent: Event) { - const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) - const event: any = createThreeEvent(nativeEvent, { intersections }) - for (const intersection of intersections) { + private propagate(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 = intersection.object - while (node && !event.stopped) { + let node: Object3D | null = root + while (node && !event.stopped && !visited.has(node)) { + visited.add(node) + event.currentObject = node ;(getMeta(node)?.props as any)?.[handler]?.(event) node = node.parent } + if (event.stopped) break } if (!event.stopped) { delete event.currentIntersection + event.currentObject = undefined ;(this.context.props as Record)[handler]?.(event) } } + /** + * 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`). 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 + * 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, 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) + this.propagate(event, handler, [[intersection, captured.object]]) + 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) + this.propagate( + event, + handler, + intersections.map((intersection): [Intersection, Object3D] => [intersection, intersection.object]), + ) + } + /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) { const missedType = `${kind}Missed` as const @@ -185,12 +372,14 @@ export class Pointer { while (node && !event.stopped && !visited.has(node)) { missed.delete(node) visited.add(node) + event.currentObject = node ;(getMeta(node)?.props as any)?.[kind]?.(event) node = node.parent } } if (!event.stopped) { delete event.currentIntersection + event.currentObject = undefined props[kind]?.(event) } 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/src/types.ts b/src/types.ts index d0c33876..55a85456 100644 --- a/src/types.ts +++ b/src/types.ts @@ -378,7 +378,16 @@ export type ThreeEvent< }, > = Intersect< [ - { nativeEvent: TEvent }, + { + nativeEvent: TEvent + /** + * 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 — 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. + */ + currentObject?: Object3D + }, When< TConfig["stoppable"], { @@ -392,11 +401,43 @@ 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 } >, ] > +export type PointerCapture = { + /** + * 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. + * + * 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(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 `object` (default: this event's `currentObject`) currently holds the pointer capture. */ + hasPointerCapture(object?: Object3D): boolean +} + type EventHandlersMap = { onClick: Prettify> onClickMissed: Prettify> @@ -404,9 +445,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/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 9d8d016d..56a63099 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -1,7 +1,8 @@ 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) @@ -567,3 +568,183 @@ 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() + }) + + 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)) + 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 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 + }) + + 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 + }) + + 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()}> + + + + )) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + await Promise.resolve() // let the refs land and the binding take its first read + + const before = material?.wireframe + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures → true + await Promise.resolve() + 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 = material?.wireframe + + expect({ before, during, after }).toEqual({ before: false, during: true, after: false }) + }) +}) + +/**********************************************************************************/ +/* */ +/* 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") + }) +}) 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) - }) - }) }) /**********************************************************************************/ diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index aa18e0db..6c6e5429 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, PerspectiveCamera, Ray, Vector3 } from "three" import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" import { meta } from "../../src/utils.ts" @@ -9,11 +9,28 @@ 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 { +// 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: RayState): 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(), } } @@ -65,6 +82,18 @@ describe("Pointer dispatch", () => { expect(click).toHaveBeenCalledTimes(1) }) + it("click sets event.currentObject to the bubbling node", () => { + const seen: any[] = [] + 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 })) + + 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() @@ -75,4 +104,340 @@ describe("Pointer dispatch", () => { expect(meshMissed).toHaveBeenCalledTimes(1) expect(canvasMissed).toHaveBeenCalledTimes(1) }) + + it("dispatch sets event.currentObject to the bubbling node and merges extra fields", () => { + const seen: any[] = [] + 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].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 + }) + + 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 + }) + + 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. +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, { 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) + + 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, { 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() + }) + + 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() + }) + + 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.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. + 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("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() + 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("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. + captureWithObject = () => e.setPointerCapture({ object: 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) + + captureWithObject!() // explicit object 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() + const captured = eventful({ + onPointerDown: (e: any) => e.setPointerCapture(), + onPointerUp: capturedUp, + }) + const other = eventful({ onPointerUp: otherUp }) + 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` + 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: 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")) + 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: 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) + + 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: 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")) + 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) + }) + + 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: 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")) + 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: 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")) + 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: 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")) + expect(pointer.hasCaptured(mesh)).toBe(true) + }) }) 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) + }) +})