From 6f54ab09014e304d5a3bd647fb2320fd82c861b3 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Sat, 15 Aug 2026 18:57:36 +0200 Subject: [PATCH] feat: add DepthPicking component and useDepthPicking hook - #288 Extracted from Autofocus into a standalone, reusable primitive for reading world-space positions off the depth buffer. Also splits DepthPicking and N8AO into a new src/passes directory, since neither wraps a postprocessing Effect. --- docs/effects/autofocus.mdx | 2 +- docs/passes/depth-picking.mdx | 73 +++++++++ src/effects/Autofocus.tsx | 81 +++------- src/index.ts | 4 +- src/passes/DepthPicking.tsx | 63 ++++++++ src/{effects => passes}/N8AO.tsx | 0 src/tests/DepthPicking.test.tsx | 250 +++++++++++++++++++++++++++++++ src/tests/N8AO.test.tsx | 2 +- src/tests/effects.smoke.test.tsx | 58 ++++--- 9 files changed, 453 insertions(+), 80 deletions(-) create mode 100644 docs/passes/depth-picking.mdx create mode 100644 src/passes/DepthPicking.tsx rename src/{effects => passes}/N8AO.tsx (100%) create mode 100644 src/tests/DepthPicking.test.tsx diff --git a/docs/effects/autofocus.mdx b/docs/effects/autofocus.mdx index 159884c6..3f0974d3 100644 --- a/docs/effects/autofocus.mdx +++ b/docs/effects/autofocus.mdx @@ -5,7 +5,7 @@ nav: 1 An auto-focus effect, that extends ``. -Based on [ektogamat/AutoFocusDOF](https://github.com/ektogamat/AutoFocusDOF). +Based on [ektogamat/AutoFocusDOF](https://github.com/ektogamat/AutoFocusDOF). Built on `` and `useDepthPicking` internally - use those directly if you want a picked position for something other than ``'s own focus target. ```tsx export type AutofocusProps = typeof DepthOfField & { diff --git a/docs/passes/depth-picking.mdx b/docs/passes/depth-picking.mdx new file mode 100644 index 00000000..dac57f01 --- /dev/null +++ b/docs/passes/depth-picking.mdx @@ -0,0 +1,73 @@ +--- +title: DepthPicking +nav: 1 +--- + +Mounts a `postprocessing` `DepthPickingPass` and exposes its `readDepth` via ref - nothing else. Renders nothing, updates nothing automatically. Pair it with `useDepthPicking` for a world-space position instead of raw depth. + +`` is built on both of these - use them directly when you want a picked position for something other than ``'s own focus target. + +```tsx + + + +``` + +Ref-api: + +```tsx +type DepthPickingApi = { + readDepth: (ndc: THREE.Vector2 | THREE.Vector3) => Promise +} +``` + +## `useDepthPicking` + +```tsx +function useDepthPicking( + pass: RefObject, + camera?: THREE.Camera, // defaults to the composer's camera, then r3f's own +): (x: number, y: number) => Promise +``` + +A hook that turns a screen position into a world-space point, using a mounted ``'s `readDepth`. Returns `false` if nothing was hit. Call it whenever you want - on click, on hover, every frame - nothing runs on its own. It only needs a ref to the mounted pass, not the `` context itself, so it works anywhere under ``: + +```tsx +const pickRef = useRef(null) + +function Cursor() { + const getHit = useDepthPicking(pickRef) + const meshRef = useRef(null) + useFrame(async ({ pointer }) => { + const hit = await getHit(pointer.x, pointer.y) + if (hit) meshRef.current?.position.copy(hit) + }) + return ( + + + {/* depthWrite false - see the warning below */} + + + ) +} + +return ( + <> + + + + + +) +``` + +It unprojects using the ``'s own camera when called from inside it - the same one the pass renders depth from - falling back to r3f's own camera otherwise. Called from outside that `` (like `Cursor` above) with a non-default camera, pass that same camera as the second argument explicitly. + +**If you render something at the picked position** (a cursor, a placement preview, ...), give its material `depthWrite={false}`. Depth is read from the same buffer everything else renders into - without this, your own marker sits at the last hitpoint, gets sampled by the *next* pick as the closest surface there, and the marker creeps toward the camera every frame, faster as it gets closer, until it resets and repeats. (`Autofocus`'s own debug markers already do this.) + +Click-to-pick instead of every frame: + +```tsx +const hit = await getHit(pointerNdcX, pointerNdcY) +if (hit) character.moveTo(hit) +``` diff --git a/src/effects/Autofocus.tsx b/src/effects/Autofocus.tsx index eee8477a..d061655d 100644 --- a/src/effects/Autofocus.tsx +++ b/src/effects/Autofocus.tsx @@ -1,11 +1,9 @@ import { createPortal, useFrame, useThree, type Vector3 as R3FVector3 } from '@react-three/fiber' import { easing } from 'maath' -import { DepthOfFieldEffect, DepthPickingPass } from 'postprocessing' +import { DepthOfFieldEffect } from 'postprocessing' import { Ref, useCallback, - useContext, - useEffect, useImperativeHandle, useMemo, useRef, @@ -15,7 +13,7 @@ import { } from 'react' import { Mesh, Vector3 } from 'three' -import { EffectComposerContext } from '../EffectComposer' +import { DepthPicking, useDepthPicking, type DepthPickingApi } from '../passes/DepthPicking' import { DepthOfField } from './DepthOfField' export type AutofocusProps = Omit, 'ref'> & { @@ -47,49 +45,22 @@ export function Autofocus({ ...props }: AutofocusProps) { const dofRef = useRef(null) - const hitpointRef = useRef(null) - const targetRef = useRef(null) + const pickRef = useRef(null) + const getHit = useDepthPicking(pickRef) + const hitpointMarkerRef = useRef(null) + const dofTargetMarkerRef = useRef(null) const scene = useThree(({ scene }) => scene) const pointer = useThree(({ pointer }) => pointer) - const { composer, camera } = useContext(EffectComposerContext) - const [depthPickingPass] = useState(() => new DepthPickingPass()) - useEffect(() => { - // Fixed early index (right after RenderPass, which is always index 0), - // not appended - so this never risks becoming the structurally-last - // pass and silently stealing renderToScreen from whatever the real - // last pass is, regardless of what else adds/removes passes and when. - composer.addPass(depthPickingPass, 1) - return () => { - composer.removePass(depthPickingPass) - } - }, [composer, depthPickingPass]) - - useEffect(() => { - return () => { - depthPickingPass.dispose() - } - }, [depthPickingPass]) - - const [hitpoint] = useState(() => new Vector3(0, 0, 0)) - - const [ndc] = useState(() => new Vector3(0, 0, 0)) - const getHit = useCallback( - async (x: number, y: number) => { - ndc.x = x - ndc.y = y - ndc.z = await depthPickingPass.readDepth(ndc) - ndc.z = ndc.z * 2.0 - 1.0 - const hit = 1 - ndc.z > 0.0000001 // it is missed if ndc.z is close to 1 - return hit ? ndc.unproject(camera) : false - }, - [ndc, depthPickingPass, camera] - ) + // A stable non-null value, purely to enable DepthOfField's own autoFocus + // mode (`target != null`) - the actual per-frame value is applied + // imperatively to dofRef.current.target below. + const [autoFocusMarker] = useState(() => new Vector3()) + const [hitpoint] = useState(() => new Vector3()) const update = useCallback( async (delta: number, updateTarget = true) => { - // Update hitpoint if (target) { hitpoint.set(...(target as unknown as [number, number, number])) } else { @@ -98,7 +69,6 @@ export function Autofocus({ if (hit) hitpoint.copy(hit) } - // Update target if (updateTarget && dofRef.current?.target) { if (smoothTime > 0 && delta > 0) { easing.damp3(dofRef.current.target, hitpoint, smoothTime, delta) @@ -107,42 +77,37 @@ export function Autofocus({ } } }, - [target, hitpoint, followMouse, getHit, smoothTime, pointer] + [target, hitpoint, followMouse, pointer, getHit, smoothTime] ) - useFrame(async (_, delta) => { + useFrame((_, delta) => { if (!manual) { update(delta) } - if (hitpointRef.current) { - hitpointRef.current.position.copy(hitpoint) + if (hitpointMarkerRef.current) { + hitpointMarkerRef.current.position.copy(hitpoint) } - if (targetRef.current && dofRef.current?.target) { - targetRef.current.position.copy(dofRef.current.target) + if (dofTargetMarkerRef.current && dofRef.current?.target) { + dofTargetMarkerRef.current.position.copy(dofRef.current.target) } }) // Ref API - const api = useMemo( - () => ({ - dofRef, - hitpoint, - update, - }), - [hitpoint, update] - ) + const api = useMemo(() => ({ dofRef, hitpoint, update }), [hitpoint, update]) useImperativeHandle(ref, () => api, [api]) return ( <> + + {debug ? createPortal( <> - + - + @@ -151,7 +116,7 @@ export function Autofocus({ ) : null} - + ) } diff --git a/src/index.ts b/src/index.ts index e87e5d67..860ac4d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,5 +39,5 @@ export * from './effects/ToneMapping' export * from './effects/Vignette' export * from './effects/Water' -// These are not effect passes -export * from './effects/N8AO' +export * from './passes/DepthPicking' +export * from './passes/N8AO' diff --git a/src/passes/DepthPicking.tsx b/src/passes/DepthPicking.tsx new file mode 100644 index 00000000..b9bf1774 --- /dev/null +++ b/src/passes/DepthPicking.tsx @@ -0,0 +1,63 @@ +import { useThree } from '@react-three/fiber' +import { CopyPass, DepthPickingPass as DepthPickingPassImpl } from 'postprocessing' +import { use, useCallback, useEffect, useImperativeHandle, useState, type Ref } from 'react' +import type { Camera } from 'three' +import { Vector2, Vector3 } from 'three' +import { EffectComposerContext } from '../EffectComposer' +import { useDispose } from '../util' + +export type DepthPickingApi = { + readDepth: (ndc: Vector2 | Vector3) => Promise +} + +export type DepthPickingProps = { + ref?: Ref +} + +// Mounts a vanilla `postprocessing` DepthPickingPass and exposes its +// `readDepth` via ref - nothing else. Renders nothing. Pair with +// `useDepthPicking` for a world-space position instead of raw depth. +export function DepthPicking({ ref }: DepthPickingProps) { + const { composer } = use(EffectComposerContext) + + const [depthPickingPass] = useState(() => new DepthPickingPassImpl()) + const [copyPass] = useState(() => new CopyPass()) + useEffect(() => { + composer.addPass(depthPickingPass) + composer.addPass(copyPass) + return () => { + composer.removePass(depthPickingPass) + composer.removePass(copyPass) + } + }, [composer, depthPickingPass, copyPass]) + + useDispose(depthPickingPass) + useDispose(copyPass) + + useImperativeHandle(ref, () => ({ readDepth: (ndc) => depthPickingPass.readDepth(ndc) }), [depthPickingPass]) + + return null +} + +export function useDepthPicking(pass: React.RefObject, camera?: Camera) { + const composerCamera = use(EffectComposerContext)?.camera + const defaultCamera = useThree((state) => state.camera) + const resolvedCamera = camera ?? composerCamera ?? defaultCamera + const [ndc] = useState(() => new Vector3()) + + return useCallback( + async (x: number, y: number): Promise => { + if (!pass.current) return false + ndc.x = x + ndc.y = y + ndc.z = await pass.current.readDepth(ndc) + ndc.z = ndc.z * 2.0 - 1.0 + const hit = 1 - ndc.z > 0.0000001 // missed if ndc.z is close to 1 + // clone - unproject mutates in place, and ndc is reused across calls, + // so returning it directly would hand out a reference that changes + // under the caller on the next pick. + return hit ? ndc.clone().unproject(resolvedCamera) : false + }, + [pass, resolvedCamera, ndc] + ) +} diff --git a/src/effects/N8AO.tsx b/src/passes/N8AO.tsx similarity index 100% rename from src/effects/N8AO.tsx rename to src/passes/N8AO.tsx diff --git a/src/tests/DepthPicking.test.tsx b/src/tests/DepthPicking.test.tsx new file mode 100644 index 00000000..4aa04f17 --- /dev/null +++ b/src/tests/DepthPicking.test.tsx @@ -0,0 +1,250 @@ +import { useThree } from '@react-three/fiber' +import { DepthPickingPass as DepthPickingPassImpl, EffectComposer as EffectComposerImpl, EffectPass, RenderPass } from 'postprocessing' +import * as React from 'react' +import * as THREE from 'three' +import { describe, expect, it } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { Noise } from '../effects/Noise' +import { DepthPicking, useDepthPicking, type DepthPickingApi } from '../passes/DepthPicking' +import { flush, root, strict, waitForComposer } from './test-utils' + +describe('DepthPicking', () => { + it('adds its pass after RenderPass', async () => { + const composerRef = React.createRef() + + await React.act(async () => root.render({})) + const composer = await waitForComposer(composerRef) + await flush() + + expect(composer.passes[0]).toBeInstanceOf(RenderPass) + expect(composer.passes.slice(1)).toContainEqual(expect.any(DepthPickingPassImpl)) + + await React.act(async () => root.render(null)) + }) + + // Depth reads are position-independent (postprocessing's stable depth + // texture is populated once per frame off RenderPass, not off wherever + // DepthPicking's own pass happens to land) - verified against real + // geometry in test-env, not just structurally here. enableNormalPass adds + // a NormalPass right after RenderPass too, shifting what "index 1" used + // to mean back when this was added at a fixed index. + it('still keeps its trailing CopyPass owning renderToScreen with enableNormalPass and other effects around it', async () => { + const composerRef = React.createRef() + + await React.act(async () => + root.render( + + + + + + ) + ) + const composer = await waitForComposer(composerRef) + await flush() + + const depthPickingPass = composer.passes.find((p) => p instanceof DepthPickingPassImpl)! + expect(depthPickingPass.renderToScreen).toBe(false) + expect(composer.passes.at(-1)!.renderToScreen).toBe(true) + + await React.act(async () => root.render(null)) + }) + + it('never lets its pass own renderToScreen when a real effect follows it, regardless of StrictMode', async () => { + const composerRef = React.createRef() + + await React.act(async () => + root.render( + strict( + + + + + ) + ) + ) + const composer = await waitForComposer(composerRef) + for (let i = 0; i < 10; i++) await flush() + + const pass = composer.passes.find((p) => p instanceof DepthPickingPassImpl)! + expect(pass.renderToScreen).toBe(false) + + // The real, visible output (Noise's own EffectPass) must own it instead. + const effectPass = composer.passes.find((p) => p instanceof EffectPass)! + expect(effectPass.renderToScreen).toBe(true) + + await React.act(async () => root.render(null)) + }) + + // DepthPickingPass.render() is conditional on a pending readDepth() call - + // unlike a normal pass, a frame with nothing pending renders nothing at + // all. If it ever owned renderToScreen (e.g. as the structurally-last + // pass with no other effects present), those frames would leave the + // screen showing whatever was already in the framebuffer. Its own + // trailing CopyPass (always unconditional) must own renderToScreen + // instead, even with no other effects around. + it('never owns renderToScreen even with no other effects - its own trailing CopyPass does instead', async () => { + const composerRef = React.createRef() + + await React.act(async () => root.render({})) + const composer = await waitForComposer(composerRef) + await flush() + + expect(composer.passes).toHaveLength(3) + const depthPickingPass = composer.passes.find((p) => p instanceof DepthPickingPassImpl)! + expect(depthPickingPass.renderToScreen).toBe(false) + expect(composer.passes.at(-1)!.renderToScreen).toBe(true) + expect(composer.passes.at(-1)).not.toBeInstanceOf(DepthPickingPassImpl) + + await React.act(async () => root.render(null)) + }) + + it('exposes readDepth via ref and renders nothing itself', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + + expect(ref.current).toBeTruthy() + expect(typeof ref.current!.readDepth).toBe('function') + }) +}) + +describe('useDepthPicking', () => { + function Picker({ + passRef, + camera, + onReady, + }: { + passRef: React.RefObject + camera?: THREE.Camera + onReady: (getHit: ReturnType) => void + }) { + const getHit = useDepthPicking(passRef, camera) + onReady(getHit) + return null + } + + it('unprojects a picked depth into a world-space point using an explicitly passed camera', async () => { + const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000) + camera.position.set(0, 0, 5) + camera.updateMatrixWorld() + camera.updateProjectionMatrix() + + // A stand-in for the mounted pass - readDepth resolves to a fixed, + // known depth so the resulting world position is fully predictable. + const fakePass: DepthPickingApi = { readDepth: async () => 0.5 } + const passRef = { current: fakePass } + + let getHit: ReturnType = null! + + await React.act(async () => + root.render( + + (getHit = fn)} /> + + ) + ) + await flush() + + const hit = await getHit(0, 0) + expect(hit).not.toBe(false) + + const expected = new THREE.Vector3(0, 0, 0.5 * 2 - 1).unproject(camera) + expect((hit as THREE.Vector3).toArray()).toEqual(expected.toArray()) + }) + + it("falls back to r3f's own default camera when none is passed explicitly", async () => { + const fakePass: DepthPickingApi = { readDepth: async () => 0.5 } + const passRef = { current: fakePass } + + let getHit: ReturnType = null! + let defaultCamera: THREE.Camera | null = null + + function CaptureDefaultCamera() { + defaultCamera = useThree((state) => state.camera) + return null + } + + await React.act(async () => + root.render( + + + (getHit = fn)} /> + + ) + ) + await flush() + + const hit = await getHit(0, 0) + expect(hit).not.toBe(false) + + const expected = new THREE.Vector3(0, 0, 0.5 * 2 - 1).unproject(defaultCamera!) + expect((hit as THREE.Vector3).toArray()).toEqual(expected.toArray()) + }) + + // The point of taking `pass` as a plain ref (rather than reading + // DepthPicking's own context) - the hook itself never touches + // EffectComposerContext, so it works from anywhere under , not + // just from inside the the pass happens to live in. + it('works when called outside the the pass is mounted in', async () => { + const fakePass: DepthPickingApi = { readDepth: async () => 0.5 } + const passRef = { current: fakePass } + let getHit: ReturnType = null! + + await React.act(async () => + root.render( + <> + + + + (getHit = fn)} /> + + ) + ) + await flush() + + expect(await getHit(0, 0)).not.toBe(false) + }) + + it('returns false when depth is at the far plane (nothing hit)', async () => { + const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000) + const fakePass: DepthPickingApi = { readDepth: async () => 1 } + const passRef = { current: fakePass } + + let getHit: ReturnType = null! + + await React.act(async () => + root.render( + + (getHit = fn)} /> + + ) + ) + await flush() + + expect(await getHit(0, 0)).toBe(false) + }) + + it('returns false when the pass ref is not attached yet', async () => { + const passRef = { current: null } + let getHit: ReturnType = null! + + await React.act(async () => + root.render( + + (getHit = fn)} /> + + ) + ) + await flush() + + expect(await getHit(0, 0)).toBe(false) + }) +}) diff --git a/src/tests/N8AO.test.tsx b/src/tests/N8AO.test.tsx index cb270cab..447dc7b0 100644 --- a/src/tests/N8AO.test.tsx +++ b/src/tests/N8AO.test.tsx @@ -2,7 +2,7 @@ import { CopyPass, EffectComposer as EffectComposerImpl } from 'postprocessing' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' import { EffectComposer } from '../EffectComposer' -import { N8AO } from '../effects/N8AO' +import { N8AO } from '../passes/N8AO' import { flush, root, waitForComposer } from './test-utils' describe('N8AO', () => { diff --git a/src/tests/effects.smoke.test.tsx b/src/tests/effects.smoke.test.tsx index e84a8e4c..0c00d055 100644 --- a/src/tests/effects.smoke.test.tsx +++ b/src/tests/effects.smoke.test.tsx @@ -10,8 +10,8 @@ // effects still need manual/visual verification before release. // // Coverage is enforced by the last test in this file: every *.tsx file in -// src/effects must appear either in SMOKE_CASES or EXCLUDED below. Adding a -// new effect file without touching either list fails CI. +// src/effects or src/passes must appear either in SMOKE_CASES or EXCLUDED +// below. Adding a new effect/pass file without touching either list fails CI. // // This file is excluded from `tsc -p tsconfig.json` (it matches // src/**/*.test.*), so editors fall back to a detached/inferred compilation @@ -23,7 +23,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { CopyPass, DepthPickingPass, EffectComposer as EffectComposerImpl } from 'postprocessing' +import { DepthPickingPass as DepthPickingPassImpl, EffectComposer as EffectComposerImpl } from 'postprocessing' import * as React from 'react' import * as THREE from 'three' import { describe, expect, it, vi } from 'vitest' @@ -44,7 +44,6 @@ import { Grid } from '../effects/Grid' import { HueSaturation } from '../effects/HueSaturation' import { LensFlare } from '../effects/LensFlare' import { LUT } from '../effects/LUT' -import { N8AO } from '../effects/N8AO' import { Noise } from '../effects/Noise' import { Outline } from '../effects/Outline' import { Pixelation } from '../effects/Pixelation' @@ -60,10 +59,12 @@ import { TiltShift2 } from '../effects/TiltShift2' import { ToneMapping } from '../effects/ToneMapping' import { Vignette } from '../effects/Vignette' import { WaterEffect } from '../effects/Water' +import { DepthPicking } from '../passes/DepthPicking' +import { N8AO } from '../passes/N8AO' import { flush, root } from './test-utils' type SmokeCase = { - /** Filename under src/effects this case covers — drives the coverage check below. */ + /** Filename under src/effects or src/passes this case covers — drives the coverage check below. */ file: string label: string composerProps?: Record @@ -85,6 +86,7 @@ const SMOKE_CASES: SmokeCase[] = [ { file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => }, { file: 'Depth.tsx', label: 'Depth', effect: (ref) => }, { file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => }, + { file: 'DepthPicking.tsx', label: 'DepthPicking', effect: (ref) => }, { file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => }, { file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => }, { file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => }, @@ -166,14 +168,11 @@ describe('effect smoke tests', () => { }) // Autofocus's ref resolves to { dofRef, hitpoint, update }, not an effect - // instance - the generic dispose check above no-ops for it. It owns three - // disposables (depthPickingPass, copyPass, the nested DepthOfField effect), - // verified here. Both the composer's teardown and Autofocus's own cleanup - // end up disposing depthPickingPass/copyPass - that's fine, dispose() is - // idempotent (just event-firing / shallow property disposal, no state). - it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect', async () => { - const depthPickingDisposeSpy = vi.spyOn(DepthPickingPass.prototype, 'dispose') - const copyPassDisposeSpy = vi.spyOn(CopyPass.prototype, 'dispose') + // instance - the generic dispose check above no-ops for it. It owns two + // disposables (the nested DepthPicking component's pass, and the + // nested DepthOfField effect), verified here. + it('Autofocus disposes the nested DepthPicking and DepthOfField effect', async () => { + const depthPickingDisposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose') // AutofocusProps' `ref` type is broken (ComponentProps // drags in DepthOfField's own `ref: Ref`, which then // intersects with `Ref` — separate pre-existing issue, @@ -198,16 +197,39 @@ describe('effect smoke tests', () => { await flush() expect(depthPickingDisposeSpy).toHaveBeenCalled() - expect(copyPassDisposeSpy).toHaveBeenCalled() expect(dofDisposeSpy).toHaveBeenCalled() depthPickingDisposeSpy.mockRestore() - copyPassDisposeSpy.mockRestore() }) - it('covers every file in src/effects (or documents why it is excluded)', () => { - const effectsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'effects') - const files = fs.readdirSync(effectsDir).filter((f) => f.endsWith('.tsx')) + it('DepthPicking disposes its pass on unmount', async () => { + const disposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose') + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await flush() + expect(ref.current).toBeTruthy() + + await React.act(async () => root.render(null)) + await flush() + + expect(disposeSpy).toHaveBeenCalled() + + disposeSpy.mockRestore() + }) + + it('covers every file in src/effects and src/passes (or documents why it is excluded)', () => { + const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') + const files = ['effects', 'passes'].flatMap((dir) => + fs.readdirSync(path.join(srcDir, dir)).filter((f) => f.endsWith('.tsx')) + ) const covered = new Set(SMOKE_CASES.map((c) => c.file)) const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))