diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 8f07ec47..d41f3f81 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -40,5 +40,13 @@ jobs: - name: Test run: pnpm test --run + # The packaging guard, and the only suite that can see a defect which exists solely + # in the BUILT bundles — e.g. a duplicated copy of core, which makes pointer events + # silently never fire for consumers while every other suite (all of which import + # `src/`, one module graph) stays green. `test:published` rebuilds first, so it can + # never run against a stale `dist/`. + - name: Test published package + run: pnpm test:published + - name: Publish preview run: pnpx pkg-pr-new publish --compact --template ./demo diff --git a/CONTEXT.md b/CONTEXT.md index c34e038c..f1f01b10 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,11 +44,12 @@ _Avoid_: sub-property, hyphen notation ### Runtime **Canvas**: -The root component. Sets up the **renderer**, scene, camera, and raycaster, and provides the **Context** to descendants. +The root component. Sets up the **renderer**, scene, and camera, and provides the **Context** to descendants. **Context**: -The per-**Canvas** runtime state — the **renderer**, `scene`, `camera`, `raycaster`, `clock`, `eventRegistry`, `viewport`, `bounds`, and the render loop. Returned by `useThree`. +The per-**Canvas** runtime state — the **renderer**, `scene`, `camera`, `clock`, `viewport`, `bounds`, `initializePlugin`, `addFrameListener`, and the render loop. Returned by `useThree`. _Avoid_: confusing it with the Solid context (`threeContext`) that distributes it — they're distinct. +_Note_: neither the **eventRegistry** nor the **raycaster** is here. Both belong to an **event engine**, one per Context — core holds no event state and does no picking. **Renderer**: The three.js renderer instance (`WebGLRenderer` or `WebGPURenderer`). Named `gl` in code — the `Context.gl` field and the `Canvas` `gl` prop (which also accepts renderer params or a factory). An R3F inheritance, and a misnomer once `WebGPURenderer` is in play. @@ -94,14 +95,14 @@ _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. +**`onPointerMissed`**: +The miss handler. On an **object**, the per-object "not-me" — fires when a click lands somewhere other than that object or its descendants (a different object, or empty space). On the **Canvas**, narrower — fires only on a total miss (empty space), the void/deselect signal. Non-stoppable; the missed set is the registry minus the hit-closure (hits + their ancestors), so `stopPropagation` never widens it. **raycast propagation**: The first dispatch phase — the handler fires on each hit **object** nearest-first along the ray. **tree propagation**: -The second dispatch phase — after an object's handler runs, the event _bubbles_ up its ancestors, finally to the **Canvas**. `stopPropagation()` halts both phases. +The second dispatch phase — after an object's handler runs, the event _bubbles_ up its ancestors. `stopPropagation()` halts both phases. ### Pointer system & raycasting @@ -114,11 +115,14 @@ A **pointer source**'s implementation — owns the source's listeners and routes **Pointer**: The per-pointer state machine (one per `pointerId`, plus a primary) that raycasts the **eventRegistry** and dispatches/bubbles to handlers, tracking its own hover and capture state. +**event engine**: +A plugin that owns a pointer paradigm end to end — its own **eventRegistry**, **raycaster**, dispatch and propagation rules. Core ships none; `pointerEvents()` (`solid-three/events`) is the reference one. Engines are peers: each owns its own registry, so two engines on one **Canvas** partition by which namespace minted the **object**. Handler props are contributed by the engine, so without one installed, `onClick` is not a prop at all. + **eventRegistry**: -The set of **objects** carrying event handlers; what the pointer system raycasts. +The set of **objects** carrying event handlers; what an **event engine** raycasts. Belongs to the engine, one per **Context** — not to core. **raycaster**: -Aims a ray and casts it against the **eventRegistry**. Variants differ by how they aim — a **screen raycaster** from a cursor in **NDC** (`CursorRaycaster` = mouse, `CenterRaycaster` = gaze), or a `ControllerRaycaster` from an XR controller's transform. +Aims a ray and casts it against the **eventRegistry**. Variants differ by how they aim — a **screen raycaster** from a cursor in **NDC** (`CursorRaycaster` = mouse, `CenterRaycaster` = gaze), or a `ControllerRaycaster` from an XR controller's transform. Belongs to the **event engine**, not to core — nothing in core casts a ray. It is stack-based, like the **camera**: the engine's own raycaster (configured at install with `pointerEvents({ raycaster })` — an instance, or a config object applied to the engine's `CursorRaycaster`) sits at the bottom, and `useRaycaster().setRaycaster(next)` pushes an override that pops on cleanup. The engine resolves the top of the stack at cast time, so a push genuinely changes what gets hit. **NDC**: Normalized device coordinates — the `[-1, 1]` cursor space a **screen raycaster** aims from. @@ -163,7 +167,8 @@ WebXR (VR/AR) session management (`createXR` / `useXR`). _In flux_: being extern - 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 **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 +- The **raycaster** belongs to the **event engine**, not to core, and is stack-based: `useRaycaster()` returns `{ raycaster, setRaycaster }`, `raycaster()` is the object that actually picks (so mutating it always changes what gets picked), and `setRaycaster(next)` pushes an override that pops on cleanup +- The active **camera** is stack-based too: setting one (via the `Canvas` prop or `useThree().setCamera`) pushes an override that pops on cleanup, restoring the previous ## Example dialogue diff --git a/README.md b/README.md index 906c89cb..a09abfb9 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ The `Canvas` component initializes the `three.js` rendering context and acts as - **fallback**: Element to render while the main content is loading asynchronously. - **gl**: A flat object mixing `WebGLRenderer` constructor params (e.g. `antialias`, `alpha`, `powerPreference`) and instance-writable props (e.g. `toneMapping`) — solid-three splits the two internally. Instance props stay reactive (set them after construction, update them anytime). **Constructor params are applied once.** They're passed to `new WebGLRenderer({ ... })` at the first construction and never re-read. Updating one later does **not** rebuild the renderer or recreate the WebGL context, because `canvas.getContext("webgl2")` is idempotent: the browser returns the same context object on every subsequent call, with the original creation flags. solid-three logs a warning when it detects a reactive change to one of these keys. If you genuinely need to change them at runtime, unmount and remount the `` (e.g. gate it behind a `` keyed on the value you want to swap) — that gives you a fresh `` element and therefore a fresh context. Also accepts a factory returning any renderer (`WebGLRenderer`, `WebGPURenderer`, `SVGRenderer`, `CSS2D/3DRenderer`, custom), or a pre-built renderer instance. See [Custom renderers](#custom-renderers) for narrowing the accepted renderer type project-wide. - **scene**: Provides custom settings for the Scene instance or an existing Scene. -- **raycaster**: Configures the Raycaster for mouse and pointer events. - **shadows**: Enables and configures shadows in the scene with various shadow mapping techniques. - **orthographic**: Toggles between Orthographic and Perspective camera for the default camera. - **linear**: Toggles linear interpolation for texture filtering. @@ -133,7 +132,6 @@ interface CanvasProps { | ((canvas: HTMLCanvasElement) => ResolvedRenderer) | ResolvedRenderer scene?: Partial | Scene - raycaster?: Partial | Raycaster shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"] orthographic?: boolean linear?: boolean @@ -155,7 +153,6 @@ interface CanvasProps { fallback={
Loading...
} gl={{ antialias: true, alpha: true }} scene={{ fog: new Fog(0xffffff, 1, 100) }} - raycaster={{ params: { Line: { threshold: 0.1 } } }} shadows="soft" orthographic={false} linear={false} @@ -488,21 +485,21 @@ const camera = useThree(ctx => ctx.camera) - **clock** (`Clock`): The `three.js` clock for timing. - **dpr** (`number`): Device pixel ratio reported by the active renderer (falls back to `1` for renderers without `getPixelRatio`, e.g. `CSS3DRenderer` / `SVGRenderer`). - **gl** (`Renderer`): The active renderer — `WebGLRenderer | WebGPURenderer | RendererLike` by default. Narrow to a concrete type project-wide via [Register augmentation](#narrowing-the-renderer-type-project-wide). -- **raycaster** (`Raycaster`): The current raycaster used for pointer events. -- **setRaycaster** (`(raycaster: Raycaster) => () => void`): A setter-function for setting the current raycaster. - **render** (`(timestamp: number, frame?: XRFrame) => void`): Drives a single frame — runs the registered `useFrame` callbacks, then renders the scene. The optional `frame` is forwarded to those callbacks; an active WebXR session passes it on each immersive frame. - **requestRender** (`() => void`): Function to request a render on the next frame. - **scene** (`Scene`): The root scene. -**Camera and Raycaster Stack System:** +There is no raycaster here. Core does no picking, so it owns no raycaster: that belongs to the pointer-events engine, which hands it out through [`useRaycaster()`](#raycasters). -`solid-three` implements a stack-based system for managing its current camera and raycaster: +**Camera Stack System:** -- **Stack-based Management**: Both cameras and raycasters are managed as stacks internally -- **Default at Tail**: The `camera` and `raycaster` from Canvas props form the tail of their respective stacks -- **Current Active Camera at Head**: The camera/raycaster at the top of the stack is the currently active camera/raycaster -- **Push To The Stack To Become Active**: By calling `setCamera(camera)` and `setRaycaster(raycaster)`, the camera/raycaster is pushed to the stack. This causes it to become the currently active camera/raycaster -- **Pop From The Stack To Deactivate**: `setCamera(camera)` and `setRaycaster(raycaster)` return a cleanup-function to pop the camera/raycaster from the stack. If the camera/raycaster was on top of the stack, the previous camera/raycaster in the stack becomes active again +`solid-three` implements a stack-based system for managing its current camera: + +- **Stack-based Management**: Cameras are managed as a stack internally +- **Default at Tail**: The `camera` from Canvas props forms the tail of the stack +- **Current Active Camera at Head**: The camera at the top of the stack is the currently active camera +- **Push To The Stack To Become Active**: By calling `setCamera(camera)`, the camera is pushed to the stack. This causes it to become the currently active camera +- **Pop From The Stack To Deactivate**: `setCamera(camera)` returns a cleanup-function to pop the camera from the stack. If the camera was on top of the stack, the previous camera in the stack becomes active again **Usage:** @@ -916,100 +913,91 @@ export function OrbitControls(props: S3.Props) { ### Raycasters -`solid-three` provides custom raycaster implementations that handle pointer tracking internally. All raycasters extend THREE.Raycaster and implement the `EventRaycaster` interface. +The raycaster belongs to the pointer-events engine, not to core: core does no picking, so it owns no raycaster. There is exactly one per canvas — the one the engine casts with — and `solid-three/events` ships the strategies it can cast with. They all extend `THREE.Raycaster` and implement `EventRaycaster`: -The interface adds one method to the standard THREE.Raycaster: +- **cast(registry, context)**: aim the ray for the current pointer, then return its hits against the registry and its descendants, nearest-first. +- **aim(context)**: position the ray without intersecting anything — the aiming half of `cast`. -- **update**: Called automatically before intersection testing to update the raycaster's position based on the event +Screen-pointer raycasters also implement `ScreenRaycaster`, which adds **setCursor(ndc)**: the engine calls it with the cursor in normalized device coordinates before each `cast()`.
Typescript Interface ```tsx interface EventRaycaster extends THREE.Raycaster { - update(event: PointerEvent | MouseEvent | WheelEvent, context: Context): void + cast(registry: Object3D[], context: Context): Intersection[] + aim(context: Context): void +} + +interface ScreenRaycaster extends EventRaycaster { + setCursor(ndc: Vector2): void } ```
-**When `update()` is called:** - -The `update()` method is automatically called by `solid-three`'s event system whenever: - -- **Mouse events**: `click`, `mousedown`, `mouseup`, `mousemove`, `contextmenu`, `dblclick` -- **Pointer events**: `pointerdown`, `pointerup`, `pointermove` -- **Wheel events**: `wheel` - -This happens before intersection testing, ensuring the raycaster is properly positioned for accurate 3D object detection. - -#### CursorRaycaster +#### Configuring the raycaster -The default raycaster that tracks the cursor position: +`pointerEvents({ raycaster })` takes either a config object of raycaster properties — applied to the engine's own `CursorRaycaster` — or a raycaster instance, used as the whole ray strategy: ```tsx import { Canvas } from "solid-three" -import { CursorRaycaster } from "solid-three" +import { CenterRaycaster, pointerEvents } from "solid-three/events" -const App = () => { - const raycaster = new CursorRaycaster() +// Config: pick nothing further than 10 units away. +;{/* Your scene */} - // CursorRaycaster is used by default, but you can explicitly set it: - return {/* Your scene */} -} +// Instance: cast from the screen centre instead of the cursor. +;{/* Your scene */} ``` -#### CenterRaycaster +The option is read once, when the engine installs. -A raycaster that always casts from the center of the screen: +#### useRaycaster -```tsx -import { Canvas } from "solid-three" -import { CenterRaycaster } from "solid-three" +`useRaycaster()` returns the raycaster this canvas picks with — the engine's own, and the only one there is. Mutate it and the next event picks differently. It throws when no engine is installed, rather than handing back a raycaster that nothing casts with. -const App = () => { - const raycaster = new CenterRaycaster() +```tsx +import { useRaycaster } from "solid-three/events" - return >{/* Your scene */} +const Reach = (props: { far: number }) => { + const raycaster = useRaycaster() + createEffect(() => (raycaster.far = props.far)) + return null } ``` -#### Creating Your Own Raycaster +#### CursorRaycaster -You can create custom raycasters by extending THREE.Raycaster and (optionally) implementing the `EventRaycaster` interface: +The default the engine falls back to when none is configured. Tracks the cursor and casts from the active camera. -```tsx -import { Raycaster, Vector2 } from "three" -import type { EventRaycaster, Context } from "solid-three" +#### CenterRaycaster -class CustomRaycaster extends Raycaster implements EventRaycaster { - constructor() { - super() - // Initialize your custom raycaster - } +Ignores the cursor and always casts from the center of the screen — useful for gaze or crosshair interaction. - update(event: PointerEvent | MouseEvent | WheelEvent, context: Context) { - const pointer = new Vector2() +#### Creating Your Own Raycaster - // Calculate normalized device coordinates based on your custom logic +For a screen pointer, the simplest path is to subclass `CursorRaycaster` and reshape the cursor — `cast()` is inherited: - // Example: Apply custom transformation to pointer coordinates - pointer.x = ((event.offsetX / context.bounds.width) * 2 - 1) * 0.5 // Scale down horizontal movement - pointer.y = (-(event.offsetY / context.bounds.height) * 2 + 1) * 0.5 // Scale down vertical movement +```tsx +import { Vector2 } from "three" +import { Canvas } from "solid-three" +import { CursorRaycaster, pointerEvents } from "solid-three/events" - // Update the raycaster with the transformed coordinates - this.setFromCamera(pointer, context.camera) +// Damp pointer movement to half speed. +class DampedRaycaster extends CursorRaycaster { + setCursor(ndc: Vector2) { + super.setCursor(new Vector2(ndc.x * 0.5, ndc.y * 0.5)) } } -// Usage -const App = () => { - const raycaster = new CustomRaycaster() - - return {/* Your scene */} -} +const App = () => ( + {/* Your scene */} +) ``` +For a non-screen ray — a custom origin and direction — implement `cast()` directly: aim `this.ray` from whatever transform you like, then return `this.intersectObjects(registry, true)`. `ControllerRaycaster` is the reference implementation. + ### LoaderCache `LoaderCache` is the default cache-manager for `useLoader`. It implements the `LoaderRegistry` interface and adds additional methods to simplify managing the cached resources. diff --git a/consumer/across-subpaths.tsx b/consumer/across-subpaths.tsx new file mode 100644 index 00000000..4507c287 --- /dev/null +++ b/consumer/across-subpaths.tsx @@ -0,0 +1,73 @@ +/** + * Does the PUBLISHED type surface actually work across subpath exports? + * + * Everything else in this repo typechecks `solid-three` against `src/`. That can never + * catch a defect in what we ship, because `src/` has exactly one copy of every type by + * construction. This file is the one place that reads the package the way a dependent + * does: `solid-three` and `solid-three/events` resolve through the `exports` map in + * `package.json`, onto the emitted declarations in `types/` — no path alias, no `src/`. + * + * What it guards: the two subpaths must agree on the SAME types. They did not when each + * subpath was rolled up into its own declaration bundle, because each bundle then carried + * a private `declare const $S3C: unique symbol` — and `unique symbol` identity is + * per-declaration. `solid-three/events`' `Context`/`Meta`/`Plugin` and `solid-three`'s + * identically-spelled ones were unrelated types, so `createT(THREE, [pointerEvents()])` + * did not typecheck at all and every prop the engine contributes went missing. + * + * Each assertion below fails loudly under that defect. Keep them: they are the only + * regression test for the shape of the published `.d.ts` files. + */ +import { createSignal } from "solid-js" +import { Canvas, createT, plugin } from "solid-three" +import { pointerEvents, type EventHandlers } from "solid-three/events" +import * as THREE from "three" + +/** + * A plugin defined the way a consumer would, against the core `plugin()` export. It is + * here to compose with `pointerEvents()`, because the brand mismatch did not just break + * the engine's own props — it corrupted its co-plugins'. With a mismatched + * `pointerEvents()` in the same list, `lookAt` below stopped resolving to this + * `(target: Vector3) => void` and collapsed back onto `Object3D`'s native overloaded + * `lookAt` method. So a plugin list of two is the honest test, not one of one. + */ +const lookAt = plugin([THREE.Object3D], (object: THREE.Object3D) => ({ + lookAt: (target: THREE.Vector3) => object.lookAt(target), +})) + +/** The load-bearing line: a plugin from one subpath, into `createT` from another. */ +const T = createT(THREE, [lookAt, pointerEvents()]) + +/** + * Annotated against the engine's own exported handler types, so this asserts more than + * "the prop exists": it asserts the prop carries the engine's event type. An `any` — the + * shape a prop degrades to when inference gives up — would not be caught by an inline + * arrow alone. + */ +const handleClick: EventHandlers["onClick"] = event => event.stopPropagation() +const handlePointerMissed: EventHandlers["onPointerMissed"] = event => event.nativeEvent.button + +export function Scene() { + const [target] = createSignal(new THREE.Vector3()) + + return ( + // `plugins` + `onPointerMissed`: the engine contributes the canvas-level prop, so + // this asserts the engine is assignable to `` too — a separate + // path through the types from the `createT` one above. + + {/* Both plugins' contributed props on one element: the engine's `onClick` and the + co-plugin's `lookAt`, which must still take a `Vector3`. */} + + + + + + {/* An inline arrow, left unannotated on purpose: `event` has to INFER. Under the + defect the prop was unknown, so this reported an implicit `any` (TS7006) on top + of the missing-prop error. `event.intersection` also pins the payload's shape. */} + event.intersection.point.clone()}> + + + + + ) +} diff --git a/consumer/tsconfig.json b/consumer/tsconfig.json new file mode 100644 index 00000000..469c88b3 --- /dev/null +++ b/consumer/tsconfig.json @@ -0,0 +1,21 @@ +{ + // A consumer's tsconfig, not the library's. It deliberately does NOT extend the root + // config: no `paths`, and — most importantly — no `allowImportingTsExtensions`, which + // the library sets for its own source but no installed consumer ever would. So the + // only way `solid-three` and `solid-three/events` resolve here is the way they resolve + // for a real dependent: through this package's `exports` map, onto the BUILT + // declarations in `types/`. Run `pnpm build` first; `pnpm lint:types` runs this. + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["."] +} diff --git a/package.json b/package.json index 75ab4057..6cf264fe 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "module": "./dist/index.js", "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "types": "./types/index.d.ts", "sideEffects": false, "license": "MIT", "files": [ @@ -22,19 +22,21 @@ "README.md" ], "scripts": { - "build": "tsup", + "build": "tsup && pnpm types", "build:site": "pnpm -C site build", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf types", "dev:site": "pnpm -C site dev", - "format": "prettier --write 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}' 'demo/**/*.{ts,tsx}' 'site/src/**/*.{ts,tsx,mdx}' '*.{ts,js,mjs,cjs}'", + "format": "prettier --write 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}' 'consumer/**/*.{ts,tsx}' 'demo/**/*.{ts,tsx}' 'site/src/**/*.{ts,tsx,mdx}' '*.{ts,js,mjs,cjs}'", "lint": "pnpm lint:circular && pnpm lint:code && pnpm lint:format && pnpm lint:types", "lint:circular": "dpdm -T src", "lint:code": "eslint --max-warnings 0 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}' 'site/src/**/*.{ts,tsx}'", - "lint:format": "prettier --check 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}' 'demo/**/*.{ts,tsx}' 'site/src/**/*.{ts,tsx,mdx}' '*.{ts,js,mjs,cjs}'", - "lint:types": "tsc --noEmit", - "prepublishOnly": "pnpm lint && pnpm build", + "lint:format": "prettier --check 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}' 'consumer/**/*.{ts,tsx}' 'demo/**/*.{ts,tsx}' 'site/src/**/*.{ts,tsx,mdx}' '*.{ts,js,mjs,cjs}'", + "lint:types": "tsc --noEmit && pnpm lint:types:consumer", + "lint:types:consumer": "tsc -p consumer/tsconfig.json", + "prepublishOnly": "pnpm build && pnpm lint && pnpm test:published", "test": "vitest run", - "types": "tsc --emitDeclarationOnly --declarationDir types" + "test:published": "pnpm build && vitest run --config vitest.published.config.ts", + "types": "rm -rf types && tsc -p tsconfig.build.json" }, "exports": { ".": { @@ -44,15 +46,31 @@ }, "development": { "import": { - "types": "./dist/index.d.ts", + "types": "./types/index.d.ts", "default": "./dist/index.dev.js" } }, "import": { - "types": "./dist/index.d.ts", + "types": "./types/index.d.ts", "default": "./dist/index.js" } }, + "./events": { + "solid": { + "development": "./dist/events.dev.solid.jsx", + "import": "./dist/events.dev.js" + }, + "development": { + "import": { + "types": "./types/events/index.d.ts", + "default": "./dist/events.dev.js" + } + }, + "import": { + "types": "./types/events/index.d.ts", + "default": "./dist/events.js" + } + }, "./testing": { "solid": { "development": "./dist/testing.dev.solid.jsx", @@ -60,20 +78,23 @@ }, "development": { "import": { - "types": "./dist/testing.d.ts", + "types": "./types/testing/index.d.ts", "default": "./dist/testing.dev.js" } }, "import": { - "types": "./dist/testing/index.d.ts", + "types": "./types/testing/index.d.ts", "default": "./dist/testing.js" } } }, "typesVersions": { "*": { + "events": [ + "./types/events/index.d.ts" + ], "testing": [ - "./dist/testing/index.d.ts" + "./types/testing/index.d.ts" ] } }, @@ -86,8 +107,10 @@ }, "devDependencies": { "@bigmistqke/repl": "link:../repl", + "@pmndrs/pointer-events": "6.6.30", "@solidjs/router": "^0.15.3", "@solidjs/testing-library": "^0.8.8", + "@types/node": "^26.1.1", "@types/three": "^0.181.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", @@ -106,6 +129,7 @@ "rollup": "^4.18.0", "rollup-plugin-dts": "^6.1.1", "solid-js": "^1.8.17", + "solid-three": "link:.", "three": "^0.181.2", "three-stdlib": "^2.36.0", "tsup": "^8.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e16044a9..efb1a063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,32 +30,38 @@ importers: version: 2.2.0 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.2) + version: 5.1.4(typescript@5.9.3)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) devDependencies: '@bigmistqke/repl': specifier: link:../repl version: link:../repl + '@pmndrs/pointer-events': + specifier: 6.6.30 + version: 6.6.30 '@solidjs/router': specifier: ^0.16.1 version: 0.16.1(solid-js@1.9.13) '@solidjs/testing-library': specifier: ^0.8.8 version: 0.8.10(@solidjs/router@0.16.1(solid-js@1.9.13))(solid-js@1.9.13) + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 '@types/three': specifier: ^0.181.0 version: 0.181.0 '@typescript-eslint/eslint-plugin': specifier: ^8.38.0 - version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.38.0 - version: 8.60.0(eslint@9.39.4)(typescript@5.9.3) + version: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@vitest/browser': specifier: ^4.1.7 - version: 4.1.7(vite@6.4.2)(vitest@4.1.7) + version: 4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) '@vitest/browser-playwright': specifier: ^4.1.7 - version: 4.1.7(playwright@1.60.0)(vite@6.4.2)(vitest@4.1.7) + version: 4.1.7(playwright@1.60.0)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) dpdm: specifier: ^3.14.0 version: 3.15.1 @@ -67,13 +73,13 @@ importers: version: 0.6.0(esbuild@0.21.5)(solid-js@1.9.13) eslint: specifier: ^9.32.0 - version: 9.39.4 + version: 9.39.4(jiti@2.7.0) eslint-plugin-eslint-comments: specifier: ^3.2.0 - version: 3.2.0(eslint@9.39.4) + version: 3.2.0(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + version: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-no-only-tests: specifier: ^3.3.0 version: 3.4.0 @@ -95,6 +101,9 @@ importers: solid-js: specifier: ^1.8.17 version: 1.9.13 + solid-three: + specifier: link:. + version: 'link:' three: specifier: ^0.181.2 version: 0.181.2 @@ -103,25 +112,25 @@ importers: version: 2.36.1(three@0.181.2) tsup: specifier: ^8.0.2 - version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) tsup-preset-solid: specifier: ^2.2.0 - version: 2.2.0(esbuild@0.21.5)(solid-js@1.9.13)(tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3)) + version: 2.2.0(esbuild@0.21.5)(solid-js@1.9.13)(tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)) typescript: specifier: ^5.4.5 version: 5.9.3 vite: specifier: 6.4.2 - version: 6.4.2 + version: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-plugin-inspect: specifier: 0.8.4 - version: 0.8.4(rollup@4.60.4)(vite@6.4.2) + version: 0.8.4(rollup@4.60.4)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vite-plugin-solid: specifier: 2.11.12 - version: 2.11.12(solid-js@1.9.13)(vite@6.4.2) + version: 2.11.12(solid-js@1.9.13)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vitest: specifier: ^4.1.7 - version: 4.1.7(@vitest/browser-playwright@4.1.7)(vite@6.4.2) + version: 4.1.7(@types/node@26.1.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) site: dependencies: @@ -130,16 +139,16 @@ importers: version: 0.2.8(@babel/standalone@7.29.7)(dom-serializer@2.0.0)(domutils@3.2.2)(htmlparser2@10.1.0)(monaco-editor@0.52.2)(solid-js@1.9.13)(typescript@5.9.3) '@kobalte/solidbase': specifier: github:bigmistqke/solidbase#f48198d - version: https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c(@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(solid-js@1.9.13)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c(@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(solid-js@1.9.13)(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@solidjs/router': specifier: ^0.16.1 version: 0.16.1(solid-js@1.9.13) '@solidjs/start': specifier: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152 - version: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@solidjs/vite-plugin-nitro-2': specifier: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152 - version: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) cannon-es: specifier: ^0.20.0 version: 0.20.0 @@ -163,14 +172,14 @@ importers: version: 0.1.1(@babel/core@7.29.7)(@types/react@19.2.15)(solid-js@1.9.13) vite: specifier: 8.0.10 - version: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + version: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) devDependencies: '@iconify-json/ri': specifier: ^1.2.6 version: 1.2.10 '@lightningjs/vite-plugin-import-chunk-url': specifier: ^0.3.1 - version: 0.3.1(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 0.3.1(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@types/three': specifier: ^0.181.0 version: 0.181.0 @@ -1140,7 +1149,7 @@ packages: solid-js: ^1.8.15 '@kobalte/solidbase@https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c': - resolution: {gitHosted: true, tarball: https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c} + resolution: {gitHosted: true, integrity: sha512-mmnbrf6JGnXVvLHeUqAxp4jRkI7w8zVxXbYUr+HqnzU6vVgSs0M1uyWQYcNPTqiX9Qpm6tAnRBRSHkWvputJQw==, tarball: https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c} version: 0.6.3-dev engines: {node: '>=22'} peerDependencies: @@ -1290,6 +1299,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pmndrs/pointer-events@6.6.30': + resolution: {integrity: sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g==} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1887,8 +1899,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} @@ -5064,8 +5076,8 @@ packages: unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -6147,9 +6159,9 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -6330,7 +6342,7 @@ snapshots: solid-presence: 0.1.8(solid-js@1.9.13) solid-prevent-scroll: 0.1.10(solid-js@1.9.13) - '@kobalte/solidbase@https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c(@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(solid-js@1.9.13)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@kobalte/solidbase@https://codeload.github.com/bigmistqke/solidbase/tar.gz/f48198dd6b4ebfdbeb88429290d04bdfe6f9397c(@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(solid-js@1.9.13)(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@alloc/quick-lru': 5.2.0 '@bprogress/core': 1.3.4 @@ -6354,7 +6366,7 @@ snapshots: '@solid-primitives/storage': 4.3.4(solid-js@1.9.13) '@solidjs/meta': 0.29.4(solid-js@1.9.13) '@solidjs/router': 0.16.1(solid-js@1.9.13) - '@solidjs/start': https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@solidjs/start': https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) cross-spawn: 7.0.6 diff: 8.0.4 esast-util-from-js: 2.0.1 @@ -6391,7 +6403,7 @@ snapshots: unist-util-visit: 5.1.0 unplugin-auto-import: 19.3.0 unplugin-icons: 22.5.0 - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) yaml: 2.9.0 transitivePeerDependencies: - '@nuxt/kit' @@ -6417,9 +6429,9 @@ snapshots: '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) solid-js: 1.9.13 - '@lightningjs/vite-plugin-import-chunk-url@0.3.1(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@lightningjs/vite-plugin-import-chunk-url@0.3.1(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) '@lume/element@0.13.1(@babel/core@7.29.7)(@types/react@19.2.15)': dependencies: @@ -6563,6 +6575,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pmndrs/pointer-events@6.6.30': {} + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -6957,7 +6971,7 @@ snapshots: dependencies: solid-js: 1.9.13 - '@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/traverse': 7.29.7 @@ -6984,8 +6998,8 @@ snapshots: source-map-js: 1.2.1 srvx: 0.11.16 terracotta: 1.1.0(solid-js@1.9.13) - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(solid-js@1.9.13)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(solid-js@1.9.13)(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@testing-library/jest-dom' - crossws @@ -6998,10 +7012,10 @@ snapshots: optionalDependencies: '@solidjs/router': 0.16.1(solid-js@1.9.13) - '@solidjs/vite-plugin-nitro-2@https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/vite-plugin-nitro-2@https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: nitropack: 2.13.4 - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7131,10 +7145,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.9.1': + '@types/node@26.1.1': dependencies: - undici-types: 7.24.6 - optional: true + undici-types: 8.3.0 '@types/offscreencanvas@2019.7.3': {} @@ -7164,15 +7177,15 @@ snapshots: '@types/webxr@0.5.24': {} - '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.60.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.60.0 - '@typescript-eslint/type-utils': 8.60.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.60.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.0 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -7180,14 +7193,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.60.0 '@typescript-eslint/types': 8.60.0 '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.0 debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7210,13 +7223,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.60.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.60.0 '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.60.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -7239,13 +7252,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.60.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.60.0 '@typescript-eslint/types': 8.60.0 '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7283,29 +7296,29 @@ snapshots: - rollup - supports-color - '@vitest/browser-playwright@4.1.7(playwright@1.60.0)(vite@6.4.2)(vitest@4.1.7)': + '@vitest/browser-playwright@4.1.7(playwright@1.60.0)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7)': dependencies: - '@vitest/browser': 4.1.7(vite@6.4.2)(vitest@4.1.7) - '@vitest/mocker': 4.1.7(vite@6.4.2) + '@vitest/browser': 4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) + '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) playwright: 1.60.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@vitest/browser-playwright@4.1.7)(vite@6.4.2) + vitest: 4.1.7(@types/node@26.1.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.7(vite@6.4.2)(vitest@4.1.7)': + '@vitest/browser@4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.7(vite@6.4.2) + '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils': 4.1.7 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.7(@vitest/browser-playwright@4.1.7)(vite@6.4.2) + vitest: 4.1.7(@types/node@26.1.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -7322,13 +7335,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@6.4.2)': + '@vitest/mocker@4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2 + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -8218,23 +8231,23 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.60.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-eslint-comments@3.2.0(eslint@9.39.4): + eslint-plugin-eslint-comments@3.2.0(eslint@9.39.4(jiti@2.7.0)): dependencies: escape-string-regexp: 1.0.5 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) ignore: 5.3.2 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8243,9 +8256,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8257,7 +8270,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -8276,9 +8289,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -8312,6 +8325,8 @@ snapshots: minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -10394,11 +10409,13 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(postcss@8.5.15): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.7.0 postcss: 8.5.15 + yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.15): dependencies: @@ -11264,16 +11281,16 @@ snapshots: tslib@2.8.1: {} - tsup-preset-solid@2.2.0(esbuild@0.21.5)(solid-js@1.9.13)(tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3)): + tsup-preset-solid@2.2.0(esbuild@0.21.5)(solid-js@1.9.13)(tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)): dependencies: esbuild-plugin-solid: 0.5.0(esbuild@0.21.5)(solid-js@1.9.13) - tsup: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + tsup: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) transitivePeerDependencies: - esbuild - solid-js - supports-color - tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -11284,7 +11301,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.60.4 source-map: 0.7.6 @@ -11374,8 +11391,7 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 - undici-types@7.24.6: - optional: true + undici-types@8.3.0: {} unenv@2.0.0-rc.24: dependencies: @@ -11631,7 +11647,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-inspect@0.8.4(rollup@4.60.4)(vite@6.4.2): + vite-plugin-inspect@0.8.4(rollup@4.60.4)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.3.0(rollup@4.60.4) @@ -11642,12 +11658,12 @@ snapshots: perfect-debounce: 1.0.0 picocolors: 1.1.1 sirv: 2.0.4 - vite: 6.4.2 + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - rollup - supports-color - vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@6.4.2): + vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 @@ -11655,12 +11671,12 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 6.4.2 - vitefu: 1.1.3(vite@6.4.2) + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 @@ -11668,23 +11684,23 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.2): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.2 + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@6.4.2: + vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -11693,9 +11709,14 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.16 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 - vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -11703,25 +11724,25 @@ snapshots: rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 yaml: 2.9.0 - vitefu@1.1.3(vite@6.4.2): + vitefu@1.1.3(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): optionalDependencies: - vite: 6.4.2 + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vitefu@1.1.3(vite@8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): optionalDependencies: - vite: 8.0.10(@types/node@25.9.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.0.10(@types/node@26.1.1)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vitest@4.1.7(@vitest/browser-playwright@4.1.7)(vite@6.4.2): + vitest@4.1.7(@types/node@26.1.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@6.4.2) + '@vitest/mocker': 4.1.7(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -11738,10 +11759,11 @@ snapshots: tinyexec: 1.2.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 6.4.2 + vite: 6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@vitest/browser-playwright': 4.1.7(playwright@1.60.0)(vite@6.4.2)(vitest@4.1.7) + '@types/node': 26.1.1 + '@vitest/browser-playwright': 4.1.7(playwright@1.60.0)(vite@6.4.2(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) transitivePeerDependencies: - msw diff --git a/site/src/routes/api/components/canvas.mdx b/site/src/routes/api/components/canvas.mdx index 0ab6d46d..98082bdb 100644 --- a/site/src/routes/api/components/canvas.mdx +++ b/site/src/routes/api/components/canvas.mdx @@ -13,7 +13,6 @@ title: Canvas | `camera` | `Partial> \| Camera` | `new PerspectiveCamera()` | The scene camera — partial props for the default camera, or an instance you built. | | `gl` | `object \| ((canvas: HTMLCanvasElement) => Renderer) \| Renderer` | `new WebGLRenderer({ alpha: true })` | How the renderer is built. See [The `gl` prop](#the-gl-prop). | | `scene` | `Partial> \| Scene` | `new Scene()` | Settings for the scene, or an existing `Scene`. | -| `raycaster` | `Partial> \| EventRaycaster \| Raycaster` | `new CursorRaycaster()` | The [raycaster](/api/utilities/raycasters) used for pointer events. | | `shadows` | `boolean \| "basic" \| "percentage" \| "soft" \| "variance" \| WebGLRenderer["shadowMap"]` | off | Enables shadows. See [Rendering defaults](#rendering-defaults) for the string mapping. | | `orthographic` | `boolean` | `false` | Use an `OrthographicCamera` for the default camera. | | `linear` | `boolean` | `false` | Use a linear output color space instead of sRGB. | @@ -23,13 +22,14 @@ 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`). | + +`onPointerMissed` is not a native `Canvas` prop — it's contributed by a pointer-event engine when one is listed on this canvas's `plugins` (typically via `createT.withCanvas`). See [Events overview](/api/events/overview#the-miss-onpointermissed).
Exact type ```tsx -interface CanvasProps extends ParentProps> { +interface CanvasProps extends ParentProps { ref?: RefWithCleanup // Context | ((context: Context) => void | (() => void)) camera?: Partial | Props> | Camera fallback?: JSX.Element @@ -42,7 +42,6 @@ interface CanvasProps extends ParentProps> { | ((canvas: HTMLCanvasElement) => ResolvedRenderer) | ResolvedRenderer scene?: Partial> | Scene - raycaster?: Partial> | EventRaycaster | Raycaster shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"] orthographic?: boolean linear?: boolean @@ -50,7 +49,7 @@ interface CanvasProps extends ParentProps> { frameloop?: "never" | "demand" | "always" style?: JSX.CSSProperties class?: string - // Plus all event handlers (Partial) + // Plugins can contribute additional props here, e.g. onPointerMissed from pointerEvents() } ``` @@ -65,7 +64,7 @@ interface CanvasProps extends ParentProps> { camera={{ position: [0, 0, 5], fov: 75 }} shadows="soft" gl={{ antialias: true }} - onClickMissed={() => console.log("Clicked empty space")} + onPointerMissed={() => console.log("Clicked empty space")} > {/* Your 3D scene */} @@ -195,5 +194,5 @@ interface RendererLike { ## See also - [`useThree`](/api/hooks/use-three) — read the renderer, scene, camera, and more from inside the canvas. -- [Raycasters](/api/utilities/raycasters) — the raycaster types accepted by the `raycaster` prop. +- [Raycasters](/api/utilities/raycasters) — the raycaster the pointer-events engine picks with, configured on the engine rather than here. - [Events overview](/api/events/overview) — the handlers you can pass to ``. diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 4f217e2d..d74389b8 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -4,19 +4,46 @@ title: Events overview # Event handling -`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). +`solid-three`'s core ships no pointer-event system. Events come from `pointerEvents`, a plugin in `solid-three/events` inspired by [`react-three-fiber`](https://github.com/pmndrs/react-three-fiber), and everything on this page — `onClick`, hover, dragging, `onPointerMissed` — exists only once you install it. -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)). +That is deliberate. Plugins contribute _typed_ props, so a scene that forgets the engine fails to compile on its first `onClick` instead of rendering a handler that silently never fires. + +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 names 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)). A canvas only accepts them if the engine is installed on it too. + +## Installing the engine + +For object-level handlers — `onClick`, `onPointerEnter`/`onPointerLeave`, dragging — install the engine into the namespace: + +```tsx +import { createT } from "solid-three" +import { pointerEvents } from "solid-three/events" + +const T = createT(THREE, [pointerEvents()]) +``` + +For the canvas-level `onPointerMissed` — the "clicked empty space" signal — reach for `createT.withCanvas`, which hands back a `Canvas` with the engine already listed on it: + +```tsx +import { pointerEvents } from "solid-three/events" + +const { T, Canvas } = createT.withCanvas(THREE, [pointerEvents()]) +``` + +The engine also owns the raycaster it picks with — core has none. Configure it with `pointerEvents({ raycaster: … })`, and reach it at runtime with `useRaycaster()`, which hands back the raycaster currently picking plus a `setRaycaster` that pushes one for a subtree. See [Raycasters](/api/utilities/raycasters). ## Supported events -### Click events (missable) +### Click events + +Fire on the object that was hit, then bubble up its ancestors: -These fire on the object that was hit, or — when nothing handles them — as a paired `*Missed` event: +- `onClick` +- `onContextMenu` — right-click +- `onDoubleClick` -- `onClick` / `onClickMissed` -- `onContextMenu` / `onContextMenuMissed` — right-click -- `onDoubleClick` / `onDoubleClickMissed` +When a click lands on something else — a different object, or empty space — the objects it _didn't_ hit fire [`onPointerMissed`](#the-miss-onpointermissed) instead. ### Hover events @@ -50,7 +77,7 @@ 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 | +| `onPointerMissed` | only `nativeEvent` — the miss carries no intersection |
Exact type @@ -115,11 +142,13 @@ Most handlers care only about the nearest hit, which is what `intersection` reso Each dispatch runs two propagation passes: 1. **Along the ray (3D space)** — the handler fires on each hit object in nearest-first order. `stopPropagation()` skips the remaining objects along the ray. -2. **Up the scene graph (tree)** — after an object's handler runs, the event bubbles up its ancestors. `stopPropagation()` stops this too. If nothing stops the event, it finally fires on the [``](/api/components/canvas) itself. +2. **Up the scene graph (tree)** — after an object's handler runs, the event bubbles up its ancestors. `stopPropagation()` stops this too. ```tsx +const T = createT(THREE, [pointerEvents()]) + const EventPropagation = () => ( - console.log("4. Canvas clicked (canvas propagation)")}> + console.log("3. Group clicked (tree propagation)")}> ( ) ``` -Without the `stopPropagation()` call the order would be front mesh → back mesh → group → canvas. Calling it on the front mesh halts all four steps. +Without the `stopPropagation()` call the order would be front mesh → back mesh → group. Calling it on the front mesh halts all three steps. ## Stoppable vs non-stoppable events @@ -154,46 +183,30 @@ 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. +- `onPointerMissed` — the [miss](#the-miss-onpointermissed) is by definition the "this object wasn't hit" signal. - `onPointerEnter` — enter has to reach the newly-hovered subtree. - `onPointerLeave` — leave has to reach the previously-hovered subtree. -## Missed events +## The miss: `onPointerMissed` -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. +`onPointerMissed` is the "not-me" signal. On an object it fires when a click, double-click, or context-menu lands somewhere _other_ than that object — a different object, or empty space. It does **not** fire when the object itself, or one of its descendants, is hit (clicking inside an object counts as hitting it). It can't be stopped and carries no object payload. This matches react-three-fiber. ```tsx - console.log("Missed — clicked outside this mesh")}> +const T = createT(THREE, [pointerEvents()]) + +// A selectable mesh: clicking it selects; clicking anything else clears it. + select()} onPointerMissed={() => deselect()}> ``` -```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: +On the [``](/api/components/canvas), `onPointerMissed` is narrower: it fires only when the click hit **nothing at all** — the canvas-wide "clicked empty space" / deselect signal. The engine has to be listed on that canvas for the prop to exist at all — `createT.withCanvas` builds both together: ```tsx - deselect()}> +const { T, Canvas } = createT.withCanvas(THREE, [pointerEvents()]) + + deselectAll()}> select()}> @@ -201,7 +214,7 @@ 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. +Common uses: an object deselecting itself when something else is clicked, or a canvas-wide background-click-to-deselect. ## Hover events @@ -233,9 +246,9 @@ Pointer capture fixes this. Inside an `onPointerDown`, `onPointerMove`, or `onPo | `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. +Once captured, every subsequent `onPointerMove` and `onPointerUp` for that pointer is delivered **exclusively** to the captured node's chain — its own handler and its ancestors' — 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. +Capture is **object-scoped** — the no-arg form captures the object whose handler is running. 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. @@ -269,7 +282,7 @@ See the [pointer events tour](/tour/04-pointer-events#dragging) for a runnable d 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" +import { hasPointerCapture } from "solid-three/events" let mesh: THREE.Mesh | undefined ;`](/api/components/canvas) — where canvas-level handlers and `onClickMissed` fire. +- [Raycasters](/api/utilities/raycasters) — configuring the engine's raycaster, and `useRaycaster()`. +- [``](/api/components/canvas) — where the canvas-level `onPointerMissed` fires. diff --git a/site/src/routes/api/events/raycastable.mdx b/site/src/routes/api/events/raycastable.mdx index 08c2545a..d775f5cb 100644 --- a/site/src/routes/api/events/raycastable.mdx +++ b/site/src/routes/api/events/raycastable.mdx @@ -10,6 +10,8 @@ The `raycastable` prop controls whether an `Object3D` can be hit by raycasting. | ------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | `raycastable` | `boolean` | `true` | When `false`, the object isn't hit by rays directly, but still receives events that bubble up from its descendants. | +`raycastable` is a core prop — it exists whether or not a pointer-event engine is installed, since it controls what any engine's raycast sees. The example below assumes `pointerEvents()` has already been installed on `T` (see [Events overview](/api/events/overview)); without it, `onClick` would not compile. + ```tsx console.log("Child clicked!")}> diff --git a/site/src/routes/api/hooks/use-three.mdx b/site/src/routes/api/hooks/use-three.mdx index 1d70a2a4..c4bf3cfe 100644 --- a/site/src/routes/api/hooks/use-three.mdx +++ b/site/src/routes/api/hooks/use-three.mdx @@ -4,7 +4,7 @@ title: useThree # useThree -`useThree` gives you the `three.js` context from inside a `` — the renderer, scene, camera, raycaster, and more. +`useThree` gives you the `three.js` context from inside a `` — the renderer, scene, camera, and more. ## Signature @@ -35,8 +35,6 @@ const camera = useThree(context => context.camera) | `clock` | `Clock` | The three.js clock, for timing. | | `dpr` | `number` | Device pixel ratio of the active renderer. Falls back to `1` for renderers without `getPixelRatio` (e.g. `CSS3DRenderer`, `SVGRenderer`). | | `gl` | `Meta` | The active renderer, wrapped with [`meta`](/api/utilities/metadata) so you can read solid-three metadata via `getMeta(three.gl)`. Narrow it project-wide with [Register augmentation](/api/components/canvas#narrowing-the-renderer-type-project-wide). | -| `raycaster` | `Raycaster \| EventRaycaster` | The current raycaster used for pointer events. | -| `setRaycaster` | `(raycaster: Raycaster) => () => void` | Push a raycaster onto the stack. Returns a cleanup that pops it. | | `render` | `(timestamp: number, frame?: XRFrame) => void` | Render one frame now. Pass a timestamp (e.g. `performance.now()`); the optional `XRFrame` is forwarded to [`useFrame`](/api/hooks/use-frame) callbacks. This is the per-frame primitive you hand to `gl.setAnimationLoop` when driving a WebXR session. | | `requestRender` | `() => void` | Request a render on the next frame. Skipped while an XR session is presenting (the session owns the loop). | | `scene` | `Meta` | The root scene, wrapped with [`meta`](/api/utilities/metadata). | @@ -44,9 +42,11 @@ const camera = useThree(context => context.camera) ## Behavior -The camera and raycaster are each managed as a **stack**. The `camera` and `raycaster` from [``](/api/components/canvas) props sit at the bottom; whatever is on top is the active one that `three.camera` / `three.raycaster` return. +The camera is managed as a **stack**. The `camera` from [``](/api/components/canvas) props sits at the bottom; whatever is on top is the active one that `three.camera` returns. -`setCamera` and `setRaycaster` push a new one onto the stack, making it active, and return a cleanup that pops it back off — restoring the previous one. Pair the cleanup with `onCleanup` so the previous camera or raycaster comes back when your component unmounts. +`setCamera` pushes a new one onto the stack, making it active, and returns a cleanup that pops it back off — restoring the previous one. Pair the cleanup with `onCleanup` so the previous camera comes back when your component unmounts. + +There is no `raycaster` here. Core does no picking, so it owns no raycaster: that belongs to the [pointer-events engine](/api/events/overview), which hands it out through [`useRaycaster()`](/api/utilities/raycasters#useraycaster). ### WebXR @@ -91,6 +91,6 @@ createEffect(() => { ## See also -- [``](/api/components/canvas) — sets the default camera, raycaster, and scene this hook reads. -- [Raycasters](/api/utilities/raycasters) — the raycaster types `setRaycaster` accepts. +- [``](/api/components/canvas) — sets the default camera and scene this hook reads. +- [Raycasters](/api/utilities/raycasters) — `useRaycaster()` and the raycaster the engine picks with. - [Metadata](/api/utilities/metadata) — reading solid-three metadata off `gl` and `scene`. diff --git a/site/src/routes/api/types.mdx b/site/src/routes/api/types.mdx index 9c28dbb8..d5e862ae 100644 --- a/site/src/routes/api/types.mdx +++ b/site/src/routes/api/types.mdx @@ -20,7 +20,7 @@ The namespace is the re-export of [`src/types.ts`](https://github.com/bigmistqke - **Renderer surface** — `Renderer`, `RendererLike`, `ResolvedRenderer`, `Register` - **Context** — `Context`, `Viewport`, `CameraKind` - **Frame loop** — `FrameListener`, `FrameListenerCallback`, `FrameListenerOptions` -- **Events** — `ThreeEvent`, `EventHandlers`, `CanvasEventHandlers`, `EventName` +- **Events** — `ThreeEvent`, `EventHandlers`, `EventName`. These belong to the event engine, so they are exported from `solid-three/events`, not from the core `S3` namespace. - **Three representations** — `Representation`, `Vector2`, `Vector3`, `Vector4`, `Color`, `Layers`, `Quaternion`, `Euler`, `Matrix3`, `Matrix4` - **Solid-three metadata** — `Meta`, `Data`, `MapToRepresentation`, `Props` - **Loaders** — `LoaderData`, `LoaderUrl` @@ -53,11 +53,11 @@ Everything importable from `solid-three`, and where each is documented. **Utilities** -| Export | What it is | -| ----------------------------------------------------------------------------------- | ----------------------------------------- | -| [`EventRaycaster`, `CursorRaycaster`, `CenterRaycaster`](/api/utilities/raycasters) | Pointer-event raycasting strategies | -| [`autodispose`](/api/utilities/autodispose) | Tie an object's lifetime to its component | -| [`meta`, `getMeta`, `hasMeta`, `$S3C`](/api/utilities/metadata) | Read and write per-object metadata | +| Export | What it is | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| [`EventRaycaster`, `CursorRaycaster`, `CenterRaycaster`, `useRaycaster`](/api/utilities/raycasters) | Pointer-event raycasting strategies, and the hook that reaches the engine's raycaster. Exported from `solid-three/events`. | +| [`autodispose`](/api/utilities/autodispose) | Tie an object's lifetime to its component | +| [`meta`, `getMeta`, `hasMeta`, `$S3C`](/api/utilities/metadata) | Read and write per-object metadata | **Types** diff --git a/site/src/routes/api/utilities/raycasters.mdx b/site/src/routes/api/utilities/raycasters.mdx index 34f03176..454b84fc 100644 --- a/site/src/routes/api/utilities/raycasters.mdx +++ b/site/src/routes/api/utilities/raycasters.mdx @@ -4,15 +4,17 @@ title: Raycasters # Raycasters -`solid-three` ships custom raycasters that decide how a pointer becomes a ray. They all extend `THREE.Raycaster` and implement `EventRaycaster`, which adds one method: +`solid-three/events` ships custom raycasters that decide how a pointer becomes a ray. They all extend `THREE.Raycaster` and implement `EventRaycaster`, which adds one method: | Method | Description | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `cast(registry, context)` | Aim the ray for the current pointer, then return its hits against `registry` and its descendants (honoring `raycastable !== false`), nearest-first. | -The pointer system calls `cast()` on every event. How the ray is aimed is the raycaster's own business — from the camera and a cursor position for screen pointers, or from an object's world transform for an XR controller. +The pointer-events engine calls `cast()` on every event. How the ray is aimed is the raycaster's own business — from the camera and a cursor position for screen pointers, or from an object's world transform for an XR controller. -Screen-pointer raycasters also implement `ScreenRaycaster`, which adds `setCursor(ndc)`. The pointer system calls it with the cursor in normalized device coordinates before each `cast()`. +Screen-pointer raycasters also implement `ScreenRaycaster`, which adds `setCursor(ndc)`. The engine calls it with the cursor in normalized device coordinates before each `cast()`. + +The engine owns the raycaster. Core has none — no `` prop, nothing on `useThree()`. Configure it up front with `pointerEvents({ raycaster })`, and read, mutate or override it at runtime with [`useRaycaster()`](#useraycaster).
Exact types @@ -29,16 +31,82 @@ interface ScreenRaycaster extends EventRaycaster {
+## Configuring the raycaster + +`pointerEvents({ raycaster })` takes either form: + +- a **config object** of raycaster properties, applied to the engine's own `CursorRaycaster` +- a **raycaster instance**, used as the whole ray strategy + +```tsx +// Config: pick nothing further than 10 units away. +pointerEvents({ raycaster: { far: 10, near: 1 } }) + +// Instance: cast from the screen centre instead of the cursor. +pointerEvents({ raycaster: new CenterRaycaster() }) +``` + +The option is read once, when the engine installs. It sets the engine's own raycaster — the bottom of the [raycaster stack](#useraycaster). To change how the canvas picks while the scene is running, go through `useRaycaster()`. + +## useRaycaster + +The raycaster is a stack. The engine's own raycaster sits at the bottom, and any subtree can push its own over it. `useRaycaster()` hands back both halves: + +```tsx +import { useRaycaster } from "solid-three/events" + +const { raycaster, setRaycaster } = useRaycaster() +``` + +| Member | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `raycaster()` | An accessor for the raycaster the canvas picks with right now — the top of the stack. Reactive. | +| `setRaycaster(next)` | Pushes `next`: the canvas picks with it from here on. Returns a disposer that pops it, restoring the previous one. | + +`raycaster()` is the very object that picks, so mutating it changes what gets hit on the next event: + +```tsx +const Reach = (props: { far: number }) => { + const { raycaster } = useRaycaster() + createEffect(() => (raycaster().far = props.far)) + return null +} +``` + +`setRaycaster()` swaps in a whole ray strategy for a subtree. The push is tied to the calling owner, so an unmounting subtree restores the previous raycaster on its own; hold on to the disposer to pop it earlier. + +```tsx +import { onCleanup } from "solid-js" +import { CenterRaycaster, useRaycaster } from "solid-three/events" + +// While this component is mounted, the whole canvas picks by gaze. +const Gaze = () => { + const { setRaycaster } = useRaycaster() + const restore = setRaycaster(new CenterRaycaster()) + onCleanup(restore) + return null +} +``` + +Note that a push is canvas-wide, not scoped to the pushing subtree's objects: there is one pointer system per canvas, and the top of the stack is what it casts with. The subtree decides how the canvas picks for as long as it is mounted. + +`useRaycaster()` throws when the canvas has no engine installed, rather than handing back a raycaster that nothing casts with. List the engine on the `` — ``, or use `createT.withCanvas` — so it is installed before the hook runs. + ## CursorRaycaster -The default. Tracks the cursor and casts from the active camera. +The default the engine falls back to when none is configured. Tracks the cursor and casts from the active camera. ```tsx -import { Canvas, CursorRaycaster } from "solid-three" +import { Canvas } from "solid-three" +import { CursorRaycaster, pointerEvents } from "solid-three/events" const App = () => { - // CursorRaycaster is the default; pass it explicitly only to swap or configure. - return {/* Your scene */} + // CursorRaycaster is the engine's default; pass it explicitly only to swap or configure. + return ( + + {/* Your scene */} + + ) } ``` @@ -47,9 +115,14 @@ const App = () => { Ignores the cursor and always casts from the centre of the screen — useful for gaze or crosshair interaction. ```tsx -import { Canvas, CenterRaycaster } from "solid-three" - -const App = () => {/* Your scene */} +import { Canvas } from "solid-three" +import { CenterRaycaster, pointerEvents } from "solid-three/events" + +const App = () => ( + + {/* Your scene */} + +) ``` ## ControllerRaycaster @@ -57,7 +130,7 @@ const App = () => {/* Your scene */} {/* Your scene */} +const App = () => ( + {/* scene */} +) ``` For a non-screen ray — a custom origin and direction — implement `cast()` directly: aim `this.ray` from whatever transform you like, then return `this.intersectObjects(registry, true)`. `ControllerRaycaster` is the reference implementation. ## See also -- [Events overview](/api/events/overview) — how and when `cast()` is called. -- [`useThree`](/api/hooks/use-three) — `raycaster` / `setRaycaster` for swapping the active raycaster at runtime. +- [Events overview](/api/events/overview) — how and when `cast()` is called, and how to install the engine. +- [``](/api/components/canvas) — where the engine is listed. diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 0e5b8737..d4412233 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 pointerMissedSnippet from "../../snippets/04-pointer-missed.tsx?raw" +import pointerMissedUrl from "../../snippets/04-pointer-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" @@ -15,20 +15,23 @@ import dragUrl from "../../snippets/04-drag.tsx?importChunkUrl" # Pointer events -The button you wrote in the last chapter handled an `onClick` from the DOM. -`solid-three` lets you put the same handler on a `` — and gives it -the right semantics for a 3D world: it only fires when the click actually -hits that mesh. +The button you wrote in the last chapter handled an `onClick` from the DOM. A `` takes the same handler, with the right semantics for a 3D world: it only fires when the click actually hits that mesh. + +Core ships no event engine, though — you install one: + +```tsx +import { pointerEvents } from "solid-three/events" + +const T = createT(THREE, [pointerEvents()]) +``` + +With `pointerEvents()` in the plugin list, every `T.*` that wraps an `Object3D` gains `onClick`, `onPointerEnter`, `onPointerMove` and the rest, as typed props. Leave it out and `onClick` is not a prop of `T.Mesh` at all — a compile error, rather than a handler that silently never fires. -Click the cube. Same toggle as before — just the target moved into the -scene. +Click the cube. Same toggle as before — just the target moved into the scene. -Under the hood, `solid-three` runs a `Raycaster` against the pointer's -position each event, finds which meshes the ray hits, and fires the -corresponding handler on the nearest one. You don't have to wire any of -that — adding `onClick` is enough. +Under the hood, the engine runs a `Raycaster` against the pointer's position each event, finds which meshes the ray hits, and fires the corresponding handler on the nearest one. You don't have to wire any of that yourself — installing the engine and adding `onClick` is enough. ## Hover @@ -43,11 +46,15 @@ flips; `scale` reads it; one property assignment runs. ## Clicking nothing -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: +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. The engine contributes `onPointerMissed` to `` for exactly that case — but only to a canvas the engine is actually installed on. `createT.withCanvas` does both at once: it builds `T` and a matching `Canvas` that already has `onPointerMissed` typed in. + +```tsx +import { pointerEvents } from "solid-three/events" + +const { T, Canvas } = createT.withCanvas(THREE, [pointerEvents()]) +``` - + 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 diff --git a/site/src/snippets/04-click.tsx b/site/src/snippets/04-click.tsx index 91f09981..3d7bf08d 100644 --- a/site/src/snippets/04-click.tsx +++ b/site/src/snippets/04-click.tsx @@ -1,8 +1,9 @@ import * as THREE from "three" import { createSignal } from "solid-js" import { Canvas, createT } from "solid-three" +import { pointerEvents } from "solid-three/events" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) export default () => { const [hot, setHot] = createSignal(false) diff --git a/site/src/snippets/04-drag.tsx b/site/src/snippets/04-drag.tsx index 8d6b7c38..ff6cc481 100644 --- a/site/src/snippets/04-drag.tsx +++ b/site/src/snippets/04-drag.tsx @@ -1,8 +1,9 @@ import * as THREE from "three" import { createSignal } from "solid-js" -import { Canvas, createT, hasPointerCapture } from "solid-three" +import { Canvas, createT } from "solid-three" +import { hasPointerCapture, pointerEvents } from "solid-three/events" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) export default () => { const [position, setPosition] = createSignal<[number, number, number]>([0, 0, 0]) diff --git a/site/src/snippets/04-hover.tsx b/site/src/snippets/04-hover.tsx index a719374d..b54faeb1 100644 --- a/site/src/snippets/04-hover.tsx +++ b/site/src/snippets/04-hover.tsx @@ -1,8 +1,9 @@ import * as THREE from "three" import { createSignal } from "solid-js" import { Canvas, createT } from "solid-three" +import { pointerEvents } from "solid-three/events" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) export default () => { const [hovered, setHovered] = createSignal(false) diff --git a/site/src/snippets/04-click-missed.tsx b/site/src/snippets/04-pointer-missed.tsx similarity index 64% rename from site/src/snippets/04-click-missed.tsx rename to site/src/snippets/04-pointer-missed.tsx index 731bcd25..6d853546 100644 --- a/site/src/snippets/04-click-missed.tsx +++ b/site/src/snippets/04-pointer-missed.tsx @@ -1,14 +1,15 @@ import * as THREE from "three" import { createSignal } from "solid-js" -import { Canvas, createT } from "solid-three" +import { createT } from "solid-three" +import { pointerEvents } from "solid-three/events" -const T = createT(THREE) +const { T, Canvas } = createT.withCanvas(THREE, [pointerEvents()]) export default () => { const [selected, setSelected] = createSignal(false) return ( - setSelected(false)}> + setSelected(false)}> setSelected(true)}> diff --git a/site/src/snippets/04-stop-propagation.tsx b/site/src/snippets/04-stop-propagation.tsx index d09799c3..43ae0f3c 100644 --- a/site/src/snippets/04-stop-propagation.tsx +++ b/site/src/snippets/04-stop-propagation.tsx @@ -1,8 +1,9 @@ import * as THREE from "three" import { createSignal } from "solid-js" import { Canvas, createT } from "solid-three" +import { pointerEvents } from "solid-three/events" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) export default () => { const [front, setFront] = createSignal(false) diff --git a/site/src/snippets/09-look-at.tsx b/site/src/snippets/09-look-at.tsx index 5ccc5609..28a2f05b 100644 --- a/site/src/snippets/09-look-at.tsx +++ b/site/src/snippets/09-look-at.tsx @@ -1,5 +1,6 @@ import { createSignal, For } from "solid-js" import { Canvas, createT, plugin } from "solid-three" +import { pointerEvents } from "solid-three/events" import * as THREE from "three" // A plugin: every Object3D gains a `lookAt` prop that calls three's `lookAt()`. @@ -7,7 +8,7 @@ const lookAt = plugin([THREE.Object3D], object => ({ lookAt: (target: THREE.Vector3) => object.lookAt(target), })) -const T = createT(THREE, [lookAt]) +const T = createT(THREE, [lookAt, pointerEvents()]) // One cone geometry, rotated so its tip points along +Z — a Mesh's lookAt() // aims +Z at the target (cameras/lights aim -Z; meshes are the opposite). diff --git a/site/src/snippets/gallery/letter-drop.tsx b/site/src/snippets/gallery/letter-drop.tsx index 47c18282..b7a2c168 100644 --- a/site/src/snippets/gallery/letter-drop.tsx +++ b/site/src/snippets/gallery/letter-drop.tsx @@ -1,6 +1,7 @@ import * as CANNON from "cannon-es" import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { Canvas, createT, useFrame, useThree } from "solid-three" +import { pointerEvents } from "solid-three/events" import * as THREE from "three" import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js" import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js" @@ -13,7 +14,7 @@ import { TTFLoader } from "three/examples/jsm/loaders/TTFLoader.js" // A relative ?url import would break in edit mode. const fontTtfUrl = "zalando-sans-expanded-latin-600-normal.ttf" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) const LETTERS = ["S", "O", "L", "I", "D", "T", "H", "R", "E", "E"] as const const SOLID_BLUE = "#2c4f7c" diff --git a/src/canvas.tsx b/src/canvas.tsx index d3df1951..3b79b7ba 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -4,17 +4,16 @@ import { Camera, OrthographicCamera, PerspectiveCamera, - Raycaster, Scene, WebGLRenderer, type WebGLRendererParameters, } from "three" import { createThree } from "./create-three.tsx" -import type { EventRaycaster } from "./raycasters.tsx" import type { BaseProps, - CanvasEventHandlers, + CanvasPropsOf, Context, + Plugin, RefWithCleanup, ResolvedRenderer, } from "./types.ts" @@ -22,13 +21,15 @@ import type { /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. */ -export interface CanvasProps extends ParentProps> { +export interface CanvasProps< + TPlugins extends readonly Plugin[] = readonly Plugin[], +> extends ParentProps { + /** Event engines and other plugins installed on this canvas. */ + plugins?: TPlugins ref?: RefWithCleanup class?: string /** Configuration for the camera used in the scene. */ camera?: Partial | BaseProps> | Camera - /** Configuration for the Raycaster used for mouse and pointer events. */ - raycaster?: Partial> | EventRaycaster | Raycaster /** Element to render while the main content is loading asynchronously. */ fallback?: JSX.Element /** Toggles flat interpolation for texture filtering. */ @@ -83,12 +84,15 @@ export interface CanvasProps extends ParentProps> { * @param props - Configuration options include camera settings, style, and children elements. * @returns A div element containing the WebGL canvas configured to occupy the full available space. */ -export function Canvas(props: ParentProps) { +export function Canvas( + props: ParentProps> & Partial>, +) { let canvas: HTMLCanvasElement | undefined let container: HTMLDivElement | undefined onMount(() => { if (!canvas || !container) return + // `createThree` installs the `plugins` and wires their contributed canvas props. const context = createThree(canvas, props) // Resize observer for the canvas to adjust camera and renderer on size change diff --git a/src/create-events.ts b/src/create-events.ts deleted file mode 100644 index de910d45..00000000 --- a/src/create-events.ts +++ /dev/null @@ -1,88 +0,0 @@ -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" - -/**********************************************************************************/ -/* */ -/* Is Event Type */ -/* */ -/**********************************************************************************/ - -/** - * Checks if a given prop key is a pointer-event handler. - * - * @param type - The prop key to check. - * @returns `true` if the key is a recognized pointer-event handler. - */ -export const isEventType = (type: string): type is EventName => - /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(type) - -/**********************************************************************************/ -/* */ -/* Create Events */ -/* */ -/**********************************************************************************/ - -/** - * Wires the pointer-event system. Registers handler-bearing objects into the - * single `context.eventRegistry`, and connects the built-in screen pointer - * source (`DOMPointerManager`), which raycasts that registry and dispatches via - * per-`pointerId` `Pointer`s. XR controllers are added as further pointers by - * 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() - function addToRegistry(object: Object3D) { - const count = refCounts.get(object) ?? 0 - if (count === 0) context.eventRegistry.push(object) - refCounts.set(object, count + 1) - return () => { - const current = refCounts.get(object) - if (current === undefined) return - if (current <= 1) { - 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) - } - } - } - - return { - /** - * Registers an `AugmentedElement` with the pointer-event system. - * - * @param object - The 3D object to register. - * @param _type - The handler type (accepted for API compatibility; the single - * registry is not keyed by type). - */ - addEventListener(object: Meta, _type: EventName) { - return addToRegistry(object) - }, - } -} diff --git a/src/create-t.tsx b/src/create-t.tsx index 5b79b7ed..6e4c4ef0 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,7 @@ -import { createMemo, type Component, type JSX } from "solid-js" +import { createMemo, mergeProps, type Component, type JSX, type ParentProps } from "solid-js" +import { Canvas, type CanvasProps } from "./canvas.tsx" import { useProps } from "./props.ts" -import type { BaseProps, Plugin, Props } from "./types.ts" +import type { BaseProps, CanvasPropsOf, Plugin, Props } from "./types.ts" import { autodispose, meta } from "./utils.ts" /**********************************************************************************/ @@ -64,3 +65,34 @@ export function createEntity( return memo as unknown as JSX.Element } } + +/** + * `createT` plus a `Canvas` typed by the same plugins. Sugar over `` + * for the common single-namespace case: the returned `Canvas` defaults its `plugins` prop + * to `plugins`, and the prop stays available to override or extend (co-existence). + */ +createT.withCanvas = function withCanvas< + const TCatalogue extends Record, + const TBoundPlugins extends readonly Plugin[], +>(catalogue: TCatalogue, plugins: TBoundPlugins) { + const T = createT(catalogue, plugins) + /** + * Generic over its OWN plugin tuple, defaulting to the bound one. That default is what + * makes the `plugins` prop genuinely optional AND genuinely overridable: with no + * `plugins` prop there is no inference site, so `TPlugins` falls back to `TBoundPlugins` + * and the bound engines' contributed canvas props are in scope; with one, `TPlugins` is + * inferred from the prop, so a DIFFERENT engine (narrowing) or a LONGER tuple + * (extending, `[bound, other]`) types the canvas by what was actually passed. Pinning + * the props to `TBoundPlugins` instead would accept only tuples of the bound plugins' + * own types, which is neither. + */ + function BoundCanvas( + props: ParentProps> & Partial>, + ) { + // `mergeProps` lets a later source's *defined* value win, so an explicit `plugins` + // prop replaces the bound default and an absent one falls through to it. + const merged = mergeProps({ plugins }, props) + return Canvas(merged as ParentProps> & Partial>) + } + return { T, Canvas: BoundCanvas } +} diff --git a/src/create-three.tsx b/src/create-three.tsx index cde66c40..c1fccd30 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -8,6 +8,7 @@ import { getOwner, mergeProps, onCleanup, + runWithOwner, untrack, } from "solid-js" import { @@ -21,7 +22,6 @@ import { PCFShadowMap, PCFSoftShadowMap, PerspectiveCamera, - Raycaster, Scene, SRGBColorSpace, Vector3, @@ -30,12 +30,9 @@ import { type WebGLRendererParameters, } from "three" import type { CanvasProps } from "./canvas.tsx" -import { createEvents } from "./create-events.ts" import { Stack } from "./data-structure/stack.ts" import { frameContext, threeContext } from "./hooks.ts" -import { eventContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" -import { CursorRaycaster, type EventRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, @@ -60,9 +57,14 @@ import { import { useMeasure } from "./utils/use-measure.ts" /** - * Creates and manages a `solid-three` scene. It initializes necessary objects like - * camera, renderer, raycaster, and scene, manages the scene graph, setups up an event system - * and rendering loop based on the provided properties. + * Creates and manages a `solid-three` scene. It initializes the objects core owns — + * camera, renderer, scene — manages the scene graph, and runs the rendering loop, based + * on the provided properties. + * + * It sets up NO event system: core ships no event engine, and does no picking — the ray + * strategy that picks belongs to whichever engine dispatches. Pointer events arrive only + * when an engine plugin is installed via the `plugins` prop, which this function also does + * — running each plugin's `install` and wiring its contributed canvas-level props. */ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { const canvasProps = defaultProps(props, { frameloop: "always" }) @@ -180,7 +182,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { const cameraIsInstance = createMemo(() => props.camera instanceof Camera) const orthographicFlag = createMemo(() => !!props.orthographic) const sceneIsInstance = createMemo(() => props.scene instanceof Scene) - const raycasterIsInstance = createMemo(() => props.raycaster instanceof Raycaster) const glKind = createMemo<"factory" | "instance" | "default">(() => { const _propsGl = props.gl if (typeof _propsGl === "function") return "factory" @@ -243,23 +244,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { }) }) - const raycaster = createMemo(() => { - if (raycasterIsInstance()) { - return meta(props.raycaster as Raycaster, { - get props() { - return props.raycaster || {} - }, - }) - } - return meta(new CursorRaycaster(), { - get props() { - return props.raycaster || {} - }, - }) - }) - - const raycasterStack = new Stack("raycaster") - // Tracks whether the *previous* renderer was built by us (vs supplied by // the user via factory/instance). Only our own renderers get disposed when // the memo re-runs — disposing a user's renderer would be rude. @@ -357,14 +341,20 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { return measure.bounds() }, owner: getOwner(), + addFrameListener, initializePlugin(token: unknown, fn: () => void) { if (initializedPlugins.has(token)) return initializedPlugins.add(token) - fn() + // `this.owner` (not a fresh `getOwner()`) — `initializePlugin` is invoked from + // inside a per-element render effect, so an ambient `getOwner()` call here would + // resolve to that element's own reactive scope and tie the plugin's cleanup to + // whichever element happens to trigger the (deduped) install first, defeating the + // point of this change. `this.owner` is the Canvas's owner, captured once above. + if (this.owner) runWithOwner(this.owner, fn) + else fn() }, canvas, clock, - eventRegistry: [], get dpr() { // Renderers without a pixel-ratio API (CSS2D/3D, SVG) didn't scale // anything — reporting `1` is honest. Users who need the device's @@ -388,12 +378,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { get scene() { return scene() }, - get raycaster() { - return raycasterStack.peek() || raycaster() - }, - setRaycaster(raycaster: Raycaster) { - return raycasterStack.push(raycaster) - }, get gl() { // Internally gl is typed as Meta (the open union) since the // memo can produce any concrete renderer the user chose. Externally it @@ -437,12 +421,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { useProps(scene, props.scene) }) - // Manage raycaster - createRenderEffect(() => { - if (!props.raycaster || props.raycaster instanceof Raycaster) return - useProps(raycaster, props.raycaster) - }) - // Manage gl createRenderEffect(() => { // Set shadow-map. `enabled` and `type` exist on both WebGL/WebGPU @@ -575,12 +553,38 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { /**********************************************************************************/ /* */ - /* Events */ + /* Plugins */ /* */ /**********************************************************************************/ - // Initialize event-system - const { addEventListener } = createEvents(context) + // Eager install: every plugin listed on the canvas gets its one-time per-context + // setup now, before any element mounts. This is one of the two install triggers — + // the lazy counterpart runs from the first plugged element in `useProps`, and + // `initializePlugin`'s token dedup makes the pair harmless. It lives here rather + // than in `` so every entry point into a scene (the `` component, + // and `test()`/`TestCanvas` from `solid-three/testing`, which call `createThree` + // directly) installs plugins identically. + const canvasMethods: Record void> = {} + for (const plugin of props.plugins ?? []) { + if (plugin.install) { + context.initializePlugin(plugin.token ?? plugin, () => plugin.install?.(context)) + } + if (!plugin.canvas) continue + for (const [key, method] of Object.entries(plugin.canvas(context))) { + if (process.env.DEV && key in canvasMethods) { + console.warn( + `S3: two plugins contribute the canvas prop "${key}" — the last one wins. Rename one of them if both were meant to fire.`, + ) + } + canvasMethods[key] = method + } + } + + // Feed each contributed canvas prop its value, reactively. `canvasMethods` is built + // once (plugins aren't reactive), so one effect per contributed prop is enough. + for (const key of Object.keys(canvasMethods)) { + createRenderEffect(() => canvasMethods[key]((props as Record)[key])) + } /**********************************************************************************/ /* */ @@ -589,11 +593,9 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { /**********************************************************************************/ const c = children(() => ( - - - {canvasProps.children} - - + + {canvasProps.children} + )) useSceneGraph(context.scene, { diff --git a/src/events-pmndrs/index.ts b/src/events-pmndrs/index.ts new file mode 100644 index 00000000..937cc696 --- /dev/null +++ b/src/events-pmndrs/index.ts @@ -0,0 +1,150 @@ +import { + forwardHtmlEvents, + type PointerEvent as PmndrsPointerEvent, + type WheelEvent as PmndrsWheelEvent, +} from "@pmndrs/pointer-events" +import { onCleanup } from "solid-js" +import { Object3D } from "three" +import { plugin } from "../plugin.ts" +import type { Context, Plugin } from "../types.ts" + +/** + * Stable per-engine identity. Module-level: two `pmndrsEvents()` instances install once. + */ +const PMNDRS_EVENTS_TOKEN = Symbol("solid-three/events-pmndrs") + +/** + * Contributed prop name -> the event type `@pmndrs/pointer-events` dispatches on three's + * `EventDispatcher`. + * + * pmndrs keeps no registry of its own. It traverses the scene and treats an object as a + * pointer target exactly when that object carries a listener for one of these types — + * `pointerEvents: "listener"` is its default. So "register a handler" and "add an event + * listener" are the same act, which is why this engine needs no per-element state and + * never reads `getMeta(object).ctx`. + */ +const POINTER_EVENT_TYPES = { + onClick: "click", + onDoubleClick: "dblclick", + onContextMenu: "contextmenu", + onPointerDown: "pointerdown", + onPointerUp: "pointerup", + onPointerMove: "pointermove", + onPointerCancel: "pointercancel", + onPointerOver: "pointerover", + onPointerOut: "pointerout", + onPointerEnter: "pointerenter", + onPointerLeave: "pointerleave", +} as const + +const WHEEL_EVENT_TYPES = { + onWheel: "wheel", +} as const + +/** Every three event type this engine listens for. */ +type PmndrsEventType = + | (typeof POINTER_EVENT_TYPES)[keyof typeof POINTER_EVENT_TYPES] + | (typeof WHEEL_EVENT_TYPES)[keyof typeof WHEEL_EVENT_TYPES] + +/** + * The handler signatures the contributed props accept. Note the event is pmndrs' own + * `PointerEvent` — an engine defines its own event shape, and this one is nothing like + * the reference engine's `ThreeEvent`. That divergence is the point: the boundary carries + * prop names and handler values, not one blessed event type. + */ +export type PmndrsEventHandlers = { + [Prop in keyof typeof POINTER_EVENT_TYPES]: (event: PmndrsPointerEvent) => void +} & { + [Prop in keyof typeof WHEEL_EVENT_TYPES]: (event: PmndrsWheelEvent) => void +} + +/** The methods the plugin contributes: each takes the prop's value — the handler. */ +export type PmndrsEventMethods = { + [Prop in keyof PmndrsEventHandlers]: (handler: PmndrsEventHandlers[Prop]) => void +} + +/** + * three types `addEventListener` per event key, and this engine registers a key it only + * knows as a union. Widen the dispatcher once, here, rather than casting at each of the + * twelve contributed methods. + */ +type EventDispatcherView = { + addEventListener(type: PmndrsEventType, listener: (event: never) => void): void + removeEventListener(type: PmndrsEventType, listener: (event: never) => void): void +} + +/** + * The engine's one-time per-context setup, run by `Context.initializePlugin` under the + * Canvas's owner — so `onCleanup` here tears the forwarder down when the Canvas unmounts, + * not when whichever element happened to install it first goes away. + * + * `forwardHtmlEvents` attaches the canvas listeners (pointermove/down/up/cancel/leave, + * pointerover, wheel — note: NOT `click`, which pmndrs synthesises itself from a + * down/up pair) and returns an `update()` that must be pumped once a frame. That pump is + * the whole reason `Context.addFrameListener` has to exist on the boundary: a plugin has + * no Solid context to call `useFrame` from. + * + * Reachable ONLY through `Context.initializePlugin`, which dedups by `token` — so this + * deliberately keeps no "already installed?" guard of its own. If the boundary ever + * double-installed, this engine would double-dispatch, and the tests would say so. + */ +function install(context: Context) { + const { update, destroy } = forwardHtmlEvents(context.canvas, () => context.camera, context.scene) + onCleanup(context.addFrameListener(() => update())) + onCleanup(destroy) +} + +/** + * A second event engine, wrapping `@pmndrs/pointer-events` — the package TresJS v5 uses. + * It exists to prove the plugin boundary hosts an engine solid-three does not own, and it + * is a deliberate foil for {@link pointerEvents}: everything about how it dispatches + * differs, and none of that difference reaches core. + * + * The sharpest divergence is the void. `pointerEvents()` contributes a canvas prop + * (``) because a miss is, for it, the absence of a hit. pmndrs + * MATERIALISES the void: `getVoidObject(scene)` is a real `Mesh` whose `parent` is the + * scene without ever being `add`ed, so events bubble through it while it is never + * traversed or rendered — and a miss is simply an intersection whose object IS that mesh. + * "Clicked empty space" is therefore an ordinary object-level listener here, and this + * engine contributes NO canvas prop at all: + * + * ```ts + * getVoidObject(context.scene).addEventListener("click", handleMiss) + * ``` + * + * Known quirks, inherited from the package and deliberately not worked around: + * - it patches `Object3D.prototype` with `setPointerCapture` / `releasePointerCapture` / + * `hasPointerCapture` at import time — a process-global mutation, shared by every + * scene in the page, engine or not; + * - its per-scene void-object cache is a module-level `Map` that is never cleaned up, so + * a disposed scene stays reachable through it; + * - `forwardHtmlEvents` takes the canvas's NATIVE pointer capture directly + * (`forwardPointerCapture` defaults to `true`), rather than asking anyone for it. + */ +export function pmndrsEvents(): Plugin<(element: Object3D) => PmndrsEventMethods> { + const base = plugin([Object3D], (object: Object3D) => { + const methods = {} as Record void> + const dispatcher = object as unknown as EventDispatcherView + + for (const [propName, eventType] of Object.entries({ + ...POINTER_EVENT_TYPES, + ...WHEEL_EVENT_TYPES, + }) as [string, PmndrsEventType][]) { + methods[propName] = (handler: unknown) => { + // The prop's runtime value is whatever the user passed — often a signal read that + // is momentarily undefined — so an absent handler simply doesn't register. + if (typeof handler !== "function") return + const listener = handler as (event: never) => void + dispatcher.addEventListener(eventType, listener) + onCleanup(() => dispatcher.removeEventListener(eventType, listener)) + } + } + + return methods as unknown as PmndrsEventMethods + }) + + return Object.assign(base, { + token: PMNDRS_EVENTS_TOKEN, + install, + }) +} diff --git a/src/events/engine.ts b/src/events/engine.ts new file mode 100644 index 00000000..d4c33bfb --- /dev/null +++ b/src/events/engine.ts @@ -0,0 +1,195 @@ +import { onCleanup } from "solid-js" +import type { Object3D, Raycaster } from "three" +import { Stack } from "../data-structure/stack.ts" +import { useProps } from "../props.ts" +import type { BaseProps, Context } from "../types.ts" +import { captureRegistry } from "./pointer-capture.ts" +import { DOMPointerManager } from "./pointer-managers.ts" +import type { DispatchEvent, PointerEngine } from "./pointers.ts" +import { CursorRaycaster, type ScreenRaycaster } from "./raycasters.ts" +import type { EventName } from "./types.ts" + +/** + * The `raycaster` option of `pointerEvents()`. Either form configures the ONE raycaster + * the engine picks with — core holds none: + * + * - a {@link ScreenRaycaster} INSTANCE — a whole ray strategy, e.g. `new CenterRaycaster()` + * for gaze input. The engine picks with that object itself. + * - a plain CONFIG OBJECT of raycaster properties, e.g. `{ far: 10, near: 1 }`, applied to + * the engine's own `CursorRaycaster`. + * + * Static per engine: it configures the engine's own raycaster once, at install — the one at + * the BOTTOM of the raycaster stack. At runtime, `useRaycaster()` reaches the stack: mutate + * the raycaster it hands back, or push a different one over it for a subtree. + */ +export type RaycasterOption = ScreenRaycaster | Partial> + +/** + * One engine state per `Context`. The registry lives HERE, not in core — which is + * what makes co-existing engines partition by object provenance: an object only ever + * enters the registry of the engine whose plugin contributed its handler prop. + */ +export class PointerEventsEngine implements PointerEngine { + readonly context: Context + readonly registry: Object3D[] = [] + /** + * The engine's own raycaster — the one built from the `raycaster` option (or the default + * `CursorRaycaster`). It sits at the BOTTOM of the stack: it picks whenever no subtree has + * pushed an override. + */ + readonly baseRaycaster: ScreenRaycaster + /** + * The raycaster overrides, innermost last. A subtree pushes with {@link setRaycaster} and + * pops on unmount, so the previous raycaster is restored. Reading it (through + * {@link raycaster}) is reactive: the stack is signal-backed. + */ + private raycasterStack = new Stack("raycaster") + /** + * The `raycaster` OPTION this engine was installed with (an instance, a config object, + * or nothing) — kept so `warnOnIgnoredRaycaster` can tell the instance that actually + * installed apart from a later one whose option goes nowhere. + */ + readonly raycasterOption: RaycasterOption | undefined + private refCounts = new Map() + private manager: DOMPointerManager + + /** The canvas-level miss handler, set through the engine's contributed canvas prop. */ + onPointerMissed: ((event: DispatchEvent) => void) | undefined + + constructor(context: Context, raycaster: ScreenRaycaster, raycasterOption?: RaycasterOption) { + this.context = context + this.baseRaycaster = raycaster + this.raycasterOption = raycasterOption + // The manager (and through it every `Pointer`) gets an ACCESSOR, never a captured + // reference: aiming and casting both resolve the top of the stack at the moment of the + // event, so pushing a raycaster genuinely changes what gets hit. + this.manager = new DOMPointerManager(context, () => this.raycaster, captureRegistry, this) + } + + /** + * The raycaster this canvas picks with right now: the top of the stack, or the engine's + * own when nothing is pushed. Every aim and every cast goes through here. + * + * Reactive — reading it in a tracking scope re-runs when a subtree pushes or pops. + */ + get raycaster(): ScreenRaycaster { + return this.raycasterStack.peek() ?? this.baseRaycaster + } + + /** + * Push `raycaster` on top of the stack: this canvas picks with it until it is popped. + * Returns a disposer that pops it, restoring whatever was underneath. The push is also + * tied to the calling owner, so an unmounting subtree restores the previous raycaster on + * its own. + */ + setRaycaster(raycaster: ScreenRaycaster): () => void { + return this.raycasterStack.push(raycaster) + } + + /** Attach the source's listeners; returns the disconnect. */ + connect() { + return this.manager.connect() + } + + /** + * Enter `object` into the engine's registry — the set the pointers raycast. + * Refcounted, so an object carrying several handlers is listed exactly once, and + * only leaves once its last handler goes. + * + * @param object - The 3D object to register. + * @param _type - The handler type (accepted for symmetry with the contributed prop + * names; the single registry is not keyed by type). + */ + register(object: Object3D, _type: EventName) { + const count = this.refCounts.get(object) ?? 0 + if (count === 0) this.registry.push(object) + this.refCounts.set(object, count + 1) + return () => { + const current = this.refCounts.get(object) + if (current === undefined) return + if (current > 1) { + this.refCounts.set(object, current - 1) + return + } + this.refCounts.delete(object) + const index = this.registry.indexOf(object) + if (index !== -1) this.registry.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 (!this.refCounts.has(object)) this.manager.releaseCaptured(object) + }) + } + } +} + +/** + * Engine state is per-`Context`, not per-plugin instance: one `pointerEvents()` can be + * handed to several canvases, and each needs its own registry, pointers and listeners. + */ +const engines = new WeakMap() + +export function getEngine(context: Context): PointerEventsEngine | undefined { + return engines.get(context) +} + +/** + * The engine's one-time per-context setup, run by `Context.initializePlugin` under the + * Canvas's owner — so `onCleanup` here removes the canvas listeners when the Canvas + * unmounts, not when whichever element installed the engine first goes away. + * + * Reachable ONLY through `Context.initializePlugin` — that is what guarantees this runs + * under the Canvas's owner (via `runWithOwner`) rather than some short-lived per-element + * render-effect scope. Do not call this from anywhere else. + */ +export function installEngine(context: Context, raycasterOption?: RaycasterOption) { + if (engines.has(context)) return + // The engine's own raycaster — the bottom of the stack. An instance IS the ray strategy, + // so it's used as given; anything else is a config object applied to the engine's own + // `CursorRaycaster` — the raycaster `useRaycaster()` hands out until something is pushed + // over it. + let screenRaycaster: ScreenRaycaster + if (isScreenRaycaster(raycasterOption)) { + screenRaycaster = raycasterOption + } else { + screenRaycaster = new CursorRaycaster() + // `useProps` (rather than `Object.assign`) so the config object accepts the same + // shapes an element's props do — pierced paths like `params-Line-threshold`, + // array-to-`set` conversion, and so on. + if (raycasterOption) useProps(screenRaycaster, raycasterOption, context) + } + const engine = new PointerEventsEngine(context, screenRaycaster, raycasterOption) + engines.set(context, engine) + onCleanup(engine.connect()) +} + +/** + * A pure read: tells the caller whether a `pointerEvents()` instance's `raycaster` + * option is being silently ignored, without installing anything. Only the first + * instance to install on a given context (through `Context.initializePlugin`'s token + * dedup) ever actually configures the engine's raycaster — every later instance's + * `raycaster` option, if it differs, goes nowhere. This is the DEV-only warning for + * that case; it never mutates `engines`. + */ +export function warnOnIgnoredRaycaster(context: Context, raycasterOption?: RaycasterOption) { + if (!process.env.DEV) return + const installed = engines.get(context) + if (!installed) return + if (!raycasterOption) return + // Identity, not equality: the instance that installed passes the very option object it + // was constructed with, so it never warns about itself — while a second instance + // carrying its own (even identical-looking) option is genuinely being ignored. + if (raycasterOption === installed.raycasterOption) return + console.warn( + "S3: an engine is already installed on this canvas, so this pointerEvents() " + + "instance's `raycaster` option is ignored — the first instance to install wins. " + + "Configure the raycaster on that instance instead.", + ) +} + +function isScreenRaycaster(value: unknown): value is ScreenRaycaster { + return typeof value === "object" && value !== null && "setCursor" in value && "cast" in value +} diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 00000000..7acd24a5 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,195 @@ +import { onCleanup, type Accessor } from "solid-js" +import { Object3D } from "three" +import { useThree } from "../hooks.ts" +import { plugin } from "../plugin.ts" +import type { Context, Plugin } from "../types.ts" +import { getMeta } from "../utils.ts" +import { getEngine, installEngine, warnOnIgnoredRaycaster, type RaycasterOption } from "./engine.ts" +import type { DispatchEvent } from "./pointers.ts" +import type { ScreenRaycaster } from "./raycasters.ts" +import type { EventHandlers, EventName, PointerEventMethods } from "./types.ts" + +export type { RaycasterOption } from "./engine.ts" +export { createThreeEvent, Pointer, type PointerEngine, type PointerRaycaster } from "./pointers.ts" +export { hasPointerCapture } from "./pointer-capture.ts" +export * from "./raycasters.ts" +export type { + EventHandlers, + EventHandlersMap, + EventName, + PointerCapture, + PointerEventMethods, + ThreeEvent, +} from "./types.ts" + +/** Stable per-engine identity. Module-level: two `pointerEvents()` instances install once. */ +const POINTER_EVENTS_TOKEN = Symbol("solid-three/events") + +/** + * What {@link pointerEvents} returns. The `canvas` static has to be spelled out + * concretely (rather than left to the optional, `Record`-typed one on `PluginStatics`): + * `CanvasPropsOf` reads the contributed canvas props off exactly this signature, and a + * plain `Plugin<…>` return type would erase it — `` would then + * be an unknown prop. + */ +export type PointerEventsPlugin = Plugin<(element: Object3D) => PointerEventMethods> & { + canvas: (context: Context) => { + onPointerMissed: (handler: EventHandlers["onPointerMissed"]) => void + } +} + +const EVENT_NAMES = [ + "onClick", + "onDoubleClick", + "onContextMenu", + "onPointerUp", + "onPointerDown", + "onPointerMove", + "onPointerEnter", + "onPointerLeave", + "onWheel", + "onPointerMissed", +] as const satisfies readonly EventName[] + +/** + * The r3f-faithful pointer engine. Install it into a namespace to give its elements + * pointer handlers: `createT(THREE, [pointerEvents()])`. Without it, `onClick` is not + * a prop of `T.Mesh` — a compile error, not a silently dead handler. + * + * The canvas-level `onPointerMissed` (the void signal, fired only on a total miss) is + * contributed to `` — so the engine must also be listed there, either with + * `` or through `createT.withCanvas`. + * + * The engine owns the raycaster: core has none. Configure it here, and at runtime read, + * mutate or override it with {@link useRaycaster}. + * + * @param options.raycaster - How this canvas picks. Either a whole ray strategy — a + * `ScreenRaycaster` instance such as `new CenterRaycaster()` for gaze input — or a plain + * config object of raycaster properties (`{ far: 10, near: 1 }`) applied to the engine's + * own `CursorRaycaster`. Defaults to an unconfigured `CursorRaycaster`. The option is + * read once, at install, and sets the engine's own raycaster — the bottom of the + * raycaster stack; at runtime {@link useRaycaster} mutates it or pushes over it. Only the first + * `pointerEvents()` instance to install on a given canvas actually configures the engine + * — engine state is per-canvas, not per-instance. If several instances are in play (e.g. + * one listed on `` and another on the namespace via + * `createT(THREE, [...])`), configure the raycaster on the instance you list on + * ``: that's the one that installs, and every later instance's + * `raycaster` option is silently ignored (a DEV warning fires when it differs, but only + * when the ignored instance is also listed on `` — a namespace-only + * instance's option can't be observed at all). + */ +export function pointerEvents(options?: { raycaster?: RaycasterOption }): PointerEventsPlugin { + const base = plugin([Object3D], (object: Object3D) => { + const methods = {} as Record void> + for (const name of EVENT_NAMES) { + // A handler prop only ever enters the object into the registry — the handler + // itself is read back off `getMeta(object).props` at dispatch time, so a + // reactive swap needs no re-registration. + methods[name] = (handler: EventHandlers[EventName]) => { + // The prop's runtime value is whatever the user passed — often a signal read + // that is momentarily undefined — so an absent handler simply doesn't register. + if (typeof handler !== "function") return + const context = getMeta(object)?.ctx + if (!context) return + const engine = getEngine(context) + if (!engine) return + onCleanup(engine.register(object, name)) + } + } + return methods + }) + + return Object.assign(base, { + token: POINTER_EVENTS_TOKEN, + install: (context: Context) => installEngine(context, options?.raycaster), + canvas: (context: Context) => { + // A pure read: `install` (and the engine it sets up) is reached only through + // `Context.initializePlugin`, which dedups by `token` — shared across every + // `pointerEvents()` instance — so a second instance's `install` closure never + // runs. Canvas-prop resolution, by contrast, runs unconditionally for every + // plugin listed on ``, so it's the one place a second instance's + // `raycaster` option is reachable — hence the warning lives here, not in + // `installEngine`. + warnOnIgnoredRaycaster(context, options?.raycaster) + return { + onPointerMissed: (handler: EventHandlers["onPointerMissed"]) => { + const engine = getEngine(context) + if (!engine) return + // As above: the prop's runtime value can be undefined (an unset canvas prop), + // which clears the handler rather than installing a non-function. + // + // The cast bridges the dispatcher's internal event to the public one. The + // `Pointer` builds a `DispatchEvent`, whose `nativeEvent` is typed as the + // widest `Event` because one dispatcher serves every gesture; the miss is only + // ever fired from the click family, so the `MouseEvent` the handler declares is + // what actually arrives. + engine.onPointerMissed = + typeof handler === "function" + ? (handler as unknown as (event: DispatchEvent) => void) + : undefined + }, + } + }, + }) +} + +/** What {@link useRaycaster} hands back: the raycaster stack's two halves. */ +export interface RaycasterHandle { + /** + * The raycaster this canvas picks with right now — the top of the stack. An ACCESSOR, so + * it stays live under destructuring and reactive in a tracking scope: it re-reads when a + * subtree pushes a raycaster or pops one. + */ + raycaster: Accessor + /** + * Push a raycaster: this canvas picks with it from here on. Returns a disposer that pops + * it, restoring whatever was underneath. The push is also tied to the calling owner, so + * an unmounting subtree restores the previous raycaster on its own. + */ + setRaycaster: (raycaster: ScreenRaycaster) => () => void +} + +/** + * Reach the raycaster this canvas picks with. It is a stack: the engine's own raycaster + * (from `pointerEvents({ raycaster })`, or the default `CursorRaycaster`) sits at the + * bottom, and a subtree can push its own over it. + * + * `raycaster()` is the top of the stack — the very object that picks, so mutating it + * changes what gets hit on the next event: + * + * ```tsx + * const { raycaster, setRaycaster } = useRaycaster() + * createEffect(() => (raycaster().far = reach())) + * ``` + * + * `setRaycaster(next)` pushes a whole ray strategy for a subtree and returns a disposer + * that pops it: + * + * ```tsx + * const restore = setRaycaster(new CenterRaycaster()) // this subtree picks by gaze + * onCleanup(restore) // and the previous raycaster comes back + * ``` + * + * Must be called under a `` that has a `pointerEvents()` engine installed — there + * is no raycaster otherwise, and handing back an inert one would silently do nothing. + * + * @throws If called outside a ``, or inside one with no engine installed. + */ +export function useRaycaster(): RaycasterHandle { + // Throws "S3: Hooks can only be used within the Canvas component!" outside a Canvas. + const context = useThree() + const engine = getEngine(context) + if (!engine) { + throw new Error( + "S3: useRaycaster() needs the pointer-events engine, which owns the raycaster — " + + "this Canvas has none installed. List it on the Canvas: " + + " (or use createT.withCanvas). An engine listed " + + "only on createT's namespace installs lazily, on the first element carrying a " + + "handler, so it may not exist yet when this hook runs.", + ) + } + return { + raycaster: () => engine.raycaster, + setRaycaster: raycaster => engine.setRaycaster(raycaster), + } +} diff --git a/src/pointer-capture.ts b/src/events/pointer-capture.ts similarity index 100% rename from src/pointer-capture.ts rename to src/events/pointer-capture.ts diff --git a/src/pointer-managers.ts b/src/events/pointer-managers.ts similarity index 83% rename from src/pointer-managers.ts rename to src/events/pointer-managers.ts index fceae036..189ce33b 100644 --- a/src/pointer-managers.ts +++ b/src/events/pointer-managers.ts @@ -1,7 +1,8 @@ +import type { Accessor } from "solid-js" 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" +import type { Context } from "../types.ts" +import { Pointer, type PointerCaptureRegistry, type PointerEngine } from "./pointers.ts" +import type { ScreenRaycaster } from "./raycasters.ts" type RayEvent = PointerEvent | MouseEvent | WheelEvent @@ -12,9 +13,14 @@ type RayEvent = PointerEvent | MouseEvent | WheelEvent * click/dblclick/contextmenu/wheel gestures — those are `MouseEvent`s with no * `pointerId`, so they don't belong to a specific touch. * - * It aims the (single, shared) screen raycaster from each event before calling - * the pointer's gesture method; that's safe because `setCursor` → `cast` runs - * synchronously within one event, so concurrent pointers never collide. + * It aims the shared screen raycaster from each event before calling the pointer's + * gesture method; that's safe because `setCursor` → `cast` runs synchronously within + * one event, so concurrent pointers never collide. + * + * The raycaster arrives as an ACCESSOR, not a value: which raycaster the canvas picks + * with is the top of the engine's raycaster stack, and a subtree can push a different + * one at any time. Aiming resolves it per event, and each `Pointer` gets the same + * accessor so its casts resolve it too. */ export class DOMPointerManager { private pointers = new Map() @@ -26,10 +32,11 @@ export class DOMPointerManager { constructor( private context: Context, - private raycaster: ScreenRaycaster, - private captureRegistry?: PointerCaptureRegistry, + private raycaster: Accessor, + private captureRegistry: PointerCaptureRegistry, + private engine: PointerEngine, ) { - this.primary = new Pointer(context, raycaster, undefined, captureRegistry) + this.primary = new Pointer(engine, raycaster, undefined, captureRegistry) } /** @@ -49,7 +56,7 @@ export class DOMPointerManager { if (!pointer) { const canvas = this.context.canvas pointer = new Pointer( - this.context, + this.engine, this.raycaster, { capture: () => canvas.setPointerCapture(id), @@ -72,7 +79,8 @@ export class DOMPointerManager { /** Attach all canvas listeners; returns a disconnect that removes them. */ connect(): () => void { const canvas = this.context.canvas - const aim = (event: RayEvent) => this.raycaster.setCursor(this.ndc(event)) + // Resolve the top of the raycaster stack per event — never a captured reference. + const aim = (event: RayEvent) => this.raycaster().setCursor(this.ndc(event)) const onMove = (event: PointerEvent) => { aim(event) diff --git a/src/pointers.ts b/src/events/pointers.ts similarity index 70% rename from src/pointers.ts rename to src/events/pointers.ts index 7d51b1ba..7f463309 100644 --- a/src/pointers.ts +++ b/src/events/pointers.ts @@ -1,6 +1,8 @@ +import type { Accessor } from "solid-js" import { Plane, Vector3, type Intersection, type Object3D, type Ray } from "three" -import type { Context, Meta, PointerCapture, Prettify } from "./types.ts" -import { getMeta } from "./utils.ts" +import type { Context, Meta, Prettify } from "../types.ts" +import { getMeta } from "../utils.ts" +import type { PointerCapture } from "./types.ts" /** * The dispatcher's writable view of an event while it's built and walked. Every @@ -25,18 +27,29 @@ 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 } +/** + * The slice of the engine a `Pointer` dispatches against. The registry lives on the + * engine — not on the `Context` — which is what lets several engines co-exist on one + * canvas: an object only ever enters the registry of the engine whose plugin + * contributed its handler prop. `onPointerMissed` is the canvas-level miss handler the + * engine received through its contributed canvas prop. + */ +export interface PointerEngine { + readonly context: Context + readonly registry: Object3D[] + readonly onPointerMissed: ((event: DispatchEvent) => void) | undefined +} + /** * Build the {@link DispatchEvent} for one dispatch from a native `MouseEvent` | * `PointerEvent` | `WheelEvent`, optionally merging a plugin source's typed `extra` @@ -106,27 +119,46 @@ interface Captured { /** * One pointer's dispatch + per-pointer state, decoupled from the DOM. A * `*PointerManager` owns the source (canvas / XR controller) and the raycaster, - * and calls these gesture methods; the `Pointer` raycasts the context's single - * `eventRegistry` and bubbles to the `onPointer*` / `onClick` / … handlers, - * tracking its own hover state so multiple pointers stay independent. + * and calls these gesture methods; the `Pointer` raycasts its engine's + * {@link PointerEngine.registry} and bubbles to the `onPointer*` / `onClick` / … + * handlers, tracking its own hover state so multiple pointers stay independent. * - * Dispatch logic is ported verbatim from the previous per-kind registries - * (`createHoverEventRegistry` / `createMissableEventRegistry` / - * `createDefaultEventRegistry`); the only changes are per-pointer instance state - * and the single `onPointer*` family (the redundant `onMouse*` family is gone). + * `propagate` is a pure chain walk — it does not fire any canvas-level handler. + * Only `click()`'s total-miss path fires a canvas-level handler (`onPointerMissed`). */ export class Pointer { private hovered = new Set() - private hoveredCanvas = false private captured: Captured | null = null constructor( - private context: Context, - private raycaster: PointerRaycaster, + private engine: PointerEngine, + /** + * The raycaster to pick with — either the raycaster itself, or an accessor for it (the + * same `T | Accessor` shape `Stack` takes). The DOM source passes an accessor for + * the top of the engine's raycaster stack, so a subtree that pushes a raycaster changes + * what this pointer hits; a source with one fixed ray strategy (an XR controller) passes + * the raycaster directly. + */ + private raycasterSource: PointerRaycaster | Accessor, private sink?: PointerCaptureSink, private captureRegistry?: PointerCaptureRegistry, ) {} + /** The three.js context this pointer dispatches in — the engine's. */ + private get context(): Context { + return this.engine.context + } + + /** + * The raycaster to cast/aim with right now. Resolved afresh at every use — never + * captured — so it always reflects the current top of the engine's raycaster stack. + */ + private get raycaster(): PointerRaycaster { + return typeof this.raycasterSource === "function" + ? this.raycasterSource() + : this.raycasterSource + } + /** Whether this pointer currently holds `object` captured. */ hasCaptured(object: Object3D): boolean { return this.captured?.object === object @@ -202,10 +234,13 @@ export class Pointer { * 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()) + // One resolution of the stack for the whole reprojection — aiming and reading the ray + // must both act on the same raycaster. + const raycaster = this.raycaster + raycaster.aim(this.context) + const point = raycaster.ray.intersectPlane(captured.plane, new Vector3()) if (!point) return captured.intersection - const distance = this.raycaster.ray.origin.distanceTo(point) + const distance = raycaster.ray.origin.distanceTo(point) return { ...captured.intersection, point, distance } } @@ -245,8 +280,7 @@ export class Pointer { // 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 + const intersections = this.raycaster.cast(this.engine.registry, this.context) // Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects). const enterEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) @@ -261,10 +295,6 @@ export class Pointer { current = current.parent } } - if (!this.hoveredCanvas) { - this.hoveredCanvas = true - props.onPointerEnter?.(enterEvent) - } // Phase #2 — Move (bubble up, stoppable). Capturable: a handler may start a // drag by calling `setPointerCapture()` from here. @@ -292,8 +322,6 @@ export class Pointer { /** The pointer left the canvas/source: leave everything currently hovered. */ leave(nativeEvent: Event) { const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false }) - ;(this.context.props as Record).onPointerLeave?.(leaveEvent) - this.hoveredCanvas = false for (const object of this.hovered) (getMeta(object)?.props as any)?.onPointerLeave?.(leaveEvent) this.hovered.clear() } @@ -312,11 +340,11 @@ export class Pointer { * 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`. + * `stopPropagation`. 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`. */ private propagate(event: DispatchEvent, handler: string, roots: Array<[Intersection, Object3D]>) { const visited = new Set() @@ -331,23 +359,19 @@ export class Pointer { } if (event.stopped) break } - if (!event.stopped) { - delete event.currentIntersection - event.currentObject = undefined - ;(this.context.props as Record)[handler]?.(event) - } + delete event.currentIntersection + event.currentObject = undefined } /** * 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. + * `stopPropagation`. `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); the intersection is the + * live ray reprojected onto the captured plane. */ dispatch( handler: string, @@ -366,7 +390,7 @@ export class Pointer { return } - const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) + const intersections = this.raycaster.cast(this.engine.registry, this.context) const event = createThreeEvent(nativeEvent, { intersections }, extra) if (capturable) this.attachCapture(event) this.propagate( @@ -379,52 +403,47 @@ export class Pointer { ) } - /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ + /** + * Click-family gesture: bubble `onClick`/`onDoubleClick`/`onContextMenu` down the hit + * chain, then fire `onPointerMissed` on every registered object the click did NOT land + * on — r3f's per-object "not-me". "Landed on" means hit, or an ancestor of a hit (its + * subtree was hit); the missed set is the registry minus that hit-closure, computed from + * the raycast and so independent of `stopPropagation` — a stopped bubble can't reclassify + * an ancestor as missed (no Surprise B). The canvas-level `onPointerMissed` is the void + * signal: it fires only on a total miss (the empty-space / deselect case). + */ 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 + const registry = this.engine.registry + if (registry.length === 0 && !this.engine.onPointerMissed) 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 1 — bubble the gesture down the hit chain (no canvas-level fire). + this.propagate( + event, + kind, + intersections.map((intersection): [Intersection, Object3D] => [ + intersection, + intersection.object, + ]), + ) - // 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 2 — the miss. Collect the hit-closure (each hit and all its ancestors), then + // fire `onPointerMissed` on every registered object outside it — the "not-me". This is + // geometry-based, so `stopPropagation` never widens the missed set. Canvas-level fires + // only on a total miss. + const hitClosure = new Set() + for (const { object } of intersections) { + let node: Object3D | null = object + while (node && !hitClosure.has(node)) { + hitClosure.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) + for (const object of registry) + if (!hitClosure.has(object)) (getMeta(object)?.props as any)?.onPointerMissed?.(missedEvent) + if (intersections.length === 0) this.engine.onPointerMissed?.(missedEvent) } } diff --git a/src/raycasters.tsx b/src/events/raycasters.ts similarity index 97% rename from src/raycasters.tsx rename to src/events/raycasters.ts index 81c55867..bd27c21a 100644 --- a/src/raycasters.tsx +++ b/src/events/raycasters.ts @@ -1,6 +1,6 @@ import { Quaternion, Raycaster, Vector2, Vector3, type Intersection, type Object3D } from "three" -import type { Context, Meta } from "./types.ts" -import { getMeta } from "./utils.ts" +import type { Context, Meta } from "../types.ts" +import { getMeta } from "../utils.ts" const CENTER = new Vector2(0, 0) diff --git a/src/events/types.ts b/src/events/types.ts new file mode 100644 index 00000000..5d189d23 --- /dev/null +++ b/src/events/types.ts @@ -0,0 +1,109 @@ +import type { Intersection, Object3D, Vector3 as ThreeVector3 } from "three" +import type { Intersect, Prettify, When } from "../types.ts" + +/**********************************************************************************/ +/* */ +/* Event */ +/* */ +/**********************************************************************************/ + +export type ThreeEvent< + TEvent, + TConfig extends { stoppable?: boolean; intersections?: boolean } = { + stoppable: true + intersections: true + }, +> = Intersect< + [ + { + 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"], + { + stopped: boolean + stopPropagation: () => void + } + >, + When< + TConfig["intersections"], + { + 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 +} + +export type EventHandlersMap = { + onClick: Prettify> + onDoubleClick: Prettify> + onContextMenu: Prettify> + onPointerUp: Prettify & PointerCapture> + onPointerDown: Prettify & PointerCapture> + onPointerMove: Prettify & PointerCapture> + onPointerEnter: Prettify> + onPointerLeave: Prettify> + onWheel: Prettify> + // The miss. Object-level fires on every registered object the click did not land on; + // canvas-level fires only on a total miss. Non-stoppable, no intersection payload. + onPointerMissed: Prettify> +} + +/** The handler a user writes for each event, keyed by its prop name. */ +export type EventHandlers = { + [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void +} + +/** + * What the engine's plugin contributes to each `Object3D`: one method per event + * name, taking the user's handler as its prop value. A contributed method's + * first-param type becomes the element's prop type, so this is what makes + * `onClick` a prop of `T.Mesh` — and only when the engine is installed. + */ +export type PointerEventMethods = { + [TKey in keyof EventHandlersMap]: (handler: EventHandlers[TKey]) => void +} + +/** The names of all `EventHandlers` */ +export type EventName = keyof EventHandlersMap diff --git a/src/index.ts b/src/index.ts index aa2a9ec8..3f72b14f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,21 @@ 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 { createThreeEvent, Pointer, type PointerRaycaster } from "./pointers.ts" export { useProps } from "./props.ts" -export * from "./raycasters.tsx" export * as S3 from "./types.ts" // Direct re-exports of types that users commonly need at the top level. -// `Register` is augmentable from `declare module "solid-three"` (see its -// JSDoc); `SupportedRenderer` and `ResolvedRenderer` show up in advanced typings. -export type { Plugin, Register, ResolvedRenderer, SupportedRenderer } from "./types.ts" +// `Context`, `Plugin`, `PluginStatics` and `FrameListener` are the plugin contract: every +// `install(context)` / `canvas(context)` signature an engine author writes names `Context`, +// and a frame-driven engine names `FrameListener`. `Register` is augmentable from +// `declare module "solid-three"` (see its JSDoc); `SupportedRenderer` and +// `ResolvedRenderer` show up in advanced typings. +export type { + Context, + FrameListener, + Plugin, + PluginStatics, + Register, + ResolvedRenderer, + SupportedRenderer, +} from "./types.ts" export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts" diff --git a/src/internal-context.ts b/src/internal-context.ts index 340bf5a9..a36741a7 100644 --- a/src/internal-context.ts +++ b/src/internal-context.ts @@ -1,23 +1,4 @@ import { type JSX, createContext, useContext } from "solid-js" -import { Object3D } from "three" -import type { EventName, Meta } from "./types.ts" - -/** - * Registers an event listener for an `AugmentedElement` to the nearest Canvas component up the component tree. - * This function must be called within components that are descendants of the Canvas component. - * - * @param object - The Three.js object to attach the event listener to. - * @param type - The type of event to listen for (e.g., 'click', 'mouseenter'). - * @throws Throws an error if used outside of the Canvas component context. - */ -export const addToEventListeners = (object: Meta, type: EventName) => { - const addToEventListeners = useContext(eventContext) - if (!addToEventListeners) { - throw new Error("S3: Hooks can only be used within the Canvas component!") - } - return addToEventListeners(object, type) -} -export const eventContext = createContext<(object: Meta, type: EventName) => () => void>() /** * This function facilitates the rendering of JSX elements outside the normal scene diff --git a/src/plugin.ts b/src/plugin.ts index da46a21c..fa6f8ff7 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -42,6 +42,11 @@ export function resolvePluginMethods( const result = (plugin as (el: object) => Record | undefined)(element) if (!result) continue for (const key in result) { + if (process.env.DEV && key in merged) { + console.warn( + `S3: two plugins contribute the prop "${key}" — the last one wins. Rename one of them if both were meant to run.`, + ) + } const descriptor = Object.getOwnPropertyDescriptor(result, key) if (descriptor?.get || descriptor?.set) Object.defineProperty(merged, key, descriptor) else merged[key] = result[key] diff --git a/src/props.ts b/src/props.ts index 5d8ae0f9..7b8fc44b 100644 --- a/src/props.ts +++ b/src/props.ts @@ -10,15 +10,12 @@ import { untrack, } from "solid-js" import { Color, type Object3D, RGBAFormat, Texture, UnsignedByteType } from "three" -import { isEventType } from "./create-events.ts" import { useThree } from "./hooks.ts" -import { addToEventListeners } from "./internal-context.ts" import { resolvePluginMethods } from "./plugin.ts" import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" import { getMeta, hasColorSpace, - hasMeta, isBufferGeometry, isFog, isMaterial, @@ -193,8 +190,8 @@ const NEEDS_UPDATE = [ ] /** - * Applies a specified property value to an `AugmentedElement`. This function handles nested properties, - * automatic updates of the `needsUpdate` flag, color space conversions, and event listener management. + * Applies a specified property value to an `AugmentedElement`. This function handles plugin-contributed + * props, nested properties, automatic updates of the `needsUpdate` flag, and color space conversions. * It efficiently manages property assignments with appropriate handling for different data types and structures. * * @param source - The target object for property application. @@ -255,21 +252,6 @@ function applyProp>( } } - if (isEventType(type)) { - if (isObject3D(source) && hasMeta(source)) { - const cleanup = addToEventListeners(source, type) - onCleanup(cleanup) - } else { - console.error( - "Event handlers can only be added to Three elements extending from Object3D. Ignored event-type:", - type, - "from element", - source, - ) - } - return - } - const target = source[type] try { @@ -355,7 +337,7 @@ const EMPTY_METHODS: Record void> = {} export function useProps>( accessor: T | undefined | Accessor, props: any, - context: Pick = useThree(), + context: Context = useThree(), plugins: Plugin[] = [], ) { const [local, instanceProps] = splitProps(props, ["ref", "args", "object", "attach", "children"]) @@ -381,12 +363,18 @@ export function useProps>( // keeps plugin resolution off the per-element hot path (see plugin-system spec). let pluginMethods = EMPTY_METHODS if (plugins.length) { + for (const plugin of plugins) { + if (!plugin.install) continue + const token = plugin.token ?? plugin + context.initializePlugin(token, () => plugin.install?.(context)) + } + pluginMethods = resolvePluginMethods(object, plugins) // Give plugin code the mount-site context via getMeta(element).ctx. Set at // creation (here), not attach: contributed methods run during applyProp, before // a top-level element attaches to the scene. Gated, so no-plugin elements pay nothing. const childMeta = getMeta(object) - if (childMeta) childMeta.ctx = context as Context + if (childMeta) childMeta.ctx = context } // Apply the props to THREE-instance diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 3c7778bf..993551d8 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,7 @@ import { type Accessor, type JSX, createRoot, mergeProps } from "solid-js" import type { CanvasProps } from "../canvas.tsx" import { createThree } from "../create-three.tsx" +import type { CanvasPropsOf, Plugin } from "../types.ts" import { useRef } from "../utils.ts" const activeUnmounts = new Set<() => void>() @@ -21,6 +22,10 @@ export function cleanup() { * Initializes a testing environment for `solid-three`. Designed to run in a * real browser (e.g. vitest browser mode). jsdom is not supported. * + * Accepts the same props as ``, `plugins` included — so an engine's + * contributed canvas props (e.g. `onPointerMissed` from `pointerEvents()`) are typed + * and delivered here exactly as they are on the real component. + * * @param children - An accessor for the `AugmentedElement` to render. * @param [props] - Optional properties to configure canvas. * @returns `S3.Context` augmented with methods to unmount the scene and to wait for the next animation frame. @@ -30,9 +35,9 @@ export function cleanup() { * await testScene.waitTillNextFrame(); * testScene.unmount(); */ -export function test( +export function test( children: Accessor, - props?: Omit, + props?: Omit, "children"> & Partial>, ): TestApi { const canvas = createTestCanvas() let context: ReturnType = null! @@ -62,7 +67,7 @@ export function test( }, }, props, - ), + ) as CanvasProps, ) }) @@ -87,13 +92,15 @@ type TestApi = ReturnType & { * @example * render(() => ); */ -export function TestCanvas(props: CanvasProps) { +export function TestCanvas( + props: CanvasProps & Partial>, +) { const canvas = createTestCanvas() const container = (
{canvas}
) as HTMLDivElement - const three = createRoot(() => createThree(canvas, props)) + const three = createRoot(() => createThree(canvas, props as CanvasProps)) useRef(props, three) return container diff --git a/src/types.ts b/src/types.ts index 34347ff7..b7b0bea1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,9 @@ import type { Accessor, JSX, Owner } from "solid-js" import type { Clock, ColorRepresentation, - Intersection, Loader, - Object3D, OrthographicCamera, PerspectiveCamera, - Raycaster, Scene, Color as ThreeColor, Euler as ThreeEuler, @@ -23,7 +20,6 @@ import type { import type { WebGPURenderer } from "three/webgpu" import type { CanvasProps } from "./canvas.tsx" import type { $S3C } from "./constants.ts" -import type { EventRaycaster } from "./raycasters.tsx" import type { Measure } from "./utils/use-measure.ts" /**********************************************************************************/ @@ -242,11 +238,29 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( : never /** - * A composable extension: a function `(element) => methods`. A contributed - * method's first-param type becomes the element's prop type (see {@link PluginPropsOf}). + * Optional statics an engine-style plugin attaches to its function. `plugin()` does not + * add them; the engine does, via `Object.assign`. + */ +export interface PluginStatics { + /** + * Stable per-engine identity — a MODULE-LEVEL symbol, never the plugin instance. + * Two instances of the same engine share one installation per context. Using the + * instance here double-installs and double-dispatches. + */ + token?: symbol + /** One-time per-context setup, run through `Context.initializePlugin` under the canvas owner. */ + install?: (context: Context) => void + /** Canvas-level contributed props: prop name -> method receiving the prop value. */ + canvas?: (context: Context) => Record void> +} + +/** + * A composable extension: a function `(element) => methods`, optionally carrying + * {@link PluginStatics}. A contributed method's first-param type becomes the element's + * prop type (see {@link PluginPropsOf}). * Created via {@link PluginFn} (`plugin()`); a non-matching element yields `undefined`. */ -export type Plugin any> = TFn +export type Plugin any> = TFn & PluginStatics /** The three `plugin()` creation forms: global, class-filtered, type-guard. */ export interface PluginFn { @@ -263,11 +277,39 @@ export interface PluginFn { ): Plugin<(element: T) => Methods> } +/** + * `true` iff `T` is exactly `any` (the only type that is assignable both to and from + * `0 extends 1 & T` — a literal type, a union, `unknown`, and `never` all fail this + * check). Used to detect when {@link PluginReturn} bottoms out at `any` because the + * loose `Plugin` default (`TFn = (element: any) => any`) was never narrowed by a real + * plugin tuple — see {@link PluginPropsOf}. + */ +type IsAny = 0 extends 1 & T ? true : false + +/** + * `true` iff `Methods` is keyed by an INDEX SIGNATURE rather than by known keys — i.e. it + * names no prop in particular. The sibling of {@link IsAny}, and it guards the same hole + * from the other side: mapping over `Record void>` produces + * `{ [x: string]: any }`, which accepts EVERY prop and silently drops it, since the plugin + * only ever acts on the keys it really contributes. + * + * That loose type is not hypothetical: it is what {@link PluginStatics} itself declares for + * `canvas`, so an engine author who annotates against the published contract lands on it. + * (`pointerEvents()` avoids it by spelling its `canvas` static out concretely — see + * `PointerEventsPlugin`.) Yielding `{}` here means a loose plugin contributes NOTHING + * rather than EVERYTHING, so an unknown prop stays the compile error this boundary exists + * to produce. `any` also lands here (`keyof any` is `string | number | symbol`), which + * makes this a superset of {@link IsAny} for the mapped-type sites. + */ +type IsLoose = string extends keyof Methods ? true : false + type PluginReturn = TPlugin extends Plugin ? TFn extends { (element: infer TElement): infer TReturnType } ? TKind extends TElement - ? TReturnType + ? IsAny extends true + ? {} + : TReturnType : {} : {} : {} @@ -277,16 +319,14 @@ type PluginReturn = * of the same name (a plugin intercepts the prop at runtime, so its type must replace the * native one, not intersect with it — `nativeMethod & V` is satisfiable by nothing). * - * Guarded for the no-plugins case: when no specific plugins are inferred, `TPlugins` is the - * loose default `readonly Plugin[]` whose `length` is `number` (not a literal). There's no - * contribution to key off, and `keyof PluginPropsOf<…, Plugin[]>` would be every key — so - * yield `never` (drop nothing). Only a real inferred tuple (literal `length`, e.g. from an - * inline or `const` plugin array) contributes keys. A pre-typed `Plugin[]` variable still - * reads as loose, so its overrides fall back to the (harmless) intersection. + * No no-plugins guard needed here: with the loose default `readonly Plugin[]`, + * `PluginPropsOf` itself resolves to `{}` (see its doc comment and {@link IsAny}), so + * `keyof PluginPropsOf<…, Plugin[]>` is already `never` — nothing to drop. */ -type ContributedKeys = number extends TPlugins["length"] - ? never - : keyof PluginPropsOf, TPlugins> +type ContributedKeys = keyof PluginPropsOf< + InstanceOf, + TPlugins +> /** * An element's full prop type: its base {@link BaseProps} with the props contributed by @@ -302,14 +342,51 @@ export type Props = Omit< > & Partial, TPlugins>> -/** Resolves the contributed props for element type `TKind` across `TPlugins`. */ +/** + * Resolves the contributed props for element type `TKind` across `TPlugins`. + * + * Handles the no-plugins case at the root cause rather than by guarding on + * `TPlugins["length"]`: with the loose default `readonly Plugin[]`, `Plugin`'s own + * default (`TFn = (element: any) => any`) makes {@link PluginReturn} resolve to `any` + * for every plugin in the tuple. Without {@link IsAny}, a mapped type over `any` + * degrades into an index signature that swallows EVERY prop — so `createT(THREE)` + * would accept `onClick` (and any typo) and then silently drop it, since no engine is + * installed to act on it. That is precisely the dead-handler failure the event + * boundary exists to turn into a compile error. `IsAny` catches that `any` inside + * `PluginReturn` and yields `{}` there instead, so this type falls out to `{}` too — + * no plugins inferred, no contributed props. + * + * A length guard would additionally reject the ordinary *hoisted* case + * (`const plugins = [pointerEvents()]; createT(THREE, plugins)`), because a `const` + * array's inferred type still has a `number` `length` even though its elements are a + * literal tuple. Fixing this at `PluginReturn` avoids that regression. + * + * The one remaining trade-off: a plugin list passed as a pre-typed `Plugin[]` + * variable (rather than an inline/hoisted array) has no concrete `TFn` to narrow + * from, so its contributed props are not typed and must be passed through a + * namespace whose plugins TypeScript can actually see. Runtime is unaffected either + * way. + */ export type PluginPropsOf = UnionToIntersection< { [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< string, any > - ? { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } + ? IsLoose extends true + ? {} + : { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } + : {} + }[number] +> + +/** Resolves the canvas-level contributed props across `TPlugins`. */ +export type CanvasPropsOf = UnionToIntersection< + { + [K in keyof TPlugins]: TPlugins[K] extends { canvas: (context: any) => infer Methods } + ? IsLoose extends true + ? {} + : { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } : {} }[number] > @@ -324,12 +401,14 @@ export interface Context { * XR plugin wiring its controller source on the first `onXRSelect` registration. */ initializePlugin(token: unknown, fn: () => void): void + /** + * Subscribe to the frame loop. The engine-facing half of `useFrame`: a plugin has + * no Solid context, so the loop must be reachable from `Context` itself. + */ + addFrameListener: FrameListener canvas: HTMLCanvasElement clock: Clock camera: CameraKind - /** Objects carrying any pointer handler; raycast by the pointer system. */ - eventRegistry: Object3D[] - raycaster: Raycaster | EventRaycaster dpr: number gl: Meta props: CanvasProps @@ -337,7 +416,6 @@ export interface Context { requestRender: () => void scene: Meta setCamera(camera: CameraKind): () => void - setRaycaster(camera: Raycaster): () => void viewport: Viewport } @@ -363,108 +441,17 @@ export type FrameListener = ( /**********************************************************************************/ /* */ -/* Event */ +/* Type Helpers */ /* */ /**********************************************************************************/ +/** + * `U` unless `T` is exactly `false` — the switch a config flag flips. Used by the + * event engine's `ThreeEvent` to add or drop whole slices of the event shape; it + * lives here because it is a plain type utility, not an event concept. + */ export type When = T extends false ? (T extends true ? U : unknown) : U -export type ThreeEvent< - TEvent, - TConfig extends { stoppable?: boolean; intersections?: boolean } = { - stoppable: true - intersections: true - }, -> = Intersect< - [ - { - 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"], - { - stopped: boolean - stopPropagation: () => void - } - >, - When< - TConfig["intersections"], - { - 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> - onDoubleClick: Prettify> - onDoubleClickMissed: Prettify> - onContextMenu: Prettify> - onContextMenuMissed: Prettify> - onPointerUp: Prettify & PointerCapture> - onPointerDown: Prettify & PointerCapture> - onPointerMove: Prettify & PointerCapture> - onPointerEnter: Prettify> - onPointerLeave: Prettify> - onWheel: Prettify> -} - -export type EventHandlers = { - [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void -} - -export type CanvasEventHandlers = { - [TKey in keyof EventHandlersMap]: ( - event: Prettify>, - ) => void -} - -/** The names of all `EventHandlers` */ -export type EventName = keyof EventHandlersMap - /**********************************************************************************/ /* */ /* Solid Three Representation */ @@ -526,16 +513,20 @@ export type MapToRepresentation = { } /** - * Generic `solid-three` props of a given class. Plugin-contributed props are NOT - * baked in here — they're intersected directly at the composition sites (`createT` - * proxy + ``) via {@link PluginPropsOf}, which keeps `TPlugins` inferable at - * those sites (burying it in this `Overwrite` defeats inference — see notes). + * Generic `solid-three` props of a given class. Pointer handlers are NOT here: core + * ships no event engine, so `onClick` is a prop of `T.Mesh` only once an engine + * contributes it (`createT(THREE, [pointerEvents()])`) — without one it is a compile + * error, not a silently dead handler. + * + * Plugin-contributed props are likewise not baked in here — they're intersected + * directly at the composition sites (`createT` proxy + ``) via + * {@link PluginPropsOf}, which keeps `TPlugins` inferable at those sites (burying it + * in this `Overwrite` defeats inference — see notes). */ export type BaseProps = Partial< Overwrite< [ MapToRepresentation>, - EventHandlers, { args: T extends Constructor ? ConstructorOverloadParameters : undefined attach: string | ((parent: object, self: Meta>) => () => void) diff --git a/tests/core/api-coverage.test.tsx b/tests/core/api-coverage.test.tsx index a96284a5..8fa3c5bd 100644 --- a/tests/core/api-coverage.test.tsx +++ b/tests/core/api-coverage.test.tsx @@ -4,8 +4,6 @@ import { afterEach, assertType, beforeEach, describe, expect, it, vi } from "vit import { createEntity, createT, - CursorRaycaster, - CenterRaycaster, getMeta, hasMeta, load, @@ -13,6 +11,7 @@ import { Resource, useProps, } from "../../src/index.ts" +import { CenterRaycaster, CursorRaycaster, pointerEvents } from "../../src/events/index.ts" import { LoaderCache } from "../../src/data-structure/loader-cache.ts" import { useLoader } from "../../src/hooks.ts" import { test } from "../../src/testing/index.tsx" @@ -185,7 +184,8 @@ describe("CursorRaycaster / CenterRaycaster", () => { describe("pointer capture types", () => { it("exposes pointer-capture methods on pointer events", () => { - const _T = createT(THREE) + // `onPointerDown` is a prop of `T.Mesh` only because the engine contributes it. + const _T = createT(THREE, [pointerEvents()]) type MeshProps = Parameters[0] const onPointerDown: NonNullable = event => { assertType<() => void>(event.setPointerCapture) diff --git a/tests/core/boundary.test.tsx b/tests/core/boundary.test.tsx new file mode 100644 index 00000000..04e2dc55 --- /dev/null +++ b/tests/core/boundary.test.tsx @@ -0,0 +1,429 @@ +import { render } from "@solidjs/testing-library" +import { onCleanup } from "solid-js" +import { Object3D, Raycaster, Vector2 } from "three" +import * as THREE from "three" +import { assertType, describe, expect, it, vi } from "vitest" +import { Canvas } from "../../src/canvas.tsx" +import { createT } from "../../src/create-t.tsx" +import { pointerEvents } from "../../src/events/index.ts" +import type { EventHandlers } from "../../src/events/types.ts" +import { useThree } from "../../src/hooks.ts" +import { plugin } from "../../src/plugin.ts" +import { test as renderThree } from "../../src/testing/index.tsx" +import type { Context, Plugin } from "../../src/types.ts" +import { clickCanvasCentre } from "../utils/pointer-utils.ts" + +/** + * A minimal fake engine: records the canvas-level prop it was handed, AND keeps its + * own registry of elements that opted in via its contributed `onFakeClick` prop. + * + * The registry is genuinely private to each `fakeEngine(...)` call — a fresh `Set` + * closed over by both `install` and the per-element method, never shared with any + * other engine (fake or real). On a real click on the canvas, `install` raycasts + * ONLY that registry — never the whole scene — and calls `onRegistryHit` with the hit + * object. That is the mechanism the "disjoint provenance" suite exercises: it proves + * two engines installed on the same canvas never see each other's registered objects. + * + * `onRegistryHit` defaults to `received` (the canvas-props-channel tests only care + * about that one); pass it explicitly when a test needs to tell a genuine registry hit + * apart from the canvas-prop channel's own unconditional mount-time call (every + * contributed canvas prop is fed its current value once at mount, `undefined` if the + * consumer never passed it — see `create-three.tsx`'s per-plugin `createRenderEffect`). + */ +function fakeEngine( + name: string, + received: (value: unknown) => void, + onRegistryHit: (object: Object3D) => void = received, +) { + const registry = new Set() + return Object.assign( + plugin([Object3D], (object: Object3D) => ({ + onFakeClick: (handler: unknown) => { + if (typeof handler !== "function") return + registry.add(object) + onCleanup(() => registry.delete(object)) + }, + })), + { + token: Symbol(name), + install: (context: Context) => { + const onClick = (event: MouseEvent) => { + const { width, height } = context.bounds + const ndc = new Vector2( + (event.offsetX / width) * 2 - 1, + -(event.offsetY / height) * 2 + 1, + ) + const raycaster = new Raycaster() + raycaster.setFromCamera(ndc, context.camera) + const hits = raycaster.intersectObjects([...registry], false) + if (hits.length > 0) onRegistryHit(hits[0].object) + } + context.canvas.addEventListener("click", onClick) + onCleanup(() => context.canvas.removeEventListener("click", onClick)) + }, + canvas: (_context: Context) => ({ + onFakeMissed: (value: unknown) => received(value), + }), + }, + ) +} + +/** + * A SECOND engine whose contributed surface is spelled differently from `fakeEngine`'s: + * canvas prop `onOtherMissed`, element prop `onOtherClick`. The distinct spelling is the + * whole point — two `fakeEngine(...)` instances have identical structural types, so an + * override with one typechecks against a `Canvas` whose `plugins` prop is pinned to the + * bound tuple even when overriding is not actually supported. Only an engine of a + * genuinely different type discriminates. + */ +function otherEngine(name: string, received: (value: unknown) => void) { + return Object.assign( + plugin([Object3D], () => ({ + onOtherClick: (_handler: unknown) => {}, + })), + { + token: Symbol(name), + canvas: (_context: Context) => ({ + onOtherMissed: (value: unknown) => received(value), + }), + }, + ) +} + +describe("canvas-props channel", () => { + it("delivers a contributed canvas prop to the engine that declared it", async () => { + const received = vi.fn() + const engine = fakeEngine("a", received) + const handler = () => {} + + render(() => ( + + {null} + + )) + await Promise.resolve() + + expect(received).toHaveBeenCalledWith(handler) + }) + + it("warns and last-wins when two engines contribute the same canvas prop", async () => { + const first = vi.fn() + const second = vi.fn() + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const handler = () => {} + + render(() => ( + + {null} + + )) + await Promise.resolve() + + expect(second).toHaveBeenCalledWith(handler) + expect(first).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining("onFakeMissed")) + warn.mockRestore() + }) +}) + +describe("createT.withCanvas", () => { + it("returns a Canvas with the plugins pre-bound", async () => { + const received = vi.fn() + const engine = fakeEngine("bound", received) + const handler = () => {} + + const { T, Canvas: BoundCanvas } = createT.withCanvas(THREE, [engine]) + + render(() => ( + + + + )) + await Promise.resolve() + + expect(received).toHaveBeenCalledWith(handler) + }) + + // Overriding with a DIFFERENT engine, not a second instance of the bound one: the + // `plugins` prop has to be typed by what the caller passes, not pinned to the bound + // tuple, or `onOtherMissed` below is an unknown prop (TS2322) — which is exactly the + // shape of the bug an override test written with two `fakeEngine(...)`s cannot see. + it("lets an explicit plugins prop replace the pre-bound default with a different engine", async () => { + const boundReceived = vi.fn() + const overrideReceived = vi.fn() + const boundEngine = fakeEngine("bound", boundReceived) + const override = otherEngine("override", overrideReceived) + const handler = () => {} + + const { T, Canvas: BoundCanvas } = createT.withCanvas(THREE, [boundEngine]) + + render(() => ( + + + + )) + await Promise.resolve() + + // The override's engine gets the prop it declared… + expect(overrideReceived).toHaveBeenCalledWith(handler) + // …and the bound default is not installed at all: were it installed, its own + // contributed canvas prop would have been fed its (absent) value at mount. + expect(boundReceived).not.toHaveBeenCalled() + }) + + it("lets an explicit plugins prop extend the pre-bound default with another engine", async () => { + const boundReceived = vi.fn() + const extraReceived = vi.fn() + const boundEngine = fakeEngine("bound", boundReceived) + const extra = otherEngine("extra", extraReceived) + const fakeHandler = () => {} + const otherHandler = () => {} + + const { T, Canvas: BoundCanvas } = createT.withCanvas(THREE, [boundEngine]) + + // Both engines' canvas props on one canvas — and no `@ts-expect-error` anywhere: + // the extended tuple must genuinely typecheck, contributing the union of both + // engines' canvas props. + render(() => ( + + + + )) + await Promise.resolve() + + expect(boundReceived).toHaveBeenCalledWith(fakeHandler) + expect(extraReceived).toHaveBeenCalledWith(otherHandler) + }) +}) + +describe("core owns no picking machinery", () => { + /** + * Not "core keeps a raycaster it never casts with" — core has NO raycaster at all. The + * raycaster belongs to the engine that picks with it (`useRaycaster()` from + * `solid-three/events`), so there is exactly one, and mutating it always affects + * picking. A claim that can't rot: nothing to police. + */ + it("exposes no raycaster on the context", async () => { + let context: Context | undefined + await renderThree(() => { + context = useThree() + return null + }) + expect(context && "raycaster" in context).toBe(false) + expect(context && "setRaycaster" in context).toBe(false) + }) + + it("core has no event registry", async () => { + let context: Context | undefined + await renderThree(() => { + context = useThree() + return null + }) + expect(context && "eventRegistry" in context).toBe(false) + }) + + it("takes no raycaster config on (type-level)", () => { + const three = renderThree( + () => null, + // @ts-expect-error the raycaster is configured on the engine — pointerEvents({ raycaster }) — not here. + { raycaster: { far: 10 } }, + ) + three.unmount() + }) +}) + +describe("no engine, no events", () => { + it("attaches no canvas listeners and dispatches nothing", async () => { + const T = createT(THREE) + const addEventListener = vi.spyOn(HTMLCanvasElement.prototype, "addEventListener") + + await renderThree(() => ) + + // The exact listener names `DOMPointerManager.connect` registers + // (src/events/pointer-managers.ts) — kept explicit rather than a + // `startsWith("pointer")` heuristic, which misses "lostpointercapture". + const engineListenerNames = [ + "pointermove", + "pointerdown", + "pointerup", + "pointerleave", + "pointercancel", + "lostpointercapture", + "click", + "dblclick", + "contextmenu", + "wheel", + ] + const pointerListeners = addEventListener.mock.calls.filter(([type]) => + engineListenerNames.includes(String(type)), + ) + expect(pointerListeners).toHaveLength(0) + addEventListener.mockRestore() + }) + + it("does not accept onClick as a prop of T.Mesh (type-level)", () => { + const T = createT(THREE) + const three = renderThree(() => ( + // @ts-expect-error onClick is contributed by the event engine; without it, it does not exist. + {}} /> + )) + three.unmount() + }) +}) + +/** + * The sibling of the no-plugins hole above. A mapped type over an INDEX SIGNATURE + * degrades into an index signature, so a plugin whose contributed methods are typed + * `Record void>` — which is exactly what `PluginStatics` declares + * for `canvas`, and therefore what an engine author annotating against the contract ends + * up with — would make every element/canvas prop typecheck and then silently drop. + * `pointerEvents()` escapes it only by spelling its `canvas` static out concretely. + * The two type-level tests below pin that a loose plugin contributes NOTHING rather than + * EVERYTHING: an unknown prop stays a compile error, which is the failure mode the whole + * event boundary exists to produce. + */ +const looseElementPlugin: Plugin<(element: Object3D) => Record void>> = + plugin([Object3D], () => ({ onLoosePing: () => {} })) + +const looseCanvasPlugin = Object.assign( + plugin([Object3D], () => ({ onLooseClick: (_handler: unknown) => {} })), + { + token: Symbol("loose-canvas"), + // Annotated with `PluginStatics.canvas`'s own declared return type — the realistic + // way a third-party engine author reaches this shape. + canvas: (_context: Context): Record void> => ({ + onLooseMissed: () => {}, + }), + }, +) + +describe("loosely-typed plugins contribute nothing, not everything", () => { + it("does not accept an arbitrary element prop from an index-signature-typed plugin (type-level)", () => { + const T = createT(THREE, [looseElementPlugin]) + const three = renderThree(() => ( + // @ts-expect-error a plugin typed with an index signature must not make every prop legal. + + )) + three.unmount() + }) + + it("does not accept an arbitrary canvas prop from an index-signature-typed plugin (type-level)", async () => { + render(() => ( + // @ts-expect-error same hole, canvas-level channel. + + {null} + + )) + await Promise.resolve() + expect(true).toBe(true) + }) + + it("still infers a concrete plugin's contributed props from a hoisted array (type-level)", () => { + // The guard must not be a blunt one: a `const` array hoisted into a variable has a + // `number` `length` but still-literal element types, and its props must keep inferring. + const plugins = [pointerEvents()] + const _T = createT(THREE, plugins) + type MeshProps = Parameters[0] + assertType(({} as MeshProps).onClick) + expect(true).toBe(true) + }) +}) + +describe("disjoint provenance", () => { + it("a click on one namespace's mesh never reaches the other engine's registry", async () => { + const engineA = pointerEvents() + // `otherReceived` observes ONLY genuine registry hits — kept apart from the + // canvas-props channel's own unconditional mount-time call (see `fakeEngine`'s + // doc comment), which would otherwise make `not.toHaveBeenCalled()` fail for a + // reason that has nothing to do with provenance. + const otherReceived = vi.fn() + const engineB = fakeEngine("other", vi.fn(), otherReceived) + + const TDom = createT(THREE, [engineA]) + const TOther = createT(THREE, [engineB]) + const clicked = vi.fn() + + const { canvas, waitTillNextFrame } = renderThree( + () => ( + <> + + + + + {/* Off to the side: a real click at the canvas centre must never hit this, + whatever its own engine's registry (mis)reports. */} + {}} position-x={100}> + + + + + ), + { plugins: [engineA, engineB] }, + ) + await waitTillNextFrame() // TOther.Mesh's offset position only reaches the raycaster after a frame + + // Click the centre of the canvas, where TDom.Mesh sits. + clickCanvasCentre(canvas) + + expect(clicked).toHaveBeenCalledTimes(1) + expect(otherReceived).not.toHaveBeenCalled() + }) +}) + +describe("token stability", () => { + // Layer A: the module-level `POINTER_EVENTS_TOKEN` in `src/events/index.ts` is the + // dedup key `Context.initializePlugin` uses, so every `pointerEvents()` call must + // return the SAME token. This is the layer the name "token stability" refers to, + // and it is checked here in isolation — no canvas, no engine, no `installEngine` + // Layer-B guard involved — so a regression to a per-call `Symbol()` fails this test + // even though `installEngine`'s own `engines.has(context)` guard (Layer B) would + // still silently prevent a double install below. + it("pointerEvents() returns the same module-level token across instances", () => { + expect(pointerEvents().token).toBe(pointerEvents().token) + expect(typeof pointerEvents().token).toBe("symbol") + }) + + // Layer A + Layer B together: the end-to-end guarantee that two `pointerEvents()` + // instances on one canvas still produce exactly one manager and one dispatch. This + // does NOT isolate which layer is doing the deduping — `installEngine`'s own + // `engines.has(context)` guard (Layer B) is sufficient on its own to make this pass, + // so it stays valuable as a composite/end-to-end check but cannot, alone, catch a + // Layer-A-only regression (see the token-identity test above for that). + it("two pointerEvents() instances install one manager and dispatch once", async () => { + const first = pointerEvents() + const second = pointerEvents() + const clicked = vi.fn() + const T = createT(THREE, [first]) + const addEventListener = vi.spyOn(HTMLCanvasElement.prototype, "addEventListener") + + const { canvas } = renderThree( + () => ( + + + + + ), + { plugins: [first, second] }, + ) + + // A second installed manager would attach a second "pointerdown" listener even + // before any click — catches a double-install that a single click might not + // observably double-dispatch (e.g. if only the most-recently-installed engine + // ever gets objects registered into it). + const pointerDownListeners = addEventListener.mock.calls.filter( + ([type]) => type === "pointerdown", + ) + expect(pointerDownListeners).toHaveLength(1) + addEventListener.mockRestore() + + clickCanvasCentre(canvas) + expect(clicked).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx deleted file mode 100644 index 30f070f8..00000000 --- a/tests/core/canvas-events.test.tsx +++ /dev/null @@ -1,714 +0,0 @@ -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, hasPointerCapture } from "../../src/index.ts" -import { test } from "../../src/testing/index.tsx" - -const T = createT(THREE) - -// offsetX/Y that hits the 2×2 BoxGeometry centred at origin (camera at z=5) -const HIT_X = 640 -const HIT_Y = 400 - -// offsetX/Y that misses the mesh (top-left corner of canvas) -const MISS_X = 0 -const MISS_Y = 0 - -function makeEvent(type: string, clientX: number, clientY: number) { - // The test canvas is mounted at (0, 0) in document.body, so offsetX/Y === clientX/Y. - // We dispatch a real MouseEvent; the browser computes offsetX/Y from the target's - // bounding rect — no `Object.defineProperty` hacks needed. - return new MouseEvent(type, { clientX, clientY, bubbles: true }) -} - -function hitEvent(type: string) { - return makeEvent(type, HIT_X, HIT_Y) -} - -function missEvent(type: string) { - return makeEvent(type, MISS_X, MISS_Y) -} - -/** A 2×2 mesh at origin whose onClick stops propagation. */ -const StoppingMesh = (props: { eventType: string; handler?: (e: any) => void }) => { - const handlerProp = { - [props.eventType]: (e: any) => { - e.stopPropagation() - props.handler?.(e) - }, - } - return ( - - - - - ) -} - -/** A 2×2 mesh at origin that registers for an event without stopping propagation. */ -const ListeningMesh = (props: { eventType: string; handler?: (e: any) => void }) => { - const handlerProp = { [props.eventType]: (e: any) => props.handler?.(e) } - return ( - - - - - ) -} - -/**********************************************************************************/ -/* */ -/* Missable Events */ -/* */ -/**********************************************************************************/ - -describe("canvas missable events", () => { - // - // onClick - // - describe("onClick", () => { - it("fires when canvas is clicked and no meshes are in the scene", () => { - const handleClick = vi.fn() - const { canvas } = test(() => null, { onClick: handleClick }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it("fires when click propagates through a mesh that does not stop it", () => { - const handleClick = vi.fn() - const { canvas } = test(() => , { onClick: handleClick }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it("fires when click misses all meshes", () => { - const handleClick = vi.fn() - const { canvas } = test(() => , { onClick: handleClick }) - - fireEvent(canvas, missEvent("click")) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleClick = vi.fn() - const { canvas } = test(() => , { onClick: handleClick }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClick).not.toHaveBeenCalled() - }) - }) - - // - // onClickMissed - // - describe("onClickMissed", () => { - it("fires when click misses all registered meshes", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => , { - onClickMissed: handleClickMissed, - }) - - fireEvent(canvas, missEvent("click")) - - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("fires when canvas is clicked with no meshes in the scene", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => null, { onClickMissed: handleClickMissed }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when click hits a registered mesh", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => , { - onClickMissed: handleClickMissed, - }) - - 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() - }) - }) - - // - // onDoubleClick - // - describe("onDoubleClick", () => { - it("fires when canvas is double-clicked and no meshes are in the scene", () => { - const handleDoubleClick = vi.fn() - const { canvas } = test(() => null, { onDoubleClick: handleDoubleClick }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleDoubleClick).toHaveBeenCalledTimes(1) - }) - - it("fires when double-click propagates through a mesh that does not stop it", () => { - const handleDoubleClick = vi.fn() - const { canvas } = test(() => , { - onDoubleClick: handleDoubleClick, - }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleDoubleClick).toHaveBeenCalledTimes(1) - }) - - it("fires when double-click misses all meshes", () => { - const handleDoubleClick = vi.fn() - const { canvas } = test(() => , { - onDoubleClick: handleDoubleClick, - }) - - fireEvent(canvas, missEvent("dblclick")) - - expect(handleDoubleClick).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleDoubleClick = vi.fn() - const { canvas } = test(() => , { - onDoubleClick: handleDoubleClick, - }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleDoubleClick).not.toHaveBeenCalled() - }) - }) - - // - // onDoubleClickMissed - // - describe("onDoubleClickMissed", () => { - it("fires when double-click misses all registered meshes", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, - }) - - fireEvent(canvas, missEvent("dblclick")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - 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() - const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, - }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleMissed).not.toHaveBeenCalled() - }) - }) - - // - // onContextMenu - // - describe("onContextMenu", () => { - it("fires when canvas receives contextmenu and no meshes are in the scene", () => { - const handleContextMenu = vi.fn() - const { canvas } = test(() => null, { onContextMenu: handleContextMenu }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleContextMenu).toHaveBeenCalledTimes(1) - }) - - it("fires when contextmenu propagates through a mesh that does not stop it", () => { - const handleContextMenu = vi.fn() - const { canvas } = test(() => , { - onContextMenu: handleContextMenu, - }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleContextMenu).toHaveBeenCalledTimes(1) - }) - - it("fires when contextmenu misses all meshes", () => { - const handleContextMenu = vi.fn() - const { canvas } = test(() => , { - onContextMenu: handleContextMenu, - }) - - fireEvent(canvas, missEvent("contextmenu")) - - expect(handleContextMenu).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleContextMenu = vi.fn() - const { canvas } = test(() => , { - onContextMenu: handleContextMenu, - }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleContextMenu).not.toHaveBeenCalled() - }) - }) - - // - // onContextMenuMissed - // - describe("onContextMenuMissed", () => { - it("fires when contextmenu misses all registered meshes", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, - }) - - 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) - }) - - it("does not fire when contextmenu hits a registered mesh", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, - }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleMissed).not.toHaveBeenCalled() - }) - }) -}) - -/**********************************************************************************/ -/* */ -/* Default Events */ -/* */ -/**********************************************************************************/ - -describe("canvas default events", () => { - // - // onPointerDown - // - describe("onPointerDown", () => { - it("fires when pointerdown occurs with no meshes in the scene", () => { - const handlePointerDown = vi.fn() - const { canvas } = test(() => null, { onPointerDown: handlePointerDown }) - - fireEvent(canvas, hitEvent("pointerdown")) - - expect(handlePointerDown).toHaveBeenCalledTimes(1) - }) - - it("fires when pointerdown propagates through a mesh that does not stop it", () => { - const handlePointerDown = vi.fn() - const { canvas } = test(() => , { - onPointerDown: handlePointerDown, - }) - - fireEvent(canvas, hitEvent("pointerdown")) - - expect(handlePointerDown).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handlePointerDown = vi.fn() - const { canvas } = test(() => , { - onPointerDown: handlePointerDown, - }) - - fireEvent(canvas, hitEvent("pointerdown")) - - expect(handlePointerDown).not.toHaveBeenCalled() - }) - }) - - // - // onPointerUp - // - describe("onPointerUp", () => { - it("fires when pointerup occurs with no meshes in the scene", () => { - const handlePointerUp = vi.fn() - const { canvas } = test(() => null, { onPointerUp: handlePointerUp }) - - fireEvent(canvas, hitEvent("pointerup")) - - expect(handlePointerUp).toHaveBeenCalledTimes(1) - }) - - it("fires when pointerup propagates through a mesh that does not stop it", () => { - const handlePointerUp = vi.fn() - const { canvas } = test(() => , { - onPointerUp: handlePointerUp, - }) - - fireEvent(canvas, hitEvent("pointerup")) - - expect(handlePointerUp).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handlePointerUp = vi.fn() - const { canvas } = test(() => , { - onPointerUp: handlePointerUp, - }) - - fireEvent(canvas, hitEvent("pointerup")) - - expect(handlePointerUp).not.toHaveBeenCalled() - }) - }) - - // - // onWheel - // - describe("onWheel", () => { - it("fires when wheel event occurs with no meshes in the scene", () => { - const handleWheel = vi.fn() - const { canvas } = test(() => null, { onWheel: handleWheel }) - - fireEvent( - canvas, - new WheelEvent("wheel", { deltaY: 100, clientX: HIT_X, clientY: HIT_Y, bubbles: true }), - ) - - expect(handleWheel).toHaveBeenCalledTimes(1) - }) - - it("fires when wheel event propagates through a mesh that does not stop it", () => { - const handleWheel = vi.fn() - const { canvas } = test(() => , { onWheel: handleWheel }) - - fireEvent( - canvas, - new WheelEvent("wheel", { deltaY: 100, clientX: HIT_X, clientY: HIT_Y, bubbles: true }), - ) - - expect(handleWheel).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleWheel = vi.fn() - const { canvas } = test(() => , { onWheel: handleWheel }) - - fireEvent( - canvas, - new WheelEvent("wheel", { deltaY: 100, clientX: HIT_X, clientY: HIT_Y, bubbles: true }), - ) - - expect(handleWheel).not.toHaveBeenCalled() - }) - }) -}) - -/**********************************************************************************/ -/* */ -/* Hover Events */ -/* */ -/**********************************************************************************/ - -describe("canvas hover events", () => { - // - // onPointerEnter / onPointerLeave / onPointerMove - // - describe("onPointerEnter", () => { - it("fires when the pointer first moves over the canvas", () => { - const handlePointerEnter = vi.fn() - const { canvas } = test(() => null, { onPointerEnter: handlePointerEnter }) - - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerEnter).toHaveBeenCalledTimes(1) - }) - - it("fires only once per canvas hover session", () => { - const handlePointerEnter = vi.fn() - const { canvas } = test(() => null, { onPointerEnter: handlePointerEnter }) - - fireEvent(canvas, hitEvent("pointermove")) - fireEvent(canvas, hitEvent("pointermove")) - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerEnter).toHaveBeenCalledTimes(1) - }) - - it("fires again after the pointer has left and re-entered", () => { - const handlePointerEnter = vi.fn() - const { canvas } = test(() => null, { onPointerEnter: handlePointerEnter }) - - fireEvent(canvas, hitEvent("pointermove")) - fireEvent(canvas, makeEvent("pointerleave", HIT_X, HIT_Y)) - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerEnter).toHaveBeenCalledTimes(2) - }) - }) - - describe("onPointerLeave", () => { - it("fires when the pointer leaves the canvas", () => { - const handlePointerLeave = vi.fn() - const { canvas } = test(() => null, { onPointerLeave: handlePointerLeave }) - - fireEvent(canvas, hitEvent("pointermove")) - fireEvent(canvas, makeEvent("pointerleave", HIT_X, HIT_Y)) - - expect(handlePointerLeave).toHaveBeenCalledTimes(1) - }) - }) - - describe("onPointerMove", () => { - it("fires when the pointer moves over the canvas with no meshes", () => { - const handlePointerMove = vi.fn() - const { canvas } = test(() => null, { onPointerMove: handlePointerMove }) - - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerMove).toHaveBeenCalledTimes(1) - }) - - it("fires when pointer move propagates through a mesh that does not stop it", () => { - const handlePointerMove = vi.fn() - const { canvas } = test(() => , { - onPointerMove: handlePointerMove, - }) - - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerMove).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handlePointerMove = vi.fn() - const { canvas } = test(() => , { - onPointerMove: handlePointerMove, - }) - - fireEvent(canvas, hitEvent("pointermove")) - - expect(handlePointerMove).not.toHaveBeenCalled() - }) - }) -}) - -/**********************************************************************************/ -/* */ -/* 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/hooks.test.tsx b/tests/core/hooks.test.tsx index 4a24b0f0..28902a32 100644 --- a/tests/core/hooks.test.tsx +++ b/tests/core/hooks.test.tsx @@ -26,7 +26,6 @@ describe("hooks", () => { expect(result.camera instanceof THREE.Camera).toBeTruthy() expect(result.scene instanceof THREE.Scene).toBeTruthy() - expect(result.raycaster instanceof THREE.Raycaster).toBeTruthy() // expect(result.size).toEqual({ height: 0, width: 0, top: 0, left: 0, updateStyle: false }); }) @@ -192,4 +191,37 @@ describe("hooks", () => { }, }) }) + + it("exposes addFrameListener on the context so plugins can drive the loop", async () => { + const ticks = vi.fn() + let dispose: (() => void) | undefined + + const Component = () => { + const context = useThree() + dispose = context.addFrameListener(() => ticks()) + return null + } + + const { waitTillNextFrame } = test(() => ) + + await waitFor(() => expect(ticks.mock.calls.length).toBeGreaterThan(0)) + + dispose?.() + + // Give any already-scheduled tick a chance to land, then take that as + // the disposed baseline. + const framesToWait = 10 + for (let frame = 0; frame < framesToWait; frame++) { + await waitTillNextFrame() + } + const countAfterDisposal = ticks.mock.calls.length + + // Wait several more real animation frames — long enough that a listener + // which was still firing would certainly have ticked again — and prove + // the count hasn't moved. + for (let frame = 0; frame < framesToWait; frame++) { + await waitTillNextFrame() + } + expect(ticks.mock.calls.length).toBe(countAfterDisposal) + }) }) diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 426a244f..ce8494c5 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,9 +1,12 @@ +import { Show, createSignal, onCleanup } from "solid-js" import { assertType, describe, expect, it, vi } from "vitest" +import * as THREE from "three" import { Mesh, Object3D, PerspectiveCamera, Vector3 } from "three" import { plugin, resolvePluginMethods } from "../../src/plugin.ts" import { createT } from "../../src/create-t.tsx" import { Entity } from "../../src/components.tsx" import { test as renderThree } from "../../src/testing/index.tsx" +import type { Context } from "../../src/types.ts" import { getMeta } from "../../src/utils.ts" describe("plugin()", () => { @@ -39,6 +42,31 @@ describe("resolvePluginMethods", () => { expect(Object.keys(merged).sort()).toEqual(["ping", "shake"]) expect(resolvePluginMethods(new Mesh(), [])).toEqual({}) }) + + it("warns in dev when two plugins contribute the same element prop, and last-wins still holds", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const first = plugin([Mesh], () => ({ onPing: () => "first" })) + const second = plugin([Mesh], () => ({ onPing: () => "second" })) + + const merged = resolvePluginMethods(new Mesh(), [first, second]) + + expect(merged.onPing(undefined)).toBe("second") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("onPing")) + warn.mockRestore() + }) + + it("does not warn when two plugins contribute different element props", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const first = plugin([Mesh], () => ({ onPing: () => "ping" })) + const second = plugin([Mesh], () => ({ onPong: () => "pong" })) + + const merged = resolvePluginMethods(new Mesh(), [first, second]) + + expect(merged.onPing(undefined)).toBe("ping") + expect(merged.onPong(undefined)).toBe("pong") + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) }) describe("plugin prop routing", () => { @@ -116,3 +144,73 @@ describe("meta.ctx + initializePlugin", () => { three.unmount() }) }) + +describe("plugin statics", () => { + it("runs install once per context, under the canvas owner, keyed by token", () => { + const TOKEN = Symbol("test-engine") + const install = vi.fn() + const cleanup = vi.fn() + + // Two distinct instances of the "same engine" — separate function objects, + // sharing one module-level token. If dedup keyed off the plugin instance + // (or the plugin array entry) rather than `.token`, both would install. + const makeEngine = () => + Object.assign( + plugin([Object3D], () => ({ onPing: (_handler: () => void) => {} })), + { + token: TOKEN, + install: (context: Context) => { + install(context) + onCleanup(cleanup) + }, + }, + ) + const engineInstanceOne = makeEngine() + const engineInstanceTwo = makeEngine() + expect(engineInstanceOne).not.toBe(engineInstanceTwo) + + const T = createT(THREE, [engineInstanceOne, engineInstanceTwo]) + const three = renderThree(() => {}} />) + + expect(install).toHaveBeenCalledTimes(1) + expect(cleanup).not.toHaveBeenCalled() + + three.unmount() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it("ties install's cleanup to the canvas, not to whichever element mounted first", async () => { + const TOKEN = Symbol("test-engine-owner") + const cleanup = vi.fn() + + const engine = Object.assign( + plugin([Object3D], () => ({ onPing: (_handler: () => void) => {} })), + { + token: TOKEN, + install: (_context: Context) => { + onCleanup(cleanup) + }, + }, + ) + + const T = createT(THREE, [engine]) + const [visible, setVisible] = createSignal(true) + const three = renderThree(() => ( + + {}} /> + + )) + + await three.waitTillNextFrame() + + // Unmount the (only, first-to-install) element while the canvas stays alive — + // if `install` ran under that element's own reactive scope instead of the + // canvas owner, this would fire `cleanup` prematurely. + setVisible(false) + await three.waitTillNextFrame() + expect(cleanup).not.toHaveBeenCalled() + + three.unmount() + expect(cleanup).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/core/renderer.test.tsx b/tests/core/renderer.test.tsx index 019c1379..9c7616e2 100644 --- a/tests/core/renderer.test.tsx +++ b/tests/core/renderer.test.tsx @@ -9,6 +9,7 @@ import { } from "solid-js" import * as THREE from "three" import { beforeAll, describe, expect, it, vi } from "vitest" +import { pointerEvents } from "../../src/events/index.ts" import { createT, Entity, Portal, useFrame, useThree } from "../../src/index.ts" import { test } from "../../src/testing/index.tsx" import type { Context, Meta, RendererLike } from "../../src/types.ts" @@ -404,7 +405,13 @@ describe("renderer", () => { const object2 = new THREE.Group() const Test = (props: { first?: boolean }) => ( - null}> + // The pointer engine is what gives this Entity an `onPointerMove` prop at all — + // the handler is here so the swap happens on an event-registered object. + null} + > ) @@ -797,8 +804,8 @@ describe("renderer", () => { }) /** - * Construction firewall — see `cameraInput`/`sceneInput`/`raycasterInput`/ - * `glInput` memos in `create-three.tsx`. Each prop is read through a + * Construction firewall — see `cameraInput`/`sceneInput`/`glInput` memos + * in `create-three.tsx`. Each prop is read through a * `createMemo({equals: shallowEqual})` so reactive config-objects with * fresh references but identical *shape* don't re-allocate three.js * objects (which would break held refs). @@ -848,20 +855,6 @@ describe("renderer", () => { expect(state.scene).toBe(initial) }) - it("raycaster memo doesn't recreate when prop reference changes but shape is equal", () => { - const [tick, setTick] = createSignal(0) - const state = test(() => , { - get raycaster() { - tick() - return { near: 0.1, far: 1000 } - }, - }) - - const initial = state.raycaster - setTick(1) - expect(state.raycaster).toBe(initial) - }) - it("gl memo doesn't recreate when prop reference changes but shape is equal", () => { const [tick, setTick] = createSignal(0) const state = test(() => , { diff --git a/tests/events-pmndrs/pmndrs-engine.test.tsx b/tests/events-pmndrs/pmndrs-engine.test.tsx new file mode 100644 index 00000000..75074949 --- /dev/null +++ b/tests/events-pmndrs/pmndrs-engine.test.tsx @@ -0,0 +1,128 @@ +import { getVoidObject } from "@pmndrs/pointer-events" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { pointerEvents } from "../../src/events/index.ts" +import { pmndrsEvents } from "../../src/events-pmndrs/index.ts" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" +import type { CameraKind } from "../../src/types.ts" +import { + CANVAS_CENTRE_X, + CANVAS_CENTRE_Y, + clickAt, + clickCanvasCentre, +} from "../utils/pointer-utils.ts" + +/** + * Every assertion here waits a frame first. pmndrs BATCHES its native events and flushes + * them only when `update()` runs — which this engine drives from + * `Context.addFrameListener`. Nothing has dispatched until a frame has passed, so + * `waitTillNextFrame()` is not incidental sequencing: it is the engine's dispatch tick. + */ + +/** Where the pmndrs mesh sits, clear of the reference mesh at the origin. */ +const OFFSET_MESH_X = 3 + +/** Where a world-space point lands in client coordinates, through the live camera. */ +function projectToClient(canvas: HTMLCanvasElement, camera: CameraKind, point: THREE.Vector3) { + const ndc = point.clone().project(camera) + const { left, top, width, height } = canvas.getBoundingClientRect() + return { + clientX: left + ((ndc.x + 1) / 2) * width, + clientY: top + ((1 - ndc.y) / 2) * height, + } +} + +describe("pmndrs engine", () => { + it("dispatches a click through nothing but the public boundary", async () => { + const T = createT(THREE, [pmndrsEvents()]) + const clicked = vi.fn() + + const { canvas, waitTillNextFrame } = test(() => ( + + + + + )) + await waitTillNextFrame() + + clickCanvasCentre(canvas) + await waitTillNextFrame() + + expect(clicked).toHaveBeenCalledTimes(1) + }) + + it("reports empty space on the void OBJECT, not a canvas prop", async () => { + const T = createT(THREE, [pmndrsEvents()]) + const missed = vi.fn() + const hit = vi.fn() + + // The mesh is parked far off-screen, so the centre click hits nothing. pmndrs still + // produces an intersection — with the VOID object — and dispatches `click` there. + const { canvas, scene, waitTillNextFrame } = test(() => ( + + + + + )) + getVoidObject(scene).addEventListener("click", missed) + await waitTillNextFrame() + + clickCanvasCentre(canvas) + await waitTillNextFrame() + + expect(missed).toHaveBeenCalledTimes(1) + expect(hit).not.toHaveBeenCalled() + }) + + it("co-exists with the reference engine without corrupting it", async () => { + const reference = pointerEvents() + const pmndrs = pmndrsEvents() + const TReference = createT(THREE, [reference]) + const TPmndrs = createT(THREE, [pmndrs]) + const T = createT(THREE) + const referenceClicked = vi.fn() + const pmndrsClicked = vi.fn() + + // ONE canvas, BOTH engines. Each mesh carries the handler prop contributed by its own + // engine, and each engine partitions by provenance: the reference engine raycasts only + // its own registry, while pmndrs only ever targets objects carrying ITS listeners. + const { canvas, camera, waitTillNextFrame } = test( + () => ( + <> + + + + + + + + + + ), + { plugins: [reference, pmndrs] }, + ) + await waitTillNextFrame() + + // Importing pmndrs has already patched `Object3D.prototype` process-globally. The + // reference engine keeps captures in its own registry and never consults the + // prototype, so its dispatch is untouched — that is what this asserts. + clickCanvasCentre(canvas) + await waitTillNextFrame() + + expect(referenceClicked).toHaveBeenCalledTimes(1) + expect(pmndrsClicked).not.toHaveBeenCalled() + + const offset = projectToClient(canvas, camera, new THREE.Vector3(OFFSET_MESH_X, 0, 0)) + clickAt(canvas, offset.clientX, offset.clientY) + await waitTillNextFrame() + + expect(pmndrsClicked).toHaveBeenCalledTimes(1) + expect(referenceClicked).toHaveBeenCalledTimes(1) // still exactly the one from before + + // Sanity: the centre click really was inside the reference mesh and outside the + // pmndrs one, so the two assertions above are about provenance, not geometry. + expect(CANVAS_CENTRE_X).not.toBeCloseTo(offset.clientX, 0) + expect(CANVAS_CENTRE_Y).toBeCloseTo(offset.clientY, 0) + }) +}) diff --git a/tests/events/canvas-events.test.tsx b/tests/events/canvas-events.test.tsx new file mode 100644 index 00000000..8b36a3d4 --- /dev/null +++ b/tests/events/canvas-events.test.tsx @@ -0,0 +1,278 @@ +import { fireEvent } from "@solidjs/testing-library" +import { createSignal } from "solid-js" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { hasPointerCapture, pointerEvents } from "../../src/events/index.ts" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" + +// One engine instance, installed both into the namespace (so `T.Mesh` has pointer +// props) and onto the canvas (so the canvas-level `onPointerMissed` reaches it). +const engine = pointerEvents() +const T = createT(THREE, [engine]) + +// offsetX/Y that hits the 2×2 BoxGeometry centred at origin (camera at z=5) +const HIT_X = 640 +const HIT_Y = 400 + +// offsetX/Y that misses the mesh (top-left corner of canvas) +const MISS_X = 0 +const MISS_Y = 0 + +function makeEvent(type: string, clientX: number, clientY: number) { + // The test canvas is mounted at (0, 0) in document.body, so offsetX/Y === clientX/Y. + // We dispatch a real MouseEvent; the browser computes offsetX/Y from the target's + // bounding rect — no `Object.defineProperty` hacks needed. + return new MouseEvent(type, { clientX, clientY, bubbles: true }) +} + +function hitEvent(type: string) { + return makeEvent(type, HIT_X, HIT_Y) +} + +function missEvent(type: string) { + return makeEvent(type, MISS_X, MISS_Y) +} + +/** A 2×2 mesh at origin that registers for an event without stopping propagation. */ +const ListeningMesh = (props: { eventType: string; handler?: (e: any) => void }) => { + const handlerProp = { [props.eventType]: (e: any) => props.handler?.(e) } + return ( + + + + + ) +} + +/**********************************************************************************/ +/* */ +/* Missable Events */ +/* */ +/**********************************************************************************/ + +describe("canvas onPointerMissed", () => { + const gestures = [ + { name: "click", dom: "click" }, + { name: "double-click", dom: "dblclick" }, + { name: "context menu", dom: "contextmenu" }, + ] as const + + for (const g of gestures) { + it(`fires when ${g.name} misses all registered meshes`, () => { + const handleMissed = vi.fn() + const { canvas } = test(() => , { + plugins: [engine], + onPointerMissed: handleMissed, + }) + + fireEvent(canvas, missEvent(g.dom)) + + expect(handleMissed).toHaveBeenCalledTimes(1) + }) + + it(`fires when ${g.name} occurs with no meshes in the scene`, () => { + const handleMissed = vi.fn() + const { canvas } = test(() => null, { plugins: [engine], onPointerMissed: handleMissed }) + + fireEvent(canvas, hitEvent(g.dom)) + + expect(handleMissed).toHaveBeenCalledTimes(1) + }) + + it(`does not fire when ${g.name} hits a registered mesh`, () => { + const handleMissed = vi.fn() + const { canvas } = test(() => , { + plugins: [engine], + onPointerMissed: handleMissed, + }) + + fireEvent(canvas, hitEvent(g.dom)) + + expect(handleMissed).not.toHaveBeenCalled() + }) + } +}) + +/**********************************************************************************/ +/* */ +/* 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", () => { + // The engine has to be installed for there to be listeners at all — an empty scene + // never triggers the lazy per-element install, so list it on the canvas. + const three = test(() => null, { plugins: [engine] }) + 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/events/events.test.tsx similarity index 85% rename from tests/core/events.test.tsx rename to tests/events/events.test.tsx index 310cad68..e9ea9700 100644 --- a/tests/core/events.test.tsx +++ b/tests/events/events.test.tsx @@ -2,10 +2,12 @@ import { fireEvent } from "@solidjs/testing-library" import { createSignal } from "solid-js" import * as THREE from "three" import { describe, expect, it, vi } from "vitest" +import { pointerEvents } from "../../src/events/index.ts" import { createT } from "../../src/index.ts" import { test } from "../../src/testing/index.tsx" +import { clickCanvasCentre, makeClickAt } from "../utils/pointer-utils.ts" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) describe("events", () => { it("can handle onPointerDown", async () => { @@ -227,26 +229,21 @@ describe("events", () => { /**********************************************************************************/ /* */ -/* Mesh-level onClickMissed */ +/* Mesh-level onPointerMissed */ /* */ /**********************************************************************************/ -const HIT_X = 640 -const HIT_Y = 400 +// HIT_X/HIT_Y match CANVAS_CENTRE_X/CANVAS_CENTRE_Y from tests/utils/pointer-utils.ts — +// where a 2×2 BoxGeometry centred at the origin sits, camera at z=5. const MISS_X = 0 const MISS_Y = 0 -function makeClickAt(clientX: number, clientY: number) { - // Canvas is at (0, 0) in document.body, so offsetX/Y === clientX/Y. - return new MouseEvent("click", { clientX, clientY, bubbles: true }) -} - -describe("mesh onClickMissed", () => { +describe("mesh onPointerMissed", () => { it("fires when a click misses the mesh", async () => { - const handleClickMissed = vi.fn() + const handleMissed = vi.fn() const { canvas } = await test(() => ( - + @@ -254,53 +251,54 @@ describe("mesh onClickMissed", () => { fireEvent(canvas, makeClickAt(MISS_X, MISS_Y)) - expect(handleClickMissed).toHaveBeenCalledTimes(1) + expect(handleMissed).toHaveBeenCalledTimes(1) }) it("does not fire when the mesh itself is clicked", async () => { - const handleClickMissed = vi.fn() + const handleMissed = vi.fn() const { canvas } = await test(() => ( - + )) - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) - expect(handleClickMissed).not.toHaveBeenCalled() + expect(handleMissed).not.toHaveBeenCalled() }) - it("does not fire when a different mesh in the scene is clicked", async () => { - const handleClickMissed = vi.fn() + it("fires when a different mesh in the scene is clicked", async () => { + const handleMissed = vi.fn() - // Mesh A: off-center (far right), has onClickMissed - // Mesh B: at origin (center of screen), gets clicked - const { canvas } = await test(() => ( + // Mesh A: off-center (far right), has onPointerMissed. + // Mesh B: at origin (center of screen), registered and gets clicked. + const { canvas, waitTillNextFrame } = await test(() => ( <> - + - + {}}> )) + await waitTillNextFrame() // A's position only reaches the raycaster after a frame - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) // hits B; A is off to the side - expect(handleClickMissed).not.toHaveBeenCalled() + expect(handleMissed).toHaveBeenCalledTimes(1) // A wasn't hit → it hears that B was }) it("does not fire on a parent when its child is clicked", async () => { - const handleParentClickMissed = vi.fn() + const handleParentMissed = vi.fn() const handleChildClick = vi.fn() const { canvas } = await test(() => ( - + @@ -308,10 +306,10 @@ describe("mesh onClickMissed", () => { )) - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) expect(handleChildClick).toHaveBeenCalledTimes(1) - expect(handleParentClickMissed).not.toHaveBeenCalled() + expect(handleParentMissed).not.toHaveBeenCalled() }) }) @@ -334,14 +332,14 @@ describe("event handler reactivity", () => { )) // No handler yet — click should not fire - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) expect(handleClick).not.toHaveBeenCalled() // Add the handler reactively setOnClick(() => handleClick) - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) expect(handleClick).toHaveBeenCalledTimes(1) }) @@ -358,14 +356,14 @@ describe("event handler reactivity", () => { )) // Handler active — click fires - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) expect(handleClick).toHaveBeenCalledTimes(1) // Remove handler reactively setOnClick(undefined) - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) + clickCanvasCentre(canvas) expect(handleClick).toHaveBeenCalledTimes(1) // no new call }) diff --git a/tests/core/multi-pointer.test.tsx b/tests/events/multi-pointer.test.tsx similarity index 95% rename from tests/core/multi-pointer.test.tsx rename to tests/events/multi-pointer.test.tsx index 490a083b..2068f1a3 100644 --- a/tests/core/multi-pointer.test.tsx +++ b/tests/events/multi-pointer.test.tsx @@ -1,10 +1,11 @@ import { fireEvent } from "@solidjs/testing-library" import * as THREE from "three" import { afterEach, describe, expect, it, vi } from "vitest" +import { pointerEvents } from "../../src/events/index.ts" import { createT } from "../../src/index.ts" import { cleanup, test } from "../../src/testing/index.tsx" -const T = createT(THREE) +const T = createT(THREE, [pointerEvents()]) afterEach(cleanup) diff --git a/tests/events/pointer-behaviour.test.tsx b/tests/events/pointer-behaviour.test.tsx new file mode 100644 index 00000000..5776ac74 --- /dev/null +++ b/tests/events/pointer-behaviour.test.tsx @@ -0,0 +1,428 @@ +import { fireEvent } from "@solidjs/testing-library" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { pointerEvents } from "../../src/events/index.ts" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" + +/** + * Behavioural tests for the pointer-event system: what actually fires when the + * user interacts, on a real scene through the real raycaster. The rest of the + * suite pins which objects get *registered*; these pin which handlers get + * *called* on a click / move / wheel. + * + * NOTE: raycasting reads each object's `matrixWorld`, which is refreshed once + * per render frame. A synthetic `fireEvent` fires before the first frame, so + * any test that relies on an object's *position* must `await waitTillNextFrame()` + * first — otherwise every mesh hit-tests as if at the origin. Tests that use + * only an origin mesh (or an empty scene) don't need it. + */ + +// One engine instance, installed both into the namespace (so `T.Mesh` has pointer +// props) and onto the canvas (so the canvas-level `onPointerMissed` reaches it). +const engine = pointerEvents() +const T = createT(THREE, [engine]) + +// (640,400) hits a 2×2 box centred at origin (camera at z=5). +const HIT_X = 640 +const HIT_Y = 400 +const AT = { clientX: HIT_X, clientY: HIT_Y, bubbles: true } + +function fire(canvas: HTMLCanvasElement, type: string) { + fireEvent(canvas, new MouseEvent(type, AT)) +} + +/** A 2×2 box at origin whose only handler is the one named by `eventType`. */ +function SoleHandlerBox(props: { eventType: string }) { + return ( + {} }}> + + + + ) +} + +/** A 2×2 box at depth `z` carrying one handler. */ +function StackBox(props: { z: number; prop: string; handler: (e: any) => void }) { + return ( + + + + + ) +} + +/**********************************************************************************/ +/* */ +/* Any handler makes an object a target for every gesture (no per-type) */ +/* */ +/**********************************************************************************/ + +/** + * A mesh with *any* pointer handler is hit-tested for *every* gesture. So a + * click lands on a mesh whose only handler is `onWheel` (or `onPointerMove`): + * it counts as a hit, so the click is not a void and the canvas-level + * `onPointerMissed` does not fire. + */ +describe("a mesh whose only handler is a different event still counts as a hit", () => { + const gestures = ["click", "dblclick", "contextmenu"] as const + const unrelated = ["onWheel", "onPointerMove"] as const + + for (const gesture of gestures) { + for (const u of unrelated) { + it(`clicking a ${u}-only mesh does not fire onPointerMissed (union: it is a hit)`, () => { + const missed = vi.fn() + const { canvas } = test(() => , { + plugins: [engine], + onPointerMissed: missed, + }) + + fire(canvas, gesture) + + expect(missed).not.toHaveBeenCalled() // the mesh is a hit, so the click is not a void + }) + } + } +}) + +/**********************************************************************************/ +/* */ +/* Clicking one object notifies the others via their onPointerMissed */ +/* */ +/**********************************************************************************/ + +describe("clicking one object fires another object's onPointerMissed (r3f not-me)", () => { + it("clicking A fires B's onPointerMissed; A's own does not", async () => { + const aClick = vi.fn() + const aMissed = vi.fn() + const bMissed = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + <> + + + + + {}} onPointerMissed={bMissed}> + + + + + )) + await waitTillNextFrame() // B's position only reaches the raycaster after a frame + + fire(canvas, "click") // hits A at the origin; B is off to the side + + expect(aClick).toHaveBeenCalledTimes(1) + expect(bMissed).toHaveBeenCalledTimes(1) // B wasn't hit → it hears that something else was + expect(aMissed).not.toHaveBeenCalled() // A was the hit + }) + + it("a total miss fires onPointerMissed on every registered object", async () => { + const aMissed = vi.fn() + const bMissed = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + <> + {}} onPointerMissed={aMissed}> + + + + {}} onPointerMissed={bMissed}> + + + + + )) + await waitTillNextFrame() + + fireEvent(canvas, new MouseEvent("click", { clientX: 0, clientY: 0, bubbles: true })) // empty space + + expect(aMissed).toHaveBeenCalledTimes(1) + expect(bMissed).toHaveBeenCalledTimes(1) + }) +}) + +/**********************************************************************************/ +/* */ +/* A click passes through to objects stacked behind the front */ +/* */ +/**********************************************************************************/ + +/** Every gesture that propagates, with the DOM event that triggers it. */ +const PROPAGATING = [ + { name: "click", prop: "onClick", make: () => new MouseEvent("click", AT) }, + { name: "dblclick", prop: "onDoubleClick", make: () => new MouseEvent("dblclick", AT) }, + { name: "contextmenu", prop: "onContextMenu", make: () => new MouseEvent("contextmenu", AT) }, + { name: "pointerdown", prop: "onPointerDown", make: () => new PointerEvent("pointerdown", AT) }, + { name: "pointerup", prop: "onPointerUp", make: () => new PointerEvent("pointerup", AT) }, + { name: "wheel", prop: "onWheel", make: () => new WheelEvent("wheel", { ...AT, deltaY: 1 }) }, + { name: "pointermove", prop: "onPointerMove", make: () => new PointerEvent("pointermove", AT) }, +] as const + +describe("an object behind the front one still receives the gesture; stopPropagation stops it", () => { + for (const g of PROPAGATING) { + it(`${g.name}: front and rear both fire (the front does not occlude the rear)`, async () => { + const front = vi.fn() + const rear = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + <> + + + + )) + await waitTillNextFrame() // the rear's depth only takes effect after a frame + + fireEvent(canvas, g.make()) + + expect(front).toHaveBeenCalledTimes(1) + expect(rear).toHaveBeenCalledTimes(1) + }) + + it(`${g.name}: stopPropagation on the front object stops the rear`, async () => { + const front = vi.fn((e: any) => e.stopPropagation()) + const rear = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + <> + + + + )) + await waitTillNextFrame() + + fireEvent(canvas, g.make()) + + expect(front).toHaveBeenCalledTimes(1) + expect(rear).not.toHaveBeenCalled() + }) + } +}) + +/**********************************************************************************/ +/* */ +/* A click inside a group should not register as a miss of that group */ +/* */ +/**********************************************************************************/ + +/** + * A click inside a group is a hit, not a miss of the group — even when the child + * stops propagation so the event never bubbles up to the group. The miss is gated + * solely on a total miss (nothing hit), so a stopped bubble can't reclassify an + * ancestor as missed (this is what dissolved "Surprise B"). + */ +it("a group's onPointerMissed does not fire when its own child is clicked", async () => { + const childClick = vi.fn() + const groupMissed = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + + (childClick(e), e.stopPropagation())}> + + + + + )) + await waitTillNextFrame() + + fire(canvas, "click") + + expect(childClick).toHaveBeenCalledTimes(1) + expect(groupMissed).not.toHaveBeenCalled() // a click inside the group is a hit, not a miss +}) + +/**********************************************************************************/ +/* */ +/* raycastable={false} takes a mesh out of hit-testing; ray passes on */ +/* */ +/**********************************************************************************/ + +/** + * `raycastable={false}` must take a handler-bearing mesh out of hit-testing + * entirely: its own handler never fires, and the ray passes through to whatever + * is behind it. + */ +describe("raycastable={false} skips the mesh and the ray passes through to what is behind", () => { + it("the opted-out front mesh's onClick does not fire; the rear mesh's does", async () => { + const front = vi.fn() + const rear = vi.fn() + const { canvas, waitTillNextFrame } = test(() => ( + <> + + + + + + + )) + await waitTillNextFrame() + + fire(canvas, "click") + + expect(front).not.toHaveBeenCalled() + expect(rear).toHaveBeenCalledTimes(1) + }) +}) + +/**********************************************************************************/ +/* */ +/* Clicking empty space: the canvas handler fires with no object */ +/* */ +/**********************************************************************************/ + +describe("clicking empty space is a void", () => { + it("a void fires onPointerMissed and nothing else", () => { + let missed = 0 + const { canvas } = test(() => null, { plugins: [engine], onPointerMissed: () => missed++ }) + + fire(canvas, "click") + + expect(missed).toBe(1) + }) +}) + +/**********************************************************************************/ +/* */ +/* Pointer capture */ +/* */ +/**********************************************************************************/ + +// Synthetic PointerEvents create no *active* OS pointer, so the real +// canvas.setPointerCapture would throw — mock it (we're testing dispatch, not +// OS routing). Off-mesh coordinates simulate the ray leaving the mesh; the +// meshes sit at the origin, so no frame wait is needed. +const MISS_X = 0 +const MISS_Y = 0 +const pointerAt = (type: string, x: number, y: number) => + new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true }) + +/** A 2×2 box at origin that captures on pointerdown, wrapped so an ancestor can observe. */ +function CapturingInGroup(props: { + groupMove?: (e: any) => void + groupUp?: (e: any) => void + meshStopsUp?: boolean +}) { + return ( + + e.setPointerCapture()} + onPointerUp={props.meshStopsUp ? (e: any) => e.stopPropagation() : undefined} + > + + + + + ) +} + +describe("a captured pointer's up/move still bubble to an ancestor handler unless stopped", () => { + it("a captured up reaches a root-group onPointerUp", () => { + const groupUp = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // captures the mesh + fireEvent(canvas, pointerAt("pointerup", MISS_X, MISS_Y)) // ray off the mesh + + expect(groupUp).toHaveBeenCalledTimes(1) // bubbled up the captured object's ancestor chain + }) + + it("a captured move reaches a root-group onPointerMove", () => { + const groupMove = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) + + expect(groupMove).toHaveBeenCalledTimes(1) + }) + + it("a captured mesh that stops propagation withholds the group onPointerUp", () => { + const groupUp = vi.fn() + const { canvas } = test(() => ) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) + fireEvent(canvas, pointerAt("pointerup", MISS_X, MISS_Y)) + + expect(groupUp).not.toHaveBeenCalled() + }) +}) + +describe("hover is frozen while captured", () => { + it("onPointerLeave does not fire while captured; it resumes after release", () => { + const move = vi.fn() + const leave = vi.fn() + const { canvas } = test(() => ( + e.setPointerCapture()} + onPointerMove={move} + onPointerLeave={leave} + > + + + + )) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointermove", HIT_X, HIT_Y)) // hover onto the mesh + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // capture + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // captured move, ray off the mesh + expect(move).toHaveBeenCalled() + expect(leave).not.toHaveBeenCalled() // hover frozen — no leave while captured + + fireEvent(canvas, new PointerEvent("lostpointercapture", { pointerId: 1, bubbles: true })) + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // released; ray still off the mesh + expect(leave).toHaveBeenCalledTimes(1) + }) +}) + +describe("releasePointerCapture() inside onPointerUp restores normal delivery", () => { + it("after release, an off-mesh move no longer reaches the once-captured mesh", () => { + const move = vi.fn() + const { canvas } = test(() => ( + e.setPointerCapture()} + onPointerUp={(e: any) => e.releasePointerCapture()} + onPointerMove={move} + > + + + + )) + vi.spyOn(canvas, "setPointerCapture").mockImplementation(() => {}) + + fireEvent(canvas, pointerAt("pointerdown", HIT_X, HIT_Y)) // capture + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // captured move (off mesh) reaches it + expect(move).toHaveBeenCalledTimes(1) + + fireEvent(canvas, pointerAt("pointerup", MISS_X, MISS_Y)) // releases capture + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // off mesh, no longer captured + expect(move).toHaveBeenCalledTimes(1) // not delivered again + }) +}) + +/**********************************************************************************/ +/* */ +/* onPointerEnter / onPointerLeave cannot be stopped */ +/* */ +/**********************************************************************************/ + +describe("enter and leave are non-stoppable", () => { + it("their events carry no stopPropagation", () => { + let enterEvent: any + let leaveEvent: any + const { canvas } = test(() => ( + (enterEvent = e)} + onPointerLeave={(e: any) => (leaveEvent = e)} + > + + + + )) + + fireEvent(canvas, pointerAt("pointermove", HIT_X, HIT_Y)) // enter + fireEvent(canvas, pointerAt("pointermove", MISS_X, MISS_Y)) // leave + + expect(enterEvent.stopPropagation).toBeUndefined() + expect(leaveEvent.stopPropagation).toBeUndefined() + }) +}) diff --git a/tests/core/pointer.test.tsx b/tests/events/pointer.test.tsx similarity index 82% rename from tests/core/pointer.test.tsx rename to tests/events/pointer.test.tsx index 53964d35..23041a86 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/events/pointer.test.tsx @@ -1,13 +1,30 @@ import { assertType, describe, expect, it, vi } from "vitest" import { type Intersection, Object3D, PerspectiveCamera, Ray, Vector3 } from "three" -import { createThreeEvent, Pointer, type PointerRaycaster } from "../../src/pointers.ts" +import { + createThreeEvent, + Pointer, + type PointerEngine, + type PointerRaycaster, +} from "../../src/events/index.ts" import { meta } from "../../src/utils.ts" function eventful(handlers: Record) { return meta(new Object3D(), { props: handlers }) as any as Object3D } -function ctx(eventRegistry: Object3D[], props: Record = {}) { - return { eventRegistry, props } as any +/** + * A stand-in for the engine a `Pointer` dispatches against: the registry it raycasts, + * the canvas-level miss handler, and the three context (of which only `camera` is read, + * and only when a capture has to synthesize a camera-facing drag plane). + */ +function engine( + registry: Object3D[], + options: { onPointerMissed?: (event: any) => void; camera?: PerspectiveCamera } = {}, +): PointerEngine { + return { + registry, + onPointerMissed: options.onPointerMissed, + context: { camera: options.camera }, + } as unknown as PointerEngine } // Mutable ray state a test tweaks between gestures. type RayState = { target?: Object3D; point?: Vector3; normal?: Vector3 } @@ -28,7 +45,6 @@ function fakeRaycaster(state: RayState): PointerRaycaster { } as any, ] : [], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } @@ -40,7 +56,7 @@ describe("Pointer dispatch", () => { const leave = vi.fn() const mesh = eventful({ onPointerEnter: enter, onPointerLeave: leave }) const state: { target?: Object3D } = { target: mesh } - const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + const pointer = new Pointer(engine([mesh]), fakeRaycaster(state)) pointer.move(new Event("pointermove")) expect(enter).toHaveBeenCalledTimes(1) @@ -53,7 +69,7 @@ describe("Pointer dispatch", () => { it("does not re-fire enter while staying on the object", () => { const enter = vi.fn() const mesh = eventful({ onPointerEnter: enter }) - const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh })) + const pointer = new Pointer(engine([mesh]), fakeRaycaster({ target: mesh })) pointer.move(new Event("pointermove")) pointer.move(new Event("pointermove")) @@ -66,7 +82,7 @@ describe("Pointer dispatch", () => { const parent = eventful({ onPointerMove: parentMove }) const child = eventful({ onPointerMove: childMove }) ;(child as any).parent = parent - const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) + const pointer = new Pointer(engine([child]), fakeRaycaster({ target: child })) pointer.move(new Event("pointermove")) expect(childMove).toHaveBeenCalledTimes(1) @@ -76,7 +92,7 @@ describe("Pointer dispatch", () => { it("fires onClick on the hit object", () => { const click = vi.fn() const mesh = eventful({ onClick: click }) - const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh })) + const pointer = new Pointer(engine([mesh]), fakeRaycaster({ target: mesh })) pointer.click("onClick", new MouseEvent("click")) expect(click).toHaveBeenCalledTimes(1) @@ -87,20 +103,24 @@ describe("Pointer dispatch", () => { 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 })) + const pointer = new Pointer(engine([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", () => { + it("fires onPointerMissed (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({})) + const mesh = eventful({ onPointerMissed: meshMissed }) + const pointer = new Pointer( + engine([mesh], { onPointerMissed: canvasMissed }), + fakeRaycaster({}), + ) pointer.click("onClick", new MouseEvent("click")) + expect(meshMissed).toHaveBeenCalledTimes(1) expect(canvasMissed).toHaveBeenCalledTimes(1) }) @@ -114,7 +134,7 @@ describe("Pointer dispatch", () => { 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 })) + const pointer = new Pointer(engine([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 @@ -151,7 +171,7 @@ describe("Pointer dispatch", () => { aim: () => {}, ray: new Ray(), } as any as PointerRaycaster - const pointer = new Pointer(ctx([childA, childB]), raycaster) + const pointer = new Pointer(engine([childA, childB]), raycaster) pointer.down(new Event("pointerdown")) expect(childADown).toHaveBeenCalledTimes(1) // each distinct hit still fires @@ -179,7 +199,7 @@ describe("Pointer dispatch", () => { aim: () => {}, ray: new Ray(), } as any as PointerRaycaster - const pointer = new Pointer(ctx([front, back]), raycaster) + const pointer = new Pointer(engine([front, back]), raycaster) pointer.move(new Event("pointermove")) expect(frontMove).toHaveBeenCalledTimes(1) @@ -196,7 +216,7 @@ 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) + const pointer = new Pointer(engine([mesh]), fakeRaycaster({ target: mesh }), sink) pointer.capture(mesh, { object: mesh, @@ -214,7 +234,7 @@ describe("Pointer capture lifecycle", () => { 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) + const pointer = new Pointer(engine([mesh]), fakeRaycaster({ target: mesh }), sink) pointer.capture(mesh, { object: mesh, @@ -228,7 +248,7 @@ describe("Pointer capture lifecycle", () => { it("capture(null) is a no-op", () => { const sink = spySink() - const pointer = new Pointer(ctx([]), fakeRaycaster({}), sink) + const pointer = new Pointer(engine([]), fakeRaycaster({}), sink) pointer.capture(null, {} as any) expect(sink.capture).not.toHaveBeenCalled() }) @@ -241,7 +261,7 @@ describe("Pointer capture lifecycle", () => { }), release: vi.fn(), } - const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh }), sink) + const pointer = new Pointer(engine([mesh]), fakeRaycaster({ target: mesh }), sink) expect(() => pointer.capture(mesh, { object: mesh, @@ -264,11 +284,10 @@ 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 } - const pointer = new Pointer(ctx([element]), raycaster) + const pointer = new Pointer(engine([element]), raycaster) pointer.capture(element, { object: hitLeaf, point: new Vector3(), @@ -289,11 +308,10 @@ 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 } - const pointer = new Pointer(ctx([mesh]), raycaster) + const pointer = new Pointer(engine([mesh]), raycaster) pointer.capture( mesh, { object: mesh, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } } as any, @@ -313,7 +331,7 @@ describe("Pointer capture lifecycle", () => { onPointerLeave: leave, }) const state: RayState = { target: mesh, point: new Vector3(), normal: new Vector3(0, 0, 1) } - const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + const pointer = new Pointer(engine([mesh]), fakeRaycaster(state)) pointer.move(new Event("pointermove")) // hover onto the mesh pointer.down(new Event("pointerdown")) // captures it @@ -340,11 +358,7 @@ describe("Pointer capture lifecycle", () => { 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, - ) + const pointer = new Pointer(engine([mesh], { camera }), fakeRaycaster({ target: mesh }), sink) pointer.down(new Event("pointerdown")) // handler only stashes expect(pointer.hasCaptured(mesh)).toBe(false) @@ -366,7 +380,7 @@ describe("Pointer capture lifecycle", () => { }) const other = eventful({ onPointerUp: otherUp }) const state: RayState = { target: captured, point: new Vector3(), normal: new Vector3(0, 0, 1) } - const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + const pointer = new Pointer(engine([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) // captures `captured` state.target = other // ray now hits `other` @@ -376,19 +390,6 @@ describe("Pointer capture lifecycle", () => { 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, 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({ @@ -402,7 +403,7 @@ describe("Pointer capture lifecycle", () => { normal: new Vector3(0, 0, 1), } const raycaster = fakeRaycaster(state) - const pointer = new Pointer(ctx([captured]), raycaster) + const pointer = new Pointer(engine([captured]), raycaster) pointer.down(new Event("pointerdown")) state.target = undefined @@ -422,7 +423,7 @@ describe("Pointer capture lifecycle", () => { }) const other = eventful({ onPointerUp: otherUp }) const state: RayState = { target: captured, point: new Vector3(), normal: new Vector3(0, 0, 1) } - const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + const pointer = new Pointer(engine([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) pointer.up(new Event("pointerup")) // captured up, then releases @@ -440,7 +441,7 @@ describe("Pointer capture lifecycle", () => { }) const other = eventful({ onPointerEnter: otherEnter, onPointerMove: vi.fn() }) const state: RayState = { target: captured, point: new Vector3(), normal: new Vector3(0, 0, 1) } - const pointer = new Pointer(ctx([captured, other]), fakeRaycaster(state)) + const pointer = new Pointer(engine([captured, other]), fakeRaycaster(state)) pointer.down(new Event("pointerdown")) state.target = other // ray now over `other` @@ -450,26 +451,10 @@ describe("Pointer capture lifecycle", () => { 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, 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, point: new Vector3(), normal: new Vector3(0, 0, 1) } - const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + const pointer = new Pointer(engine([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/events/raycaster-cast.test.tsx similarity index 98% rename from tests/core/raycaster-cast.test.tsx rename to tests/events/raycaster-cast.test.tsx index e4207bce..c796c7cd 100644 --- a/tests/core/raycaster-cast.test.tsx +++ b/tests/events/raycaster-cast.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, PerspectiveCamera, Vector2 } from "three" -import { CenterRaycaster, ControllerRaycaster, CursorRaycaster } from "../../src/raycasters.tsx" +import { CenterRaycaster, ControllerRaycaster, CursorRaycaster } from "../../src/events/index.ts" import { meta } from "../../src/utils.ts" function ctx(camera: PerspectiveCamera) { diff --git a/tests/events/raycaster-config.test.tsx b/tests/events/raycaster-config.test.tsx new file mode 100644 index 00000000..61f277cf --- /dev/null +++ b/tests/events/raycaster-config.test.tsx @@ -0,0 +1,184 @@ +import { fireEvent } from "@solidjs/testing-library" +import { ErrorBoundary } from "solid-js" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { CenterRaycaster, pointerEvents, useRaycaster } from "../../src/events/index.ts" +import type { ScreenRaycaster } from "../../src/events/raycasters.ts" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" + +/** + * The engine owns the raycaster — core has none. This file guards the two ways to reach + * it, and that both reach the SAME object, the one that actually picks: + * + * - `pointerEvents({ raycaster })` at setup: a config OBJECT (`{ far: … }`) applied to + * the engine's own `CursorRaycaster`, or a screen-raycaster INSTANCE (e.g. + * `CenterRaycaster`) used as the whole ray strategy. Plus the no-option default. + * - `useRaycaster()` at runtime: mutating the raycaster it hands back must change what gets + * picked. (Pushing a different raycaster over it — the stack — is `raycaster-stack.test.tsx`.) + */ + +// One engine instance, installed both into the namespace (so `T.Mesh` has pointer +// props) and onto the canvas (so the canvas-level `onPointerMissed` reaches it). +const engine = pointerEvents() +const T = createT(THREE, [engine]) + +// offsetX/Y that hits a mesh centred at origin (camera at z=5, canvas 1280×800). +const HIT_X = 640 +const HIT_Y = 400 + +// offsetX/Y that misses a centred mesh (top-left corner of canvas). +const MISS_X = 0 +const MISS_Y = 0 + +function clickAt(x: number, y: number) { + return new MouseEvent("click", { clientX: x, clientY: y, bubbles: true }) +} + +/** A 2×2×2 box at the origin — front face at z=1, so distance from the z=5 camera is 4. */ +function Box(props: { onClick?: (event: any) => void }) { + return ( + + + + + ) +} + +describe("pointerEvents({ raycaster: config }) configures the raycaster that actually picks", () => { + it("far excludes an object beyond it (the object sits at distance 4 from the camera)", () => { + const onClick = vi.fn() + const missed = vi.fn() + const configured = pointerEvents({ raycaster: { far: 3 } }) + const { canvas } = test(() => , { + plugins: [configured], + onPointerMissed: missed, + }) + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + + expect(onClick).not.toHaveBeenCalled() + expect(missed).toHaveBeenCalledTimes(1) + }) + + it("control: the same object is picked when far is wide enough to include it", () => { + const onClick = vi.fn() + const configured = pointerEvents({ raycaster: { far: 10, near: 1 } }) + const { canvas } = test(() => , { + plugins: [configured], + }) + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) + +describe("pointerEvents({ raycaster: instance }) is used directly by the engine", () => { + it("a CenterRaycaster instance keeps casting from screen centre, ignoring the cursor", () => { + const onClick = vi.fn() + const configured = pointerEvents({ raycaster: new CenterRaycaster() }) + const { canvas } = test(() => , { + plugins: [configured], + }) + + // Cursor is at the top-left corner — a cursor-based raycaster would miss — but + // CenterRaycaster always looks at screen centre, where the box sits. + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) + +describe("no raycaster option falls back to the engine's default CursorRaycaster", () => { + it("picks the object under the cursor", () => { + const onClick = vi.fn() + const { canvas } = test(() => , { plugins: [engine] }) + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("misses when the cursor is off the object", () => { + const onClick = vi.fn() + const { canvas } = test(() => , { plugins: [engine] }) + + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + + expect(onClick).not.toHaveBeenCalled() + }) +}) + +describe("useRaycaster()", () => { + /** + * The point of moving the raycaster into the engine. There is exactly ONE raycaster on + * the canvas now, so a runtime mutation through the hook lands on the object that + * picks. This test is what a second, inert raycaster (core's old `useThree().raycaster`, + * which nothing ever cast with) fails: the click after `far = 3` would still hit, and + * the final `toHaveBeenCalledTimes(1)` would read 2. + */ + it("far, set at runtime, changes what gets picked", () => { + const onClick = vi.fn() + const missed = vi.fn() + let raycaster: ScreenRaycaster | undefined + + const { canvas } = test( + () => { + raycaster = useRaycaster().raycaster() + return + }, + { plugins: [engine], onPointerMissed: missed }, + ) + + // Control: with the engine's default reach, the box (distance 4) is picked. + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + expect(onClick).toHaveBeenCalledTimes(1) + expect(missed).not.toHaveBeenCalled() + + if (!raycaster) throw new Error("useRaycaster() returned nothing") + raycaster.far = 3 // now shorter than the box's front face at distance 4 + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + expect(onClick).toHaveBeenCalledTimes(1) // still the one from before — the box is out of reach + expect(missed).toHaveBeenCalledTimes(1) + }) + + it("hands out the very raycaster the engine was configured with", () => { + const centerRaycaster = new CenterRaycaster() + const configured = pointerEvents({ raycaster: centerRaycaster }) + let seen: ScreenRaycaster | undefined + + test( + () => { + seen = useRaycaster().raycaster() + return null + }, + { plugins: [configured] }, + ) + + expect(seen).toBe(centerRaycaster) + }) + + it("throws on a canvas with no engine installed, rather than handing back an inert raycaster", () => { + let error: unknown + const Probe = () => { + useRaycaster() + return null + } + + test(() => ( + { + error = caught + return null + }} + > + + + )) + + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain("useRaycaster()") + }) +}) diff --git a/tests/events/raycaster-stack.test.tsx b/tests/events/raycaster-stack.test.tsx new file mode 100644 index 00000000..cc45465d --- /dev/null +++ b/tests/events/raycaster-stack.test.tsx @@ -0,0 +1,229 @@ +import { fireEvent } from "@solidjs/testing-library" +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { + CenterRaycaster, + CursorRaycaster, + pointerEvents, + useRaycaster, +} from "../../src/events/index.ts" +import type { ScreenRaycaster } from "../../src/events/raycasters.ts" +import { createT } from "../../src/index.ts" +import { test } from "../../src/testing/index.tsx" + +/** + * The raycaster is a STACK, owned by the engine. The engine's own raycaster sits at the + * bottom; a subtree pushes its own over it with `setRaycaster` and pops on unmount. + * + * What makes the stack real — and what these tests are here to hold — is that the engine + * reads the TOP OF THE STACK at cast/aim time rather than capturing a raycaster at install. + * So the assertions are about what gets HIT, not merely about which object the hook returns: + * a stack that doesn't change picking is a stack that does nothing. + */ + +const engine = pointerEvents() +const T = createT(THREE, [engine]) + +// offsetX/Y that hits a mesh centred at origin (camera at z=5, canvas 1280×800). +const HIT_X = 640 +const HIT_Y = 400 + +// offsetX/Y that misses a centred mesh (top-left corner of canvas) — for a cursor-aimed +// raycaster. A CenterRaycaster ignores the cursor and hits the box from here anyway. +const MISS_X = 0 +const MISS_Y = 0 + +function clickAt(x: number, y: number) { + return new MouseEvent("click", { clientX: x, clientY: y, bubbles: true }) +} + +/** A 2×2×2 box at the origin — front face at z=1, so distance from the z=5 camera is 4. */ +function Box(props: { onClick?: (event: any) => void }) { + return ( + + + + + ) +} + +/** A subtree that picks by gaze: pushes a `CenterRaycaster`, pops it when it unmounts. */ +function Gaze() { + const { setRaycaster } = useRaycaster() + const restore = setRaycaster(new CenterRaycaster()) + onCleanup(restore) + return null +} + +describe("setRaycaster() pushes a raycaster that actually picks", () => { + it("a pushed CenterRaycaster changes the hit result: a corner click now reaches the centred box", () => { + const onClick = vi.fn() + const [gazing, setGazing] = createSignal(false) + + const { canvas } = test( + () => ( + <> + + + + + + ), + { plugins: [engine] }, + ) + + // Bottom of the stack: the engine's default CursorRaycaster. The corner misses. + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + expect(onClick).not.toHaveBeenCalled() + + setGazing(true) + + // The pushed CenterRaycaster aims from the screen centre whatever the cursor does — + // so the very same corner click now hits the box. + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("a pushed raycaster with a short `far` puts an in-reach object out of reach", () => { + const onClick = vi.fn() + const [limiting, setLimiting] = createSignal(false) + + const ShortReach = () => { + const { setRaycaster } = useRaycaster() + const shortReach = new CursorRaycaster() + shortReach.far = 3 // the box's front face is at distance 4 + const restore = setRaycaster(shortReach) + onCleanup(restore) + return null + } + + const { canvas } = test( + () => ( + <> + + + + + + ), + { plugins: [engine] }, + ) + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + expect(onClick).toHaveBeenCalledTimes(1) + + setLimiting(true) + + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + expect(onClick).toHaveBeenCalledTimes(1) // still the one from before — out of reach now + }) +}) + +describe("popping the stack restores the previous raycaster", () => { + it("unmounting the overriding subtree reverts picking to the raycaster underneath", () => { + const onClick = vi.fn() + const [gazing, setGazing] = createSignal(false) + + const { canvas } = test( + () => ( + <> + + + + + + ), + { plugins: [engine] }, + ) + + setGazing(true) + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + expect(onClick).toHaveBeenCalledTimes(1) // gaze: the corner click hits + + setGazing(false) // the subtree unmounts, popping its raycaster + + fireEvent(canvas, clickAt(MISS_X, MISS_Y)) + expect(onClick).toHaveBeenCalledTimes(1) // back to the cursor: the corner misses again + + // And the raycaster underneath is picking again, not merely present. + fireEvent(canvas, clickAt(HIT_X, HIT_Y)) + expect(onClick).toHaveBeenCalledTimes(2) + }) + + it("restores the engine's OWN raycaster — the bottom of the stack — not some fresh default", () => { + const gazeBase = new CenterRaycaster() + const configured = pointerEvents({ raycaster: gazeBase }) + const [overriding, setOverriding] = createSignal(false) + const seen: ScreenRaycaster[] = [] + + const Override = () => { + const { setRaycaster } = useRaycaster() + const restore = setRaycaster(new CursorRaycaster()) + onCleanup(restore) + return null + } + + test( + () => { + const { raycaster } = useRaycaster() + createEffect(() => seen.push(raycaster())) + return ( + + + + ) + }, + { plugins: [configured] }, + ) + + expect(seen.at(-1)).toBe(gazeBase) + + setOverriding(true) + expect(seen.at(-1)).toBeInstanceOf(CursorRaycaster) + + setOverriding(false) + expect(seen.at(-1)).toBe(gazeBase) // the very object the engine was configured with + }) +}) + +describe("raycaster() is an accessor, not a getter", () => { + /** + * The maintainer destructures the hook's result. A getter on the returned object would + * snapshot the raycaster at destructuring time and silently lose reactivity — so the + * accessor is destructured HERE, before anything is pushed, and must still observe the + * push that happens later, from a subtree it knows nothing about. + */ + it("survives destructuring and reactively tracks the top of the stack", () => { + const [gazing, setGazing] = createSignal(false) + const seen: ScreenRaycaster[] = [] + + test( + () => { + const { raycaster } = useRaycaster() // destructured up front + createEffect(() => seen.push(raycaster())) + return ( + + + + ) + }, + { plugins: [engine] }, + ) + + expect(seen).toHaveLength(1) + const base = seen[0] + expect(base).toBeInstanceOf(CursorRaycaster) + + setGazing(true) + + // The effect re-ran: the destructured accessor saw the push. + expect(seen).toHaveLength(2) + expect(seen[1]).toBeInstanceOf(CenterRaycaster) + + setGazing(false) + + expect(seen).toHaveLength(3) + expect(seen[2]).toBe(base) + }) +}) diff --git a/tests/published/consumer.test.tsx b/tests/published/consumer.test.tsx new file mode 100644 index 00000000..5184209e --- /dev/null +++ b/tests/published/consumer.test.tsx @@ -0,0 +1,117 @@ +/** + * Does the PUBLISHED package actually WORK at runtime, across subpath exports? + * + * This is the runtime twin of `consumer/across-subpaths.tsx` (the type fixture), and it + * exists for the same reason: every other test in this repo imports from `src/`, which is + * ONE module graph by construction, so no test could ever see a defect that only appears + * once the code has been split into shipped bundles. + * + * A real one shipped. `tsup` built each entry point as its own standalone bundle, so + * `dist/events.js` inlined a SECOND COPY of core — including `src/constants.ts`'s + * `$S3C = Symbol("solid-three")`. Core's `meta()` (out of `dist/index.js`) branded objects + * with copy A's `$S3C`; the engine's `getMeta()` (out of `dist/events.js`) looked up copy + * B's, read `undefined`, and `register()` early-returned. The engine's registry stayed + * empty and POINTER EVENTS SILENTLY NEVER FIRED for anyone consuming the package. The + * whole suite stayed green throughout. The exact same duplication also gave each bundle a + * private `engines` WeakMap, so a shared brand alone would not have saved it — the modules + * themselves have to be shared. See `tsup.config.ts`. + * + * So: no `src/` import and no path alias below. `solid-three` and `solid-three/events` + * resolve the way they resolve for a dependent — through this package's `exports` map, + * onto the BUILT files in `dist/`. (`node_modules/solid-three` is a self-link back to the + * repo root; see the `solid-three` devDependency.) It follows that this test needs + * `pnpm build` to have run: `pnpm test:published` does that for you. + * + * Keep the assertion behavioural — a handler either fires or it doesn't. Asserting on the + * shape of the bundles instead would just re-test the bundler. + */ +import { render } from "@solidjs/testing-library" +import { Canvas, createT } from "solid-three" +import { pointerEvents } from "solid-three/events" +import * as THREE from "three" +import { afterEach, describe, expect, it, vi } from "vitest" + +/** The canvas the click coordinates below assume. Mirrors the `test()` harness's default. */ +const CANVAS_WIDTH = 1280 +const CANVAS_HEIGHT = 800 + +const pointerEventsPlugin = pointerEvents() +const T = createT(THREE, [pointerEventsPlugin]) + +const hosts: HTMLDivElement[] = [] + +afterEach(() => { + for (const host of hosts) host.remove() + hosts.length = 0 +}) + +/** + * `` fills its parent, so the parent is what fixes the canvas's size and position. + * Pinned to the viewport's top-left so `clientX`/`clientY` and the canvas's `offsetX`/ + * `offsetY` (what the engine turns into NDC) are the same numbers. + */ +function createHost() { + const host = document.createElement("div") + host.style.position = "fixed" + host.style.top = "0px" + host.style.left = "0px" + host.style.width = `${CANVAS_WIDTH}px` + host.style.height = `${CANVAS_HEIGHT}px` + document.body.appendChild(host) + hosts.push(host) + return host +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve())) + +/** + * `` sizes its canvas from a `ResizeObserver`, so the canvas is 0×0 for the first + * few frames after mount and a click dispatched then maps to a garbage ray. Wait for the + * size the click coordinates assume, then give the scene one more frame to render. + */ +async function waitForSizedCanvas(host: HTMLDivElement): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + const canvas = host.querySelector("canvas") + if (canvas) { + const bounds = canvas.getBoundingClientRect() + if (bounds.width === CANVAS_WIDTH && bounds.height === CANVAS_HEIGHT) { + await nextFrame() + return canvas + } + } + await nextFrame() + } + throw new Error(" never sized its canvas — the test cannot aim a click at it.") +} + +describe("the built package, resolved through its exports map", () => { + it("fires a mesh's onClick — core and the engine share one copy of core's module state", async () => { + const handleClick = vi.fn() + const host = createHost() + + render( + () => ( + + + + + + + ), + { container: host }, + ) + + const canvas = await waitForSizedCanvas(host) + + // Straight at the centre, where the 2×2 box sits with the camera at z=5. + canvas.dispatchEvent( + new MouseEvent("click", { + clientX: CANVAS_WIDTH / 2, + clientY: CANVAS_HEIGHT / 2, + bubbles: true, + }), + ) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/utils/pointer-utils.ts b/tests/utils/pointer-utils.ts new file mode 100644 index 00000000..c1a92452 --- /dev/null +++ b/tests/utils/pointer-utils.ts @@ -0,0 +1,55 @@ +import { fireEvent } from "@solidjs/testing-library" + +/** + * offsetX/Y that hits a 2×2 `BoxGeometry` centred at the origin, with the camera at + * z=5 (the `test()` harness's default camera) on the harness's default 1280x800 + * canvas (see `createTestCanvas` in `src/testing/index.tsx`). The canvas is mounted + * at (0, 0) in `document.body`, so clientX/Y === offsetX/Y. + */ +export const CANVAS_CENTRE_X = 640 +export const CANVAS_CENTRE_Y = 400 + +/** Builds a `click` `MouseEvent` at the given client coordinates. */ +export function makeClickAt(clientX: number, clientY: number) { + return new MouseEvent("click", { clientX, clientY, bubbles: true }) +} + +/** + * Fires the event sequence a real browser produces for a primary-button click: the + * pointer MOVES to the spot, then `pointerdown`, `pointerup`, and finally `click`. + * + * Every part of that sequence is load-bearing for one engine or the other: + * - the reference engine reads the native `click`; + * - `@pmndrs/pointer-events` never listens for `click` at all — it hears only + * `pointerdown`/`pointerup` and synthesises the click itself from that pair; + * - and pmndrs recomputes a live pointer's intersection on `pointermove`, so without the + * move a second click at a NEW location would still dispatch against the OLD one. + * + * Firing the true sequence is what lets ONE helper drive both engines, rather than each + * engine growing its own bespoke dispatch in its own suite. + */ +export function clickAt(canvas: HTMLCanvasElement, clientX: number, clientY: number) { + const pointer = { + clientX, + clientY, + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + button: 0, + bubbles: true, + } + fireEvent(canvas, new PointerEvent("pointermove", { ...pointer, buttons: 0 })) + fireEvent(canvas, new PointerEvent("pointerdown", { ...pointer, buttons: 1 })) + fireEvent(canvas, new PointerEvent("pointerup", { ...pointer, buttons: 0 })) + fireEvent(canvas, makeClickAt(clientX, clientY)) +} + +/** + * Clicks the centre of `canvas` — where a 2×2 `BoxGeometry` centred at the origin sits, + * given the harness's default camera and canvas size. Lifted out of + * `tests/events/events.test.tsx` so both the engines' own suites and the core boundary + * suite dispatch clicks the same way. + */ +export function clickCanvasCentre(canvas: HTMLCanvasElement) { + clickAt(canvas, CANVAS_CENTRE_X, CANVAS_CENTRE_Y) +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..a1114579 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,27 @@ +{ + // Declaration build. `tsup` emits the JavaScript; this emits every `.d.ts`. + // + // It has to be ONE compilation covering all three entry points, with the module + // structure preserved. Rolling each entry point up on its own (which is what + // `tsup`'s `dts` option did) gives every bundle a private copy of `$S3C` — and a + // `unique symbol` is nominal, so `solid-three`'s `Meta`/`Context`/`Plugin` and + // `solid-three/events`' identically-spelled ones are then unrelated types. A + // consumer could not pass `pointerEvents()` to `createT` at all. + // + // Emitting the whole source tree at once instead means `$S3C` is declared exactly + // once, in `types/constants.d.ts`, and every entry point imports that one symbol. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "types", + "sourceMap": false + }, + "include": ["src"], + // `src/events-pmndrs` is deliberately UNSHIPPED — no `exports` entry, no `dist` bundle. + // Left in, `tsc` would still emit `types/events-pmndrs/index.d.ts`, which `files: ["types/**"]` + // publishes and which imports `@pmndrs/pointer-events` — a devDependency, absent from a + // consumer's install. That is a dangling reference in the tarball for code nothing can reach. + "exclude": ["src/events-pmndrs"] +} diff --git a/tsconfig.json b/tsconfig.json index dec193ad..4681935c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,8 @@ "target": "ESNext", "verbatimModuleSyntax": true }, - "exclude": ["dist", "build", "node_modules", "demo", "demo-xr"] + // `types` is build output (the emitted declarations). `consumer` is checked by its + // own tsconfig instead of this one — on purpose: it must not inherit this config's + // `allowImportingTsExtensions`, which no real consumer sets. + "exclude": ["dist", "types", "build", "node_modules", "demo", "demo-xr", "consumer"] } diff --git a/tsup.config.ts b/tsup.config.ts index 581f330b..31c6c432 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,58 +1,87 @@ import { solidPlugin } from "esbuild-plugin-solid" import { defineConfig, type Options } from "tsup" -type Entry = { readonly entry: string; readonly name: string } type Variation = { readonly dev: boolean; readonly solid: boolean } +/** + * One build PER VARIATION, with all three entry points in it — not one build per + * (entry point × variation). + * + * The three entry points share core modules (`constants.ts`, `utils.ts`, `plugin.ts`, + * …). Building each one on its own made every bundle carry a PRIVATE COPY of that + * shared code, and a copied module means copied module-level STATE: a second + * `Symbol("solid-three")` for `$S3C`, a second `engines` WeakMap, a second loader + * cache. Core's `meta()` (from `dist/index.js`) then branded objects with copy A's + * `$S3C` while the engine's `getMeta()` (from `dist/events.js`) looked up copy B's — + * read `undefined`, never registered the object, and pointer events silently never + * fired for anyone consuming the PUBLISHED package. (The repo's own suites import from + * `src/`, a single module graph, so they could not see it. + * `tests/published/consumer.test.tsx` is the one that can: it resolves through the + * `exports` map onto these files.) + * + * Putting all three entry points in one build lets esbuild's code splitting hoist what + * they share into a chunk that each of them IMPORTS, so there is exactly one copy of + * every core module at runtime. Each variation is its own build (so its own chunks), + * which is correct: a consumer resolves every subpath under the same conditions, so it + * only ever loads entry points from one variation. + * + * This mirrors the fix already made for the declarations — see `tsconfig.build.json`, + * where one `tsc` pass over the whole source tree replaced tsup's per-entry `dts` + * rollup for exactly the same reason (a private `$S3C` per bundle). That is also why + * there is still no `dts` option here. + */ export default defineConfig(config => { const watching = !!config.watch - const packageEntries: Entry[] = [ - { entry: "src/index.ts", name: "index" }, - { entry: "src/testing/index.tsx", name: "testing" }, + const variations: Variation[] = [ + { dev: false, solid: false }, + { dev: true, solid: false }, + { dev: true, solid: true }, ] - return packageEntries.flatMap(({ entry, name }, i) => { - const packageEntries: Variation[] = [ - { dev: false, solid: false }, - { dev: true, solid: false }, - { dev: true, solid: true }, - ] + return variations.map(({ dev, solid }, index) => { + const suffix = `${dev ? ".dev" : ""}${solid ? ".solid" : ""}` - return packageEntries.flatMap(({ dev, solid }, j) => { - const outFilename = `${name}${dev ? ".dev" : ""}${solid ? ".solid" : ""}` + return { + watch: watching, + target: "esnext", + format: "esm", + clean: index === 0, + entry: { + [`index${suffix}`]: "src/index.ts", + [`events${suffix}`]: "src/events/index.ts", + [`testing${suffix}`]: "src/testing/index.tsx", + }, + // The load-bearing option: without it esbuild inlines the shared core into each + // entry point instead of emitting it once as an imported chunk. + splitting: true, + treeshake: watching ? undefined : { preset: "safest" }, + replaceNodeEnv: true, + esbuildOptions(options) { + options.define = { + ...options.define, + "process.env.NODE_ENV": dev ? `"development"` : `"production"`, + "process.env.PROD": dev ? "false" : "true", + "process.env.DEV": dev ? "true" : "false", + "import.meta.env.NODE_ENV": dev ? `"development"` : `"production"`, + "import.meta.env.PROD": dev ? "false" : "true", + "import.meta.env.DEV": dev ? "true" : "false", + } + options.jsx = "preserve" + // All variations emit into the same `dist`, so name the shared chunks per + // variation. Content hashes alone would already keep them apart, but this keeps + // it obvious which chunk belongs to which set of entry points. + options.chunkNames = `chunk${suffix}-[hash]` - return { - watch: watching, - target: "esnext", - format: "esm", - clean: i === 0, - dts: j === 0, - entry: { [outFilename]: entry }, - treeshake: watching ? undefined : { preset: "safest" }, - replaceNodeEnv: true, - esbuildOptions(options) { - options.define = { - ...options.define, - "process.env.NODE_ENV": dev ? `"development"` : `"production"`, - "process.env.PROD": dev ? "false" : "true", - "process.env.DEV": dev ? "true" : "false", - "import.meta.env.NODE_ENV": dev ? `"development"` : `"production"`, - "import.meta.env.PROD": dev ? "false" : "true", - "import.meta.env.DEV": dev ? "true" : "false", - } - options.jsx = "preserve" + if (!dev) options.drop = ["console", "debugger"] - if (!dev) options.drop = ["console", "debugger"] - - return options - }, - outExtension: ({ format }) => { - if (format === "esm" && solid) return { js: ".jsx" } - return {} - }, - esbuildPlugins: !solid ? [solidPlugin() as any] : undefined, - } satisfies Options - }) + return options + }, + outExtension: ({ format }) => { + if (format === "esm" && solid) return { js: ".jsx" } + return {} + }, + esbuildPlugins: !solid ? [solidPlugin() as any] : undefined, + } satisfies Options }) }) diff --git a/vitest.config.ts b/vitest.config.ts index c3a1ee3f..36d6c11a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,23 @@ import { playwright } from "@vitest/browser-playwright" import solidPlugin from "vite-plugin-solid" -import { defineConfig } from "vitest/config" +import { configDefaults, defineConfig } from "vitest/config" export default defineConfig({ plugins: [solidPlugin({ hot: false })], + // `process` doesn't exist as a global in the real browser tests run in (no + // Node, no polyfill) — tsup's build defines `process.env.DEV` for the + // shipped dist, but the raw source under test never goes through that + // esbuild `define` step. Mirror it here so dev-gated code (e.g. plugin + // collision warnings) can use `process.env.DEV` and still run under test. + define: { + "process.env.DEV": "true", + }, test: { include: ["tests/**/*.test.{ts,tsx}"], + // `tests/published` runs against the BUILT package, not `src/`, so it needs a build + // to have happened and its own resolution rules. It has its own config and its own + // script — `pnpm test:published`. See `vitest.published.config.ts`. + exclude: [...configDefaults.exclude, "tests/published/**"], setupFiles: ["./tests/setup.ts"], // `vite-plugin-solid` defaults `test.environment` to `'jsdom'` whenever // the user doesn't set one, which makes vitest exit 1 because jsdom diff --git a/vitest.published.config.ts b/vitest.published.config.ts new file mode 100644 index 00000000..4999078e --- /dev/null +++ b/vitest.published.config.ts @@ -0,0 +1,44 @@ +import { playwright } from "@vitest/browser-playwright" +import solidPlugin from "vite-plugin-solid" +import { defineConfig } from "vitest/config" + +/** + * The packaging-boundary suite: `tests/published`, run against the BUILT package. + * + * Separate from `vitest.config.ts` — which owns every other suite — because these two + * configs deliberately disagree about what `solid-three` means. Everything else imports + * `src/` directly and needs no resolution at all; `tests/published` imports the bare + * specifiers `solid-three` and `solid-three/events` and must resolve them through the + * `exports` map onto `dist/`, exactly as a dependent does. So: no path aliases here, and + * nothing that would quietly redirect those specifiers back to source. `tests/published` + * is excluded from the main config for the same reason. + * + * (The suite is not called `tests/dist`, which is what it tests, because the root + * `.gitignore` ignores `dist` at any depth — the files would never have been committed.) + * + * It needs `pnpm build` to have run first. Use `pnpm test:published`, which builds. + */ +export default defineConfig({ + // Compiles the JSX in `dist/*.solid.jsx` — the whole point of the `solid` export + // condition is that the CONSUMER's Solid compiler does this, so a consumer-shaped test + // has to do it too. + plugins: [solidPlugin({ hot: false })], + test: { + include: ["tests/published/**/*.test.{ts,tsx}"], + // No `setupFiles`: the shared setup imports from `src/`, which would pull a second + // copy of the library into this suite's module graph — the very thing under test. + environment: "node", + // Real WebGL contexts are GPU-process-limited; see `vitest.config.ts`. + fileParallelism: false, + browser: { + enabled: true, + provider: playwright({ + launchOptions: { + args: ["--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-features=Vulkan"], + }, + }), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, +})