@react-three/postprocessing 3.0.4 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -81
  3. package/dist/EffectComposer.d.ts +7 -6
  4. package/dist/Selection.d.ts +8 -8
  5. package/dist/effects/ASCII.d.ts +5 -4
  6. package/dist/effects/Autofocus.d.ts +8 -7
  7. package/dist/effects/Bloom.d.ts +2 -5
  8. package/dist/effects/BrightnessContrast.d.ts +2 -5
  9. package/dist/effects/ChromaticAberration.d.ts +7 -7
  10. package/dist/effects/ColorAverage.d.ts +7 -8
  11. package/dist/effects/ColorDepth.d.ts +2 -5
  12. package/dist/effects/Depth.d.ts +2 -5
  13. package/dist/effects/DepthOfField.d.ts +7 -18
  14. package/dist/effects/DotScreen.d.ts +2 -5
  15. package/dist/effects/FXAA.d.ts +2 -5
  16. package/dist/effects/Glitch.d.ts +4 -21
  17. package/dist/effects/GodRays.d.ts +7 -19
  18. package/dist/effects/Grid.d.ts +6 -7
  19. package/dist/effects/HueSaturation.d.ts +2 -5
  20. package/dist/effects/LUT.d.ts +4 -3
  21. package/dist/effects/LensFlare.d.ts +8 -8
  22. package/dist/effects/N8AO.d.ts +4 -2
  23. package/dist/effects/Noise.d.ts +2 -5
  24. package/dist/effects/Outline.d.ts +4 -23
  25. package/dist/effects/Pixelation.d.ts +3 -2
  26. package/dist/effects/Ramp.d.ts +1 -5
  27. package/dist/effects/SMAA.d.ts +2 -5
  28. package/dist/effects/SSAO.d.ts +7 -30
  29. package/dist/effects/ScanlineEffect.d.ts +2 -5
  30. package/dist/effects/SelectiveBloom.d.ts +5 -10
  31. package/dist/effects/Sepia.d.ts +2 -5
  32. package/dist/effects/ShockWave.d.ts +2 -5
  33. package/dist/effects/Texture.d.ts +7 -8
  34. package/dist/effects/TiltShift.d.ts +2 -5
  35. package/dist/effects/TiltShift2.d.ts +1 -5
  36. package/dist/effects/ToneMapping.d.ts +2 -6
  37. package/dist/effects/Vignette.d.ts +2 -5
  38. package/dist/effects/Water.d.ts +1 -5
  39. package/dist/index.d.ts +11 -10
  40. package/dist/index.js +855 -551
  41. package/dist/index.js.map +1 -1
  42. package/dist/tests/test-utils.d.ts +12 -0
  43. package/dist/util.d.ts +25 -17
  44. package/dist/wrapEffect.d.ts +12 -0
  45. package/package.json +73 -67
  46. package/src/EffectComposer.tsx +278 -200
  47. package/src/Selection.tsx +86 -46
  48. package/src/effects/ASCII.tsx +136 -135
  49. package/src/effects/Autofocus.tsx +175 -153
  50. package/src/effects/Bloom.tsx +6 -6
  51. package/src/effects/BrightnessContrast.tsx +4 -4
  52. package/src/effects/ChromaticAberration.tsx +30 -5
  53. package/src/effects/ColorAverage.tsx +17 -15
  54. package/src/effects/ColorDepth.tsx +4 -4
  55. package/src/effects/Depth.tsx +4 -4
  56. package/src/effects/DepthOfField.tsx +87 -89
  57. package/src/effects/DotScreen.tsx +4 -4
  58. package/src/effects/FXAA.tsx +4 -4
  59. package/src/effects/Glitch.tsx +37 -40
  60. package/src/effects/GodRays.tsx +20 -16
  61. package/src/effects/Grid.tsx +28 -21
  62. package/src/effects/HueSaturation.tsx +4 -4
  63. package/src/effects/LUT.tsx +27 -26
  64. package/src/effects/LensFlare.tsx +611 -611
  65. package/src/effects/N8AO.tsx +81 -81
  66. package/src/effects/Noise.tsx +4 -4
  67. package/src/effects/Outline.tsx +85 -117
  68. package/src/effects/Pixelation.tsx +17 -15
  69. package/src/effects/Ramp.tsx +150 -150
  70. package/src/effects/SMAA.tsx +4 -4
  71. package/src/effects/SSAO.tsx +44 -41
  72. package/src/effects/ScanlineEffect.tsx +7 -7
  73. package/src/effects/SelectiveBloom.tsx +116 -126
  74. package/src/effects/Sepia.tsx +4 -4
  75. package/src/effects/ShockWave.tsx +4 -4
  76. package/src/effects/Texture.tsx +27 -23
  77. package/src/effects/TiltShift.tsx +4 -4
  78. package/src/effects/TiltShift2.tsx +90 -90
  79. package/src/effects/ToneMapping.tsx +6 -6
  80. package/src/effects/Vignette.tsx +4 -4
  81. package/src/effects/Water.tsx +35 -35
  82. package/src/index.ts +41 -40
  83. package/src/tests/ChromaticAberration.test.tsx +31 -0
  84. package/src/tests/EffectComposer.test.tsx +911 -0
  85. package/src/tests/Outline.test.tsx +89 -0
  86. package/src/tests/Selection.test.tsx +324 -0
  87. package/src/tests/SelectiveBloom.test.tsx +118 -0
  88. package/src/tests/effects.smoke.test.tsx +234 -0
  89. package/src/tests/test-utils.tsx +96 -0
  90. package/src/tests/wrapEffect.test.tsx +209 -0
  91. package/src/util.tsx +96 -55
  92. package/src/wrapEffect.tsx +122 -0
  93. package/src/EffectComposer.test.tsx +0 -126
