@@ -2,8 +2,11 @@ import { type ThreeElements } from '@react-three/fiber'
22import {
33 createContext ,
44 use ,
5+ useCallback ,
56 useEffect ,
7+ useLayoutEffect ,
68 useMemo ,
9+ useReducer ,
710 useRef ,
811 useState ,
912 type Dispatch ,
@@ -19,10 +22,23 @@ export type Api = {
1922}
2023export type SelectApi = Omit < ThreeElements [ 'group' ] , 'ref' > & {
2124 enabled ?: boolean
25+ /** Keeps this subtree out of the selection, even when an ancestor Select is enabled. */
26+ exclude ?: boolean
2227}
2328
2429export const selectionContext = /* @__PURE__ */ createContext < Api | null > ( null )
2530
31+ // Lets a nested Select ask its ancestors to re-traverse (e.g. when `exclude` toggles).
32+ const refreshContext = /* @__PURE__ */ createContext < ( ( ) => void ) | null > ( null )
33+
34+ // How many Selects claim each object, so one letting go doesn't drop another's claim.
35+ const claims = new WeakMap < Object3D , number > ( )
36+ const excluded = new WeakSet < Object3D > ( )
37+
38+ function updateClaims ( objects : Object3D [ ] , delta : number ) : void {
39+ for ( const o of objects ) claims . set ( o , ( claims . get ( o ) ?? 0 ) + delta )
40+ }
41+
2642export function Selection ( { children, enabled = true } : { enabled ?: boolean ; children : ReactNode } ) {
2743 const [ selected , select ] = useState < Object3D [ ] > ( [ ] )
2844 const value = useMemo ( ( ) => ( { selected, select, enabled } ) , [ selected , select , enabled ] )
@@ -35,52 +51,76 @@ function isSelectable(object: Object3D): boolean {
3551 return ! ! ( o . isMesh || o . isLine || o . isPoints )
3652}
3753
38- export function Select ( { enabled = false , children, ...props } : SelectApi ) {
54+ function collect ( object : Object3D , out : Object3D [ ] ) : void {
55+ if ( excluded . has ( object ) ) return
56+ if ( isSelectable ( object ) ) out . push ( object )
57+ for ( const child of object . children ) collect ( child , out )
58+ }
59+
60+ export function Select ( { enabled = false , exclude = false , children, ...props } : SelectApi ) {
3961 const group = useRef < Group > ( null ! )
4062 // Stable, unlike the context value - avoids retriggering off our own write.
4163 const select = use ( selectionContext ) ?. select
64+ const refreshParent = use ( refreshContext )
4265 const claimed = useRef < Object3D [ ] > ( [ ] )
66+ const [ version , bump ] = useReducer ( ( v : number ) => v + 1 , 0 )
67+ const refresh = useCallback ( ( ) => {
68+ bump ( )
69+ refreshParent ?.( )
70+ } , [ refreshParent ] )
71+
72+ useLayoutEffect ( ( ) => {
73+ if ( ! exclude ) return
74+ const g = group . current
75+ excluded . add ( g )
76+ refreshParent ?.( )
77+ return ( ) => {
78+ excluded . delete ( g )
79+ refreshParent ?.( )
80+ }
81+ } , [ exclude , refreshParent ] )
4382
4483 useEffect ( ( ) => {
4584 if ( ! select ) return
4685
4786 const current : Object3D [ ] = [ ]
48- if ( enabled ) {
49- group . current . traverse ( ( o ) => {
50- if ( isSelectable ( o ) ) current . push ( o )
51- } )
52- }
87+ if ( enabled && ! exclude ) for ( const child of group . current . children ) collect ( child , current )
5388
5489 const previouslyClaimed = claimed . current
5590 claimed . current = current
91+ updateClaims ( previouslyClaimed , - 1 )
92+ updateClaims ( current , 1 )
5693
5794 select ( ( prev ) => {
5895 const prevSet = new Set ( prev )
59- const currentSet = new Set ( current )
6096 const toAdd = current . filter ( ( o ) => ! prevSet . has ( o ) )
61- const toRemove = previouslyClaimed . filter ( ( o ) => ! currentSet . has ( o ) && prevSet . has ( o ) )
97+ const toRemove = previouslyClaimed . filter ( ( o ) => prevSet . has ( o ) && ! claims . get ( o ) )
6298 if ( ! toAdd . length && ! toRemove . length ) return prev
6399 const toRemoveSet = toRemove . length ? new Set ( toRemove ) : null
64100 const kept = toRemoveSet ? prev . filter ( ( o ) => ! toRemoveSet . has ( o ) ) : prev
65101 return toAdd . length ? [ ...kept , ...toAdd ] : kept
66102 } )
67- } , [ enabled , children , select ] )
103+ } , [ enabled , exclude , children , select , version ] )
68104
69105 // Separate from the effect above so unmount cleanup doesn't fire on every enabled/children change.
70106 useEffect ( ( ) => {
71107 return ( ) => {
72108 if ( ! select || ! claimed . current . length ) return
73- const stillClaimed = new Set ( claimed . current )
109+ const released = new Set ( claimed . current )
110+ updateClaims ( claimed . current , - 1 )
111+ claimed . current = [ ]
74112 select ( ( prev ) => {
75- const next = prev . filter ( ( o ) => ! stillClaimed . has ( o ) )
113+ const next = prev . filter ( ( o ) => ! released . has ( o ) || claims . get ( o ) )
76114 return next . length !== prev . length ? next : prev
77115 } )
78116 }
79117 } , [ select ] )
80118
81119 return (
82- < group ref = { group } { ...props } >
83- { children }
84- </ group >
120+ < refreshContext . Provider value = { refresh } >
121+ < group ref = { group } { ...props } >
122+ { children }
123+ </ group >
124+ </ refreshContext . Provider >
85125 )
86126}
0 commit comments