From dad769e4668e0bbbfa473547d1c0b894cb955f38 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 8 Jun 2026 16:17:02 +0200 Subject: [PATCH 1/2] refactor(events)!: detect void via canvas event.object, drop *Missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A void — a gesture that hit no object — is read from the canvas-level handler: the canvas always receives the event (unless a handler stopped it), with `event.object` undefined when the ray hit nothing. Removes the `onClickMissed` / `onDoubleClickMissed` / `onContextMenuMissed` handlers. `click()` now delegates to the shared `propagate()` path that `onPointerDown` / `onPointerUp` / `onPointerMove` / `onWheel` already use, so every discrete gesture has one dispatch path and one void model. `PointerRaycaster.intersectObject`, used only by the dropped re-raycast phase, is removed from the type. Canvas-level handlers type `event.object` as `Object3D | undefined` — object-level handlers keep `Object3D`, since they only fire on a hit — so `if (!event.object)` type-checks as the empty-space test. Tests assert the canvas handler fires with `event.object` undefined on a miss and set to the hit object on a hit, across all seven gestures. --- src/pointers.ts | 64 +++------- src/types.ts | 18 ++- tests/core/canvas-events.test.tsx | 100 +++++---------- tests/core/events.test.tsx | 189 +++------------------------- tests/core/pointer.test.tsx | 23 ++-- tests/core/void-via-object.test.tsx | 57 +++++++++ 6 files changed, 150 insertions(+), 301 deletions(-) create mode 100644 tests/core/void-via-object.test.tsx diff --git a/src/pointers.ts b/src/pointers.ts index 7d51b1ba..74931678 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -25,14 +25,12 @@ export type DispatchEvent = { /** * The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against - * 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. + * a registry, 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 } @@ -379,52 +377,26 @@ export class Pointer { ) } - /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ + /** + * Bubbled `onClick`/`onDoubleClick`/`onContextMenu`, then the canvas-level + * handler — the same {@link propagate} path as `onPointerDown`/`onWheel`/etc. + * The canvas always hears the gesture (unless a handler stopped it); + * `event.object` is `undefined` when the ray hit nothing — the void. + */ click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) { - const missedType = `${kind}Missed` as const const registry = this.context.eventRegistry const props = this.context.props as Record - if (registry.length === 0 && !props[kind] && !props[missedType]) return + if (registry.length === 0 && !props[kind]) return - const missed = new Set(registry) - const visited = new Set() const intersections = this.raycaster.cast(registry, this.context) const event = createThreeEvent(nativeEvent, { intersections }) - - // Phase #1 — fire the handler, bubbling down the hit chain. - for (const intersection of intersections) { - event.currentIntersection = intersection - let node: Object3D | null = intersection.object - 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) - } - - // Phase #2 — re-raycast remaining objects to mark any genuinely under the ray as hit. - for (const remaining of missed) { - const hits = this.raycaster.intersectObject(remaining, true) - for (const { object } of hits) { - let node: Object3D | null = object - while (node && !visited.has(node)) { - missed.delete(node) - visited.add(node) - node = node.parent - } - } - } - - // Phase #3 — fire `-Missed` on the truly-missed objects, and canvas-level on a total miss. - const missedEvent = createThreeEvent(nativeEvent, { stoppable: false }) - for (const object of missed) (getMeta(object)?.props as any)?.[missedType]?.(missedEvent) - if (intersections.length === 0) props[missedType]?.(missedEvent) + this.propagate( + event, + kind, + intersections.map((intersection): [Intersection, Object3D] => [ + intersection, + intersection.object, + ]), + ) } } diff --git a/src/types.ts b/src/types.ts index 34347ff7..a9f2c29c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -439,11 +439,8 @@ export type PointerCapture = { type EventHandlersMap = { onClick: Prettify> - onClickMissed: Prettify> onDoubleClick: Prettify> - onDoubleClickMissed: Prettify> onContextMenu: Prettify> - onContextMenuMissed: Prettify> onPointerUp: Prettify & PointerCapture> onPointerDown: Prettify & PointerCapture> onPointerMove: Prettify & PointerCapture> @@ -456,10 +453,19 @@ export type EventHandlers = { [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void } +/** + * A canvas-level event: no per-object `currentIntersection`, and `object` is + * widened to `Object3D | undefined` — a canvas handler also fires when the ray + * hit nothing, so `if (!event.object)` is the empty-space check. (Object-level + * handlers keep `object: Object3D`, since they only fire on a hit.) + */ +type CanvasEvent = Prettify< + Omit & + (TEvent extends { object: Object3D } ? { object: Object3D | undefined } : unknown) +> + export type CanvasEventHandlers = { - [TKey in keyof EventHandlersMap]: ( - event: Prettify>, - ) => void + [TKey in keyof EventHandlersMap]: (event: CanvasEvent) => void } /** The names of all `EventHandlers` */ diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 30f070f8..dcb8f5a3 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -106,52 +106,38 @@ describe("canvas missable events", () => { }) // - // onClickMissed + // onClick — void via event.object // - describe("onClickMissed", () => { - it("fires when click misses all registered meshes", () => { - const handleClickMissed = vi.fn() + describe("onClick — void via event.object", () => { + it("fires with event.object undefined when the click misses all meshes", () => { + let object: unknown = "unset" const { canvas } = test(() => , { - onClickMissed: handleClickMissed, + onClick: (event: any) => (object = event.object), }) fireEvent(canvas, missEvent("click")) - expect(handleClickMissed).toHaveBeenCalledTimes(1) + expect(object).toBeUndefined() }) - it("fires when canvas is clicked with no meshes in the scene", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => null, { onClickMissed: handleClickMissed }) + it("fires with event.object undefined when no meshes are in the scene", () => { + let object: unknown = "unset" + const { canvas } = test(() => null, { onClick: (event: any) => (object = event.object) }) fireEvent(canvas, hitEvent("click")) - expect(handleClickMissed).toHaveBeenCalledTimes(1) + expect(object).toBeUndefined() }) - it("does not fire when click hits a registered mesh", () => { - const handleClickMissed = vi.fn() + it("fires with event.object set to the hit mesh when the click hits one", () => { + let object: any = "unset" const { canvas } = test(() => , { - onClickMissed: handleClickMissed, + onClick: (event: any) => (object = event.object), }) fireEvent(canvas, hitEvent("click")) - expect(handleClickMissed).not.toHaveBeenCalled() - }) - - it("does not fire when onClick is also registered and click hits a mesh", () => { - const handleClick = vi.fn() - const handleClickMissed = vi.fn() - const { canvas } = test(() => , { - onClick: handleClick, - onClickMissed: handleClickMissed, - }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClick).toHaveBeenCalledTimes(1) - expect(handleClickMissed).not.toHaveBeenCalled() + expect(object?.isMesh).toBe(true) }) }) @@ -203,38 +189,29 @@ describe("canvas missable events", () => { }) // - // onDoubleClickMissed + // onDoubleClick — void via event.object // - describe("onDoubleClickMissed", () => { - it("fires when double-click misses all registered meshes", () => { - const handleMissed = vi.fn() + describe("onDoubleClick — void via event.object", () => { + it("fires with event.object undefined when the double-click misses all meshes", () => { + let object: unknown = "unset" const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, + onDoubleClick: (event: any) => (object = event.object), }) fireEvent(canvas, missEvent("dblclick")) - expect(handleMissed).toHaveBeenCalledTimes(1) + expect(object).toBeUndefined() }) - it("fires when canvas is double-clicked with no meshes in the scene", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => null, { onDoubleClickMissed: handleMissed }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when double-click hits a registered mesh", () => { - const handleMissed = vi.fn() + it("fires with event.object set to the hit mesh when the double-click hits one", () => { + let object: any = "unset" const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, + onDoubleClick: (event: any) => (object = event.object), }) fireEvent(canvas, hitEvent("dblclick")) - expect(handleMissed).not.toHaveBeenCalled() + expect(object?.isMesh).toBe(true) }) }) @@ -286,38 +263,29 @@ describe("canvas missable events", () => { }) // - // onContextMenuMissed + // onContextMenu — void via event.object // - describe("onContextMenuMissed", () => { - it("fires when contextmenu misses all registered meshes", () => { - const handleMissed = vi.fn() + describe("onContextMenu — void via event.object", () => { + it("fires with event.object undefined when the contextmenu misses all meshes", () => { + let object: unknown = "unset" const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, + onContextMenu: (event: any) => (object = event.object), }) fireEvent(canvas, missEvent("contextmenu")) - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("fires when canvas receives contextmenu with no meshes in the scene", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => null, { onContextMenuMissed: handleMissed }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleMissed).toHaveBeenCalledTimes(1) + expect(object).toBeUndefined() }) - it("does not fire when contextmenu hits a registered mesh", () => { - const handleMissed = vi.fn() + it("fires with event.object set to the hit mesh when the contextmenu hits one", () => { + let object: any = "unset" const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, + onContextMenu: (event: any) => (object = event.object), }) fireEvent(canvas, hitEvent("contextmenu")) - expect(handleMissed).not.toHaveBeenCalled() + expect(object?.isMesh).toBe(true) }) }) }) diff --git a/tests/core/events.test.tsx b/tests/core/events.test.tsx index 310cad68..296b2cba 100644 --- a/tests/core/events.test.tsx +++ b/tests/core/events.test.tsx @@ -26,119 +26,6 @@ describe("events", () => { expect(handlePointerDown).toHaveBeenCalled() }) - // TODO: implement onPointerMissed-api - // NOTE: unsure if/how we should implement onPointerMissed - // the heuristics are unclear imo - - // it("can handle onPointerMissed", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 0 }); - // Object.defineProperty(evt, "offsetY", { get: () => 0 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).not.toHaveBeenCalled(); - // expect(handleMissed).toHaveBeenCalledWith(evt); - // }); - - // TODO: implement onPointerMissed-api - - // it("should not fire onPointerMissed when same element is clicked", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const down = new Event("pointerdown"); - // Object.defineProperty(down, "offsetX", { get: () => 577 }); - // Object.defineProperty(down, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, down); - - // const up = new Event("pointerup"); - // Object.defineProperty(up, "offsetX", { get: () => 577 }); - // Object.defineProperty(up, "offsetY", { get: () => 480 }); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 577 }); - // Object.defineProperty(evt, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).toHaveBeenCalled(); - // expect(handleMissed).not.toHaveBeenCalled(); - // }); - - // TODO: implement onPointerMissed-api - - // it("should not fire onPointerMissed on parent when child element is clicked", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // - // - // )); - - // const down = new Event("pointerdown"); - // Object.defineProperty(down, "offsetX", { get: () => 577 }); - // Object.defineProperty(down, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, down); - - // const up = new Event("pointerup"); - // Object.defineProperty(up, "offsetX", { get: () => 577 }); - // Object.defineProperty(up, "offsetY", { get: () => 480 }); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 577 }); - // Object.defineProperty(evt, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).toHaveBeenCalled(); - // expect(handleMissed).not.toHaveBeenCalled(); - // }); - - // TODO: implement onPointerMissed-api - - // it("can handle onPointerMissed on Canvas", async () => { - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 0 }); - // Object.defineProperty(evt, "offsetY", { get: () => 0 }); - - // fireEvent(canvas, evt); - // expect(handleMissed).toHaveBeenCalledWith(evt); - // }); - it("can handle onPointerMove", async () => { const handlePointerMove = vi.fn() const handlePointerEnter = vi.fn() @@ -227,7 +114,7 @@ describe("events", () => { /**********************************************************************************/ /* */ -/* Mesh-level onClickMissed */ +/* Shared click helpers */ /* */ /**********************************************************************************/ @@ -241,77 +128,41 @@ function makeClickAt(clientX: number, clientY: number) { return new MouseEvent("click", { clientX, clientY, bubbles: true }) } -describe("mesh onClickMissed", () => { - it("fires when a click misses the mesh", async () => { - const handleClickMissed = vi.fn() - - const { canvas } = await test(() => ( - - - - - )) - - fireEvent(canvas, makeClickAt(MISS_X, MISS_Y)) - - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when the mesh itself is clicked", async () => { - const handleClickMissed = vi.fn() - - const { canvas } = await test(() => ( - - - - - )) - - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) - - expect(handleClickMissed).not.toHaveBeenCalled() - }) - - it("does not fire when a different mesh in the scene is clicked", async () => { - const handleClickMissed = vi.fn() +describe("void via event.object", () => { + it("fires canvas onClick with event.object undefined when the click misses every mesh", async () => { + let object: unknown = "unset" - // Mesh A: off-center (far right), has onClickMissed - // Mesh B: at origin (center of screen), gets clicked - const { canvas } = await test(() => ( - <> - - - - + const { canvas } = await test( + () => ( - - )) + ), + { onClick: (event: any) => (object = event.object) }, + ) - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + fireEvent(canvas, makeClickAt(MISS_X, MISS_Y)) - expect(handleClickMissed).not.toHaveBeenCalled() + expect(object).toBeUndefined() }) - it("does not fire on a parent when its child is clicked", async () => { - const handleParentClickMissed = vi.fn() - const handleChildClick = vi.fn() + it("fires canvas onClick with event.object set to the hit mesh", async () => { + let object: any = "unset" - const { canvas } = await test(() => ( - - + const { canvas } = await test( + () => ( + {}}> - - )) + ), + { onClick: (event: any) => (object = event.object) }, + ) fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) - expect(handleChildClick).toHaveBeenCalledTimes(1) - expect(handleParentClickMissed).not.toHaveBeenCalled() + expect(object?.isMesh).toBe(true) }) }) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 53964d35..fa7c2c40 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -13,8 +13,7 @@ function ctx(eventRegistry: Object3D[], props: Record = {}) { 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). +// capture() can build a plane); `aim` is a no-op (tests position `ray` directly). function fakeRaycaster(state: RayState): PointerRaycaster { return { cast: () => @@ -28,7 +27,6 @@ function fakeRaycaster(state: RayState): PointerRaycaster { } as any, ] : [], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } @@ -94,15 +92,16 @@ describe("Pointer dispatch", () => { 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() - const mesh = eventful({ onClickMissed: meshMissed }) - const pointer = new Pointer(ctx([mesh], { onClickMissed: canvasMissed }), fakeRaycaster({})) + it("fires canvas onClick with event.object undefined when the click hits nothing", () => { + const seen: any[] = [] + const mesh = eventful({ onClick: vi.fn() }) + const pointer = new Pointer( + ctx([mesh], { onClick: (e: any) => seen.push(e.object) }), + fakeRaycaster({}), + ) pointer.click("onClick", new MouseEvent("click")) - expect(meshMissed).toHaveBeenCalledTimes(1) - expect(canvasMissed).toHaveBeenCalledTimes(1) + expect(seen).toEqual([undefined]) }) it("dispatch sets event.currentObject to the bubbling node and merges extra fields", () => { @@ -147,7 +146,6 @@ describe("Pointer dispatch", () => { face: { normal: new Vector3(0, 0, 1) }, }, ], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } as any as PointerRaycaster @@ -175,7 +173,6 @@ describe("Pointer dispatch", () => { }, { object: back, distance: 2, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, ], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } as any as PointerRaycaster @@ -264,7 +261,6 @@ describe("Pointer capture lifecycle", () => { 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 } @@ -289,7 +285,6 @@ describe("Pointer capture lifecycle", () => { 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 } diff --git a/tests/core/void-via-object.test.tsx b/tests/core/void-via-object.test.tsx new file mode 100644 index 00000000..306ca900 --- /dev/null +++ b/tests/core/void-via-object.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent } from "@solidjs/testing-library" +import * as THREE from "three" +import { describe, expect, it } from "vitest" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" + +const T = createT(THREE) + +// A 2×2 box at origin; the test camera makes (640, 400) hit it and (0, 0) miss. +const Box = (props: Record) => ( + + + + +) + +// Every canvas-level gesture flows through the same dispatch path, so the void +// model — "the canvas always hears it; event.object is undefined on a miss" — +// should hold identically for all of them. +const GESTURES = [ + { prop: "onClick", type: "click", Ctor: MouseEvent }, + { prop: "onDoubleClick", type: "dblclick", Ctor: MouseEvent }, + { prop: "onContextMenu", type: "contextmenu", Ctor: MouseEvent }, + { prop: "onPointerDown", type: "pointerdown", Ctor: PointerEvent }, + { prop: "onPointerUp", type: "pointerup", Ctor: PointerEvent }, + { prop: "onPointerMove", type: "pointermove", Ctor: PointerEvent }, + { prop: "onWheel", type: "wheel", Ctor: WheelEvent }, +] as const + +const makeEvent = (Ctor: any, type: string, x: number, y: number) => + new Ctor(type, { clientX: x, clientY: y, bubbles: true, pointerId: 1 }) + +describe("void via event.object", () => { + for (const { prop, type, Ctor } of GESTURES) { + it(`${prop} fires on the canvas with event.object undefined on a void`, () => { + let object: unknown = "unset" + const { canvas } = test(() => {}} />, { + [prop]: (event: any) => (object = event.object), + }) + + fireEvent(canvas, makeEvent(Ctor, type, 0, 0)) + + expect(object).toBeUndefined() + }) + + it(`${prop} fires on the canvas with event.object set to the mesh on a hit`, () => { + let object: any = "unset" + const { canvas } = test(() => {}} />, { + [prop]: (event: any) => (object = event.object), + }) + + fireEvent(canvas, makeEvent(Ctor, type, 640, 400)) + + expect(object?.isMesh).toBe(true) + }) + } +}) From bf2ac3519068d3899af376cc56723b8a7a9f4e08 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 8 Jun 2026 16:17:09 +0200 Subject: [PATCH 2/2] docs(events): document the canvas event.object empty-space pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the *Missed material with "Clicking empty space": a canvas-level handler receives every gesture, and `event.object` is undefined when the ray hit nothing — read `if (!event.object)`. Updates the tour chapter, the events reference, and the Canvas props; the glossary's "Missed event" entry becomes "Void". Rename the demo snippet to 04-void-click.tsx and switch it to `onClick` with an `event.object` check. --- CONTEXT.md | 9 +-- site/src/routes/api/components/canvas.mdx | 6 +- site/src/routes/api/events/overview.mdx | 64 ++++++------------- site/src/routes/tour/04-pointer-events.mdx | 14 ++-- ...{04-click-missed.tsx => 04-void-click.tsx} | 7 +- 5 files changed, 43 insertions(+), 57 deletions(-) rename site/src/snippets/{04-click-missed.tsx => 04-void-click.tsx} (78%) diff --git a/CONTEXT.md b/CONTEXT.md index c34e038c..79f546e9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -85,7 +85,7 @@ A `Context` method that runs a plugin's one-time, per-`Context` setup once (e.g. ### Events **`event.object`**: -The closest hit — `event.intersections[0].object`. Stable for the whole dispatch; the 3D analogue of a DOM event's `target`. +The closest hit — `event.intersections[0]?.object`, or `undefined` on a **void**. Stable for the whole dispatch; the 3D analogue of a DOM event's `target`. **`event.currentObject`**: The **object** whose handler is firing as the event bubbles the hit chain; cleared after dispatch. The 3D analogue of `currentTarget`. @@ -94,8 +94,9 @@ _Avoid_: target / currentTarget for the 3D sense — those stay DOM-only, on `na **Intersection** (intersections): A raycast hit — three.js `Intersection` (`object`, `point`, `distance`, `face`, `uv`, `normal`). `event.intersections` is nearest-first; `event.intersection` is the nearest. -**Missed event**: -`onClickMissed` / `onDoubleClickMissed` / `onContextMenuMissed` — fires on a registered **object** when the interaction did _not_ hit it or its descendants. +**Void**: +A gesture that hit no **object** — the ray missed everything. There's no dedicated handler: every gesture's canvas-level handler fires (unless `stopPropagation` halted it), with `event.object` `undefined` on a void and the hit **object** otherwise. The 3D counterpart to a DOM click landing on the page background. Read it as `if (!event.object)`. +_Avoid_: missed / `*Missed` (the removed per-object inversion). **raycast propagation**: The first dispatch phase — the handler fires on each hit **object** nearest-first along the ray. @@ -161,7 +162,7 @@ WebXR (VR/AR) session management (`createXR` / `useXR`). _In flux_: being extern - An **Object** joins its parent's scene graph (`.add()`); a non-object **Element** binds via **attach** - A **Canvas** owns one **Context**, which owns one **renderer** - A **Plugin** matches **elements** via its **selector** and contributes **plugin props**; a plugin prop **overrides** the native prop of the same name -- A dispatch runs **raycast propagation** then **tree propagation**; `stopPropagation()` halts both. `event.object` is `event.intersections[0].object` +- A dispatch runs **raycast propagation** then **tree propagation**, then the canvas-level handler; `stopPropagation()` halts all of it. `event.object` is `event.intersections[0]?.object` — `undefined` on a **void** - A **pointer source** drives one or more **Pointers**; each **Pointer** raycasts the **eventRegistry** with a **raycaster**, then dispatches via **raycast propagation** and **tree propagation** - The active **camera** and **raycaster** are stack-based: setting one (via `Canvas` props or `useThree`) pushes an override that pops on cleanup, restoring the previous diff --git a/site/src/routes/api/components/canvas.mdx b/site/src/routes/api/components/canvas.mdx index 0ab6d46d..537f60d4 100644 --- a/site/src/routes/api/components/canvas.mdx +++ b/site/src/routes/api/components/canvas.mdx @@ -23,7 +23,7 @@ title: Canvas | `style` | `JSX.CSSProperties` | — | CSS for the canvas container. | | `class` | `string` | — | CSS class for the canvas container. | | `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts; [`createXR`](/api/hooks/create-xr) uses this. | -| event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onClickMissed`). | +| event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onPointerDown`); check `event.object` to tell a hit from empty space. |
Exact type @@ -65,7 +65,9 @@ interface CanvasProps extends ParentProps> { camera={{ position: [0, 0, 5], fov: 75 }} shadows="soft" gl={{ antialias: true }} - onClickMissed={() => console.log("Clicked empty space")} + onClick={event => { + if (!event.object) console.log("Clicked empty space") + }} > {/* Your 3D scene */} diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 4f217e2d..10afa3e9 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -6,17 +6,17 @@ title: Events overview `solid-three` has its own pointer-event system, inspired by [`react-three-fiber`](https://github.com/pmndrs/react-three-fiber). Events are dispatched by raycasting against the scene, then propagate twice: along the ray (through every object the ray hit) and up the scene graph (from the hit object to its ancestors). -You attach handlers as props on any [``](/api/components/t) or [``](/api/components/entity) component. The same handler names also work on [``](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Missed events](#missed-events)). +You attach handlers as props on any [``](/api/components/t) or [``](/api/components/entity) component. The same handler names also work on [``](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Clicking empty space](#clicking-empty-space)). ## Supported events -### Click events (missable) +### Click events -These fire on the object that was hit, or — when nothing handles them — as a paired `*Missed` event: +These fire on the object that was hit, then on the [``](/api/components/canvas) — where `event.object` is `undefined` if the click hit nothing (see [Clicking empty space](#clicking-empty-space)): -- `onClick` / `onClickMissed` -- `onContextMenu` / `onContextMenuMissed` — right-click -- `onDoubleClick` / `onDoubleClickMissed` +- `onClick` +- `onContextMenu` — right-click +- `onDoubleClick` ### Hover events @@ -38,7 +38,7 @@ 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`. | +| `object` | `Object3D \| undefined` | events that raycast | The closest hit object (`intersections[0]?.object`), or `undefined` when the ray hit nothing — 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. | @@ -50,7 +50,6 @@ Not every handler receives every field — what you get depends on the event: | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` | | `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped | -| `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast |
Exact type @@ -154,46 +153,19 @@ Not every event accepts `stopPropagation()`. The split mirrors the DOM: **Non-stoppable** — these always fire for every registered handler, regardless of order: -- `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` — a [missed event](#missed-events) is by definition the "nothing else handled it" signal. - `onPointerEnter` — enter has to reach the newly-hovered subtree. - `onPointerLeave` — leave has to reach the previously-hovered subtree. -## Missed events +## Clicking empty space -A `*Missed` variant fires on a handler-bearing object when the click, double-click, or context-menu did **not** reach it. Two cases: - -1. **Clicked outside** — the ray missed the object and all its descendants. -2. **Blocked by `stopPropagation()`** — another object handled the event first and stopped it. - -```tsx - console.log("Missed — clicked outside this mesh")}> - - - -``` +Every gesture handler also works on the [``](/api/components/canvas) itself, and there `event.object` is the object the ray hit — or `undefined` when it hit nothing. One canvas handler covers both: read `event.object` to tell a real hit from a click on empty space. There's no separate "missed" handler. ```tsx - console.log("Group missed — child stopped propagation")}> - { - event.stopPropagation() - console.log("Child clicked") - }} - > - - - - -``` - -Common uses: deselecting on a background click, blocking interaction with objects behind a UI layer, or telling a parent container that a child intercepted the event. - -### Missed events on `` - -The same handlers work on [``](/api/components/canvas), where they fire when **no object handler at all** consumed the ray: - -```tsx - deselect()}> + { + if (!event.object) deselect() + }} +> select()}> @@ -201,7 +173,11 @@ The same handlers work on [``](/api/components/canvas), where they fire ``` -Use this for canvas-wide "click on empty space" logic without attaching a sentinel mesh. +The mesh's `onClick` fires as the click bubbles; the canvas's runs last and deselects only when `event.object` is empty — the canvas-wide "click on empty space" pattern, with no sentinel mesh. + +Every discrete gesture behaves this way: `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`, and the click family all reach the canvas with `event.object` either set or `undefined`. `onPointerEnter` and `onPointerLeave` are the exception — they track the canvas boundary, not a per-event hit, so there's no hit-or-empty distinction to read there. + +A handler that calls `stopPropagation()` ends the dispatch before it reaches the canvas, so a consumed event arrives as neither a hit nor an empty click. ## Hover events @@ -289,4 +265,4 @@ To keep an object from being hit while it still receives events that bubble up f - [Pointer events](/tour/04-pointer-events) — the tutorial chapter, with a worked walk-through. - [`raycastable`](/api/events/raycastable) — opt an object out of hit-testing while keeping bubbled events. -- [``](/api/components/canvas) — where canvas-level handlers and `onClickMissed` fire. +- [``](/api/components/canvas) — where canvas-level handlers fire, with `event.object` empty on a void. diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 0e5b8737..fc839e9d 100644 --- a/site/src/routes/tour/04-pointer-events.mdx +++ b/site/src/routes/tour/04-pointer-events.mdx @@ -6,8 +6,8 @@ import clickSnippet from "../../snippets/04-click.tsx?raw" import clickUrl from "../../snippets/04-click.tsx?importChunkUrl" import hoverSnippet from "../../snippets/04-hover.tsx?raw" import hoverUrl from "../../snippets/04-hover.tsx?importChunkUrl" -import clickMissedSnippet from "../../snippets/04-click-missed.tsx?raw" -import clickMissedUrl from "../../snippets/04-click-missed.tsx?importChunkUrl" +import voidClickSnippet from "../../snippets/04-void-click.tsx?raw" +import voidClickUrl from "../../snippets/04-void-click.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" @@ -45,13 +45,15 @@ flips; `scale` reads it; one property assignment runs. Sometimes the interesting event is when the user clicks but _doesn't_ hit any mesh — to deselect the current selection, dismiss a tooltip, that sort of -thing. [``](/api/components/canvas) exposes `onClickMissed` for exactly that case: +thing. Put an `onClick` on the [``](/api/components/canvas) itself: it +hears every click once the scene has had its turn, and `event.object` is the +mesh that was hit — or `undefined` when the click landed on empty space. - + Click the cube to select it; click anywhere else in the canvas to deselect. -The two handlers cover the full "did you hit something?" question between -them. +The cube's own `onClick` selects; the canvas `onClick` checks `event.object` +and deselects only when it's empty. That covers the everyday cases. There's a [longer list of pointer events](/api/events/overview) diff --git a/site/src/snippets/04-click-missed.tsx b/site/src/snippets/04-void-click.tsx similarity index 78% rename from site/src/snippets/04-click-missed.tsx rename to site/src/snippets/04-void-click.tsx index 731bcd25..d3bb8bc2 100644 --- a/site/src/snippets/04-click-missed.tsx +++ b/site/src/snippets/04-void-click.tsx @@ -8,7 +8,12 @@ export default () => { const [selected, setSelected] = createSignal(false) return ( - setSelected(false)}> + { + if (!event.object) setSelected(false) + }} + > setSelected(true)}>