@@ -1,200 +1,278 @@
1
- import type { TextureDataType, Group, Camera, Scene } from 'three'
2
- import { HalfFloatType, NoToneMapping } from 'three'
3
- import {
4
- type JSX,
5
- memo,
6
- forwardRef,
7
- useMemo,
8
- useEffect,
9
- useLayoutEffect,
10
- createContext,
11
- useRef,
12
- useImperativeHandle,
13
- } from 'react'
14
- import { useThree, useFrame, type Instance } from '@react-three/fiber'
15
- import {
16
- EffectComposer as EffectComposerImpl,
17
- RenderPass,
18
- EffectPass,
19
- NormalPass,
20
- DepthDownsamplingPass,
21
- Effect,
22
- Pass,
23
- EffectAttribute,
24
- } from 'postprocessing'
25
-
26
- export const EffectComposerContext = /* @__PURE__ */ createContext<{
27
- composer: EffectComposerImpl
28
- normalPass: NormalPass | null
29
- downSamplingPass: DepthDownsamplingPass | null
30
- camera: Camera
31
- scene: Scene
32
- resolutionScale?: number
33
- }>(null!)
34
-
35
- export type EffectComposerProps = {
36
- enabled?: boolean
37
- children: JSX.Element | JSX.Element[]
38
- depthBuffer?: boolean
39
- /** Only used for SSGI currently, leave it disabled for everything else unless it's needed */
40
- enableNormalPass?: boolean
41
- stencilBuffer?: boolean
42
- autoClear?: boolean
43
- resolutionScale?: number
44
- multisampling?: number
45
- frameBufferType?: TextureDataType
46
- renderPriority?: number
47
- camera?: Camera
48
- scene?: Scene
49
- }
50
-
51
- const isConvolution = (effect: Effect): boolean =>
52
- (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
53
-
54
- export const EffectComposer = /* @__PURE__ */ memo(
55
- /* @__PURE__ */ forwardRef<EffectComposerImpl, EffectComposerProps>(
56
- (
57
- {
58
- children,
59
- camera: _camera,
60
- scene: _scene,
61
- resolutionScale,
62
- enabled = true,
63
- renderPriority = 1,
64
- autoClear = true,
65
- depthBuffer,
66
- enableNormalPass,
67
- stencilBuffer,
68
- multisampling = 8,
69
- frameBufferType = HalfFloatType,
70
- },
71
- ref
72
- ) => {
73
- const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree()
74
- const scene = _scene || defaultScene
75
- const camera = _camera || defaultCamera
76
-
77
- const [composer, normalPass, downSamplingPass] = useMemo(() => {
78
- // Initialize composer
79
- const effectComposer = new EffectComposerImpl(gl, {
80
- depthBuffer,
81
- stencilBuffer,
82
- multisampling,
83
- frameBufferType,
84
- })
85
-
86
- // Add render pass
87
- effectComposer.addPass(new RenderPass(scene, camera))
88
-
89
- // Create normal pass
90
- let downSamplingPass = null
91
- let normalPass = null
92
- if (enableNormalPass) {
93
- normalPass = new NormalPass(scene, camera)
94
- normalPass.enabled = false
95
- effectComposer.addPass(normalPass)
96
- if (resolutionScale !== undefined) {
97
- downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale })
98
- downSamplingPass.enabled = false
99
- effectComposer.addPass(downSamplingPass)
100
- }
101
- }
102
-
103
- return [effectComposer, normalPass, downSamplingPass]
104
- }, [
105
- camera,
106
- gl,
107
- depthBuffer,
108
- stencilBuffer,
109
- multisampling,
110
- frameBufferType,
111
- scene,
112
- enableNormalPass,
113
- resolutionScale,
114
- ])
115
-
116
- useEffect(() => composer?.setSize(size.width, size.height), [composer, size])
117
- useFrame(
118
- (_, delta) => {
119
- if (enabled) {
120
- const currentAutoClear = gl.autoClear
121
- gl.autoClear = autoClear
122
- if (stencilBuffer && !autoClear) gl.clearStencil()
123
- composer.render(delta)
124
- gl.autoClear = currentAutoClear
125
- }
126
- },
127
- enabled ? renderPriority : 0
128
- )
129
-
130
- const group = useRef<Group>(null!)
131
- useLayoutEffect(() => {
132
- const passes: Pass[] = []
133
-
134
- // TODO: rewrite all of this with R3F v9
135
- const groupInstance = (group.current as Group & { __r3f: Instance<Group> }).__r3f
136
-
137
- if (groupInstance && composer) {
138
- const children = groupInstance.children
139
-
140
- for (let i = 0; i < children.length; i++) {
141
- const child = children[i].object
142
-
143
- if (child instanceof Effect) {
144
- const effects: Effect[] = [child]
145
-
146
- if (!isConvolution(child)) {
147
- let next: unknown = null
148
- while ((next = children[i + 1]?.object) instanceof Effect) {
149
- if (isConvolution(next)) break
150
- effects.push(next)
151
- i++
152
- }
153
- }
154
-
155
- const pass = new EffectPass(camera, ...effects)
156
- passes.push(pass)
157
- } else if (child instanceof Pass) {
158
- passes.push(child)
159
- }
160
- }
161
-
162
- for (const pass of passes) composer?.addPass(pass)
163
-
164
- if (normalPass) normalPass.enabled = true
165
- if (downSamplingPass) downSamplingPass.enabled = true
166
- }
167
-
168
- return () => {
169
- for (const pass of passes) composer?.removePass(pass)
170
- if (normalPass) normalPass.enabled = false
171
- if (downSamplingPass) downSamplingPass.enabled = false
172
- }
173
- }, [composer, children, camera, normalPass, downSamplingPass])
174
-
175
- // Disable tone mapping because threejs disallows tonemapping on render targets
176
- useEffect(() => {
177
- const currentTonemapping = gl.toneMapping
178
- gl.toneMapping = NoToneMapping
179
- return () => {
180
- gl.toneMapping = currentTonemapping
181
- }
182
- }, [gl])
183
-
184
- // Memoize state, otherwise it would trigger all consumers on every render
185
- const state = useMemo(
186
- () => ({ composer, normalPass, downSamplingPass, resolutionScale, camera, scene }),
187
- [composer, normalPass, downSamplingPass, resolutionScale, camera, scene]
188
- )
189
-
190
- // Expose the composer
191
- useImperativeHandle(ref, () => composer, [composer])
192
-
193
- return (
194
- <EffectComposerContext.Provider value={state}>
195
- <group ref={group}>{children}</group>
196
- </EffectComposerContext.Provider>
197
- )
198
- }
199
- )
200
- )
1
+ import { useFrame, useThree, type Instance } from '@react-three/fiber'
2
+ import {
3
+ DepthDownsamplingPass,
4
+ Effect,
5
+ EffectAttribute,
6
+ EffectComposer as EffectComposerImpl,
7
+ EffectPass,
8
+ NormalPass,
9
+ Pass,
10
+ RenderPass,
11
+ } from 'postprocessing'
12
+ import {
13
+ createContext,
14
+ memo,
15
+ useEffect,
16
+ useImperativeHandle,
17
+ useLayoutEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type ReactNode,
22
+ type Ref,
23
+ } from 'react'
24
+ import type { Camera, Group, Scene, TextureDataType, WebGLRenderer } from 'three'
25
+ import { HalfFloatType, NoToneMapping } from 'three'
26
+
27
+ export const EffectComposerContext = /* @__PURE__ */ createContext<{
28
+ composer: EffectComposerImpl
29
+ normalPass: NormalPass | null
30
+ downSamplingPass: DepthDownsamplingPass | null
31
+ camera: Camera
32
+ scene: Scene
33
+ resolutionScale?: number
34
+ }>(null!)
35
+
36
+ export type EffectComposerProps = {
37
+ enabled?: boolean
38
+ children: ReactNode
39
+ depthBuffer?: boolean
40
+ /** Only used for SSGI currently, leave it disabled for everything else unless it's needed */
41
+ enableNormalPass?: boolean
42
+ stencilBuffer?: boolean
43
+ autoClear?: boolean
44
+ resolutionScale?: number
45
+ multisampling?: number
46
+ frameBufferType?: TextureDataType
47
+ renderPriority?: number
48
+ camera?: Camera
49
+ scene?: Scene
50
+ ref?: Ref<EffectComposerImpl>
51
+ }
52
+
53
+ type ComposerState = {
54
+ composer: EffectComposerImpl
55
+ normalPass: NormalPass | null
56
+ downSamplingPass: DepthDownsamplingPass | null
57
+ }
58
+
59
+ const isConvolution = (effect: Effect): boolean =>
60
+ (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION
61
+
62
+ /**
63
+ * autoClear/toneMapping get force-set and never restored by whoever sets
64
+ * them. Ref-counted per (renderer, property) since composers can share a
65
+ * renderer; skips restoring if the value already changed since acquire.
66
+ */
67
+ function createRendererPropertyGuard<K extends 'autoClear' | 'toneMapping'>(property: K) {
68
+ const refs = new WeakMap<
69
+ WebGLRenderer,
70
+ { count: number; original: WebGLRenderer[K]; forcedValue: WebGLRenderer[K] }
71
+ >()
72
+
73
+ return {
74
+ acquire(gl: WebGLRenderer, forcedValue: WebGLRenderer[K]): void {
75
+ const existing = refs.get(gl)
76
+ if (existing) {
77
+ existing.count++
78
+ existing.forcedValue = forcedValue
79
+ } else {
80
+ refs.set(gl, { count: 1, original: gl[property], forcedValue })
81
+ }
82
+ },
83
+ release(gl: WebGLRenderer): void {
84
+ const entry = refs.get(gl)
85
+ if (!entry) return
86
+
87
+ if (--entry.count <= 0) {
88
+ if (gl[property] === entry.forcedValue) {
89
+ gl[property] = entry.original
90
+ }
91
+ refs.delete(gl)
92
+ }
93
+ },
94
+ }
95
+ }
96
+
97
+ const autoClearGuard = /* @__PURE__ */ createRendererPropertyGuard('autoClear')
98
+ const toneMappingGuard = /* @__PURE__ */ createRendererPropertyGuard('toneMapping')
99
+
100
+ /**
101
+ * Groups a flat, ordered list of Effect/Pass instances into actual composer
102
+ * passes, merging consecutive non-convolution Effects into a single
103
+ * EffectPass.
104
+ */
105
+ function buildPasses(nodes: Array<Effect | Pass>, camera: Camera): Pass[] {
106
+ const passes: Pass[] = []
107
+
108
+ for (let i = 0; i < nodes.length; i++) {
109
+ const node = nodes[i]
110
+
111
+ if (node instanceof Effect) {
112
+ const effects: Effect[] = [node]
113
+
114
+ if (!isConvolution(node)) {
115
+ let next: Effect | Pass | undefined
116
+ while ((next = nodes[i + 1]) instanceof Effect) {
117
+ if (isConvolution(next)) break
118
+ effects.push(next)
119
+ i++
120
+ }
121
+ }
122
+
123
+ passes.push(new EffectPass(camera, ...effects))
124
+ } else if (node instanceof Pass) {
125
+ passes.push(node)
126
+ }
127
+ }
128
+
129
+ return passes
130
+ }
131
+
132
+ export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({
133
+ children,
134
+ camera: _camera,
135
+ scene: _scene,
136
+ resolutionScale,
137
+ enabled = true,
138
+ renderPriority = 1,
139
+ autoClear = true,
140
+ depthBuffer,
141
+ enableNormalPass,
142
+ stencilBuffer,
143
+ multisampling = 8,
144
+ frameBufferType = HalfFloatType,
145
+ ref,
146
+ }: EffectComposerProps) {
147
+ const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree()
148
+ const scene = _scene || defaultScene
149
+ const camera = _camera || defaultCamera
150
+
151
+ // EffectComposer owns WebGL resources, so it must be created and
152
+ // disposed inside an effect lifecycle. useMemo is not suitable here
153
+ // because React may discard memoized values without running cleanup.
154
+ const [composerState, setComposerState] = useState<ComposerState | null>(null)
155
+
156
+ useEffect(() => {
157
+ autoClearGuard.acquire(gl, false)
158
+
159
+ const effectComposer = new EffectComposerImpl(gl, { depthBuffer, stencilBuffer, multisampling, frameBufferType })
160
+ effectComposer.addPass(new RenderPass(scene, camera))
161
+
162
+ let normalPass: NormalPass | null = null
163
+ let downSamplingPass: DepthDownsamplingPass | null = null
164
+
165
+ if (enableNormalPass) {
166
+ normalPass = new NormalPass(scene, camera)
167
+ normalPass.enabled = false
168
+ effectComposer.addPass(normalPass)
169
+
170
+ if (resolutionScale !== undefined) {
171
+ downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale })
172
+ downSamplingPass.enabled = false
173
+ effectComposer.addPass(downSamplingPass)
174
+ }
175
+ }
176
+
177
+ effectComposer.setSize(size.width, size.height)
178
+
179
+ setComposerState({ composer: effectComposer, normalPass, downSamplingPass })
180
+
181
+ return () => {
182
+ effectComposer.dispose()
183
+ autoClearGuard.release(gl)
184
+ }
185
+ // `size` intentionally excluded: it's applied via the composer.setSize
186
+ // effect below, and shouldn't tear down/recreate the whole composer.
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [camera, gl, depthBuffer, stencilBuffer, multisampling, frameBufferType, scene, enableNormalPass, resolutionScale])
189
+
190
+ useEffect(() => {
191
+ composerState?.composer.setSize(size.width, size.height)
192
+ }, [composerState, size])
193
+
194
+ useFrame(
195
+ (_, delta) => {
196
+ if (!enabled || !composerState) return
197
+ const { composer } = composerState
198
+ const currentAutoClear = gl.autoClear
199
+ gl.autoClear = autoClear
200
+ if (stencilBuffer && !autoClear) gl.clearStencil()
201
+ composer.render(delta)
202
+ gl.autoClear = currentAutoClear
203
+ },
204
+ enabled ? renderPriority : 0
205
+ )
206
+
207
+ // Passes are derived from the actual r3f scene graph rather than tracked
208
+ // incrementally, so the list always matches current JSX order — including
209
+ // through wrapper components — even after a reorder or a remount.
210
+ const group = useRef<Group>(null!)
211
+
212
+ useLayoutEffect(() => {
213
+ if (!composerState) return
214
+ const { composer, normalPass, downSamplingPass } = composerState
215
+
216
+ const passes: Pass[] = []
217
+ const groupInstance = (group.current as Group & { __r3f: Instance<Group> }).__r3f
218
+
219
+ if (groupInstance) {
220
+ const nodes = groupInstance.children.map((child) => child.object).filter(
221
+ (object): object is Effect | Pass => object instanceof Effect || object instanceof Pass
222
+ )
223
+
224
+ passes.push(...buildPasses(nodes, camera))
225
+ }
226
+
227
+ for (const pass of passes) composer.addPass(pass)
228
+
229
+ if (passes.length) {
230
+ if (normalPass) normalPass.enabled = true
231
+ if (downSamplingPass) downSamplingPass.enabled = true
232
+ }
233
+
234
+ return () => {
235
+ for (const pass of passes) composer.removePass(pass)
236
+ if (normalPass) normalPass.enabled = false
237
+ if (downSamplingPass) downSamplingPass.enabled = false
238
+ }
239
+ }, [composerState, children, camera])
240
+
241
+ // Disable tone mapping because threejs disallows tonemapping on render targets
242
+ useEffect(() => {
243
+ toneMappingGuard.acquire(gl, NoToneMapping)
244
+ gl.toneMapping = NoToneMapping
245
+ return () => {
246
+ toneMappingGuard.release(gl)
247
+ }
248
+ }, [gl])
249
+
250
+ // Memoize state, otherwise it would trigger all consumers on every render
251
+ const state = useMemo(
252
+ () =>
253
+ composerState
254
+ ? {
255
+ composer: composerState.composer,
256
+ normalPass: composerState.normalPass,
257
+ downSamplingPass: composerState.downSamplingPass,
258
+ resolutionScale,
259
+ camera,
260
+ scene,
261
+ }
262
+ : null,
263
+ [composerState, resolutionScale, camera, scene]
264
+ )
265
+
266
+ // Expose the composer
267
+ useImperativeHandle(ref, () => composerState?.composer as EffectComposerImpl, [composerState])
268
+
269
+ // Wait until the composer exists before mounting children so they always
270
+ // see a valid composer instance via context.
271
+ if (!state) return null
272
+
273
+ return (
274
+ <EffectComposerContext.Provider value={state}>
275
+ <group ref={group}>{children}</group>
276
+ </EffectComposerContext.Provider>
277
+ )
278
+ })
package/src/Selection.tsx CHANGED
@@ -1,46 +1,86 @@
1
- import * as THREE from 'three'
2
- import React, { createContext, useState, useContext, useEffect, useRef, useMemo } from 'react'
3
- import { type ThreeElements } from '@react-three/fiber'
4
-
5
- export type Api = {
6
- selected: THREE.Object3D[]
7
- select: React.Dispatch<React.SetStateAction<THREE.Object3D[]>>
8
- enabled: boolean
9
- }
10
- export type SelectApi = Omit<ThreeElements['group'], 'ref'> & {
11
- enabled?: boolean
12
- }
13
-
14
- export const selectionContext = /* @__PURE__ */ createContext<Api | null>(null)
15
-
16
- export function Selection({ children, enabled = true }: { enabled?: boolean; children: React.ReactNode }) {
17
- const [selected, select] = useState<THREE.Object3D[]>([])
18
- const value = useMemo(() => ({ selected, select, enabled }), [selected, select, enabled])
19
- return <selectionContext.Provider value={value}>{children}</selectionContext.Provider>
20
- }
21
-
22
- export function Select({ enabled = false, children, ...props }: SelectApi) {
23
- const group = useRef<THREE.Group>(null!)
24
- const api = useContext(selectionContext)
25
- useEffect(() => {
26
- if (api && enabled) {
27
- let changed = false
28
- const current: THREE.Object3D[] = []
29
- group.current.traverse((o) => {
30
- o.type === 'Mesh' && current.push(o)
31
- if (api.selected.indexOf(o) === -1) changed = true
32
- })
33
- if (changed) {
34
- api.select((state) => [...state, ...current])
35
- return () => {
36
- api.select((state) => state.filter((selected) => !current.includes(selected)))
37
- }
38
- }
39
- }
40
- }, [enabled, children, api])
41
- return (
42
- <group ref={group} {...props}>
43
- {children}
44
- </group>
45
- )
46
- }
1
+ import { type ThreeElements } from '@react-three/fiber'
2
+ import {
3
+ createContext,
4
+ use,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type Dispatch,
10
+ type ReactNode,
11
+ type SetStateAction,
12
+ } from 'react'
13
+ import { type Group, type Line, type Mesh, type Object3D, type Points } from 'three'
14
+
15
+ export type Api = {
16
+ selected: Object3D[]
17
+ select: Dispatch<SetStateAction<Object3D[]>>
18
+ enabled: boolean
19
+ }
20
+ export type SelectApi = Omit<ThreeElements['group'], 'ref'> & {
21
+ enabled?: boolean
22
+ }
23
+
24
+ export const selectionContext = /* @__PURE__ */ createContext<Api | null>(null)
25
+
26
+ export function Selection({ children, enabled = true }: { enabled?: boolean; children: ReactNode }) {
27
+ const [selected, select] = useState<Object3D[]>([])
28
+ const value = useMemo(() => ({ selected, select, enabled }), [selected, select, enabled])
29
+ return <selectionContext.Provider value={value}>{children}</selectionContext.Provider>
30
+ }
31
+
32
+ // Covers Mesh/Line/Points subclasses too, unlike `.type`.
33
+ function isSelectable(object: Object3D): boolean {
34
+ const o = object as Partial<Mesh & Line & Points>
35
+ return !!(o.isMesh || o.isLine || o.isPoints)
36
+ }
37
+
38
+ export function Select({ enabled = false, children, ...props }: SelectApi) {
39
+ const group = useRef<Group>(null!)
40
+ // Stable, unlike the context value - avoids retriggering off our own write.
41
+ const select = use(selectionContext)?.select
42
+ const claimed = useRef<Object3D[]>([])
43
+
44
+ useEffect(() => {
45
+ if (!select) return
46
+
47
+ const current: Object3D[] = []
48
+ if (enabled) {
49
+ group.current.traverse((o) => {
50
+ if (isSelectable(o)) current.push(o)
51
+ })
52
+ }
53
+
54
+ const previouslyClaimed = claimed.current
55
+ claimed.current = current
56
+
57
+ select((prev) => {
58
+ const prevSet = new Set(prev)
59
+ const currentSet = new Set(current)
60
+ const toAdd = current.filter((o) => !prevSet.has(o))
61
+ const toRemove = previouslyClaimed.filter((o) => !currentSet.has(o) && prevSet.has(o))
62
+ if (!toAdd.length && !toRemove.length) return prev
63
+ const toRemoveSet = toRemove.length ? new Set(toRemove) : null
64
+ const kept = toRemoveSet ? prev.filter((o) => !toRemoveSet.has(o)) : prev
65
+ return toAdd.length ? [...kept, ...toAdd] : kept
66
+ })
67
+ }, [enabled, children, select])
68
+
69
+ // Separate from the effect above so unmount cleanup doesn't fire on every enabled/children change.
70
+ useEffect(() => {
71
+ return () => {
72
+ if (!select || !claimed.current.length) return
73
+ const stillClaimed = new Set(claimed.current)
74
+ select((prev) => {
75
+ const next = prev.filter((o) => !stillClaimed.has(o))
76
+ return next.length !== prev.length ? next : prev
77
+ })
78
+ }
79
+ }, [select])
80
+
81
+ return (
82
+ <group ref={group} {...props}>
83
+ {children}
84
+ </group>
85
+ )
86
+ }