Skip to content

Commit 1031262

Browse files
refactor(controllers): Refactored how Controllers interact w/ three (#294)
This a big refactor of controller and how they interact with three counterparts. The flow of events was very confusing, to say the least. This PR makes this flow much direct and does some other notable changes. --------- Co-authored-by: Cody Bennett <[email protected]>
1 parent d6acce1 commit 1031262

32 files changed

+1343
-322
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"devDependencies": {
3838
"@react-three/drei": "^9.13.2",
3939
"@react-three/fiber": "^8.0.27",
40+
"@react-three/test-renderer": "^8.2.0",
4041
"@types/react": "^18.0.14",
4142
"@types/react-dom": "^18.0.5",
4243
"@types/react-test-renderer": "^18.0.0",

src/Controllers.test.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import * as React from 'react'
2+
import { describe, it, expect, vi } from 'vitest'
3+
import { createStoreMock, createStoreProvider } from './mocks/storeMock'
4+
import { render } from './testUtils/testUtilsThree'
5+
import { Controllers } from './Controllers'
6+
import { XRControllerMock } from './mocks/XRControllerMock'
7+
import { XRControllerModel } from './XRControllerModel'
8+
import { XRControllerModelFactoryMock } from './mocks/XRControllerModelFactoryMock'
9+
import { XRInputSourceMock } from './mocks/XRInputSourceMock'
10+
import { act } from '@react-three/test-renderer'
11+
12+
vi.mock('./XRControllerModelFactory', async () => {
13+
const { XRControllerModelFactoryMock } = await vi.importActual<typeof import('./mocks/XRControllerModelFactoryMock')>(
14+
'./mocks/XRControllerModelFactoryMock'
15+
)
16+
return { XRControllerModelFactory: XRControllerModelFactoryMock }
17+
})
18+
19+
describe('Controllers', () => {
20+
it('should not render anything if controllers in state are empty', async () => {
21+
const store = createStoreMock()
22+
const xrControllerMock = new XRControllerMock(0)
23+
store.setState({ controllers: [] })
24+
25+
const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })
26+
27+
// We aren't rendering anything as a direct children, only in portals
28+
const graph = renderer.toGraph()
29+
expect(graph).toHaveLength(0)
30+
// Checking portals
31+
expect(xrControllerMock.grip.children).toHaveLength(0)
32+
expect(xrControllerMock.controller.children).toHaveLength(0)
33+
})
34+
35+
it('should render one xr controller model and one ray given one controller in state', async () => {
36+
const store = createStoreMock()
37+
const xrControllerMock = new XRControllerMock(0)
38+
store.setState({ controllers: [xrControllerMock] })
39+
40+
await render(<Controllers />, { wrapper: createStoreProvider(store) })
41+
42+
// Checking portals
43+
expect(xrControllerMock.grip.children).toHaveLength(1)
44+
expect(xrControllerMock.grip.children[0]).toBeInstanceOf(XRControllerModel)
45+
expect(xrControllerMock.controller.children).toHaveLength(1)
46+
expect(xrControllerMock.controller.children[0].type).toBe('Line')
47+
})
48+
49+
it('should render two xr controller models and two rays given one controller in state', async () => {
50+
const store = createStoreMock()
51+
const xrControllerMockLeft = new XRControllerMock(0)
52+
const xrControllerMockRight = new XRControllerMock(1)
53+
store.setState({ controllers: [xrControllerMockLeft, xrControllerMockRight] })
54+
55+
await render(<Controllers />, { wrapper: createStoreProvider(store) })
56+
57+
// Checking portals
58+
// left
59+
expect(xrControllerMockLeft.grip.children).toHaveLength(1)
60+
expect(xrControllerMockLeft.grip.children[0]).toBeInstanceOf(XRControllerModel)
61+
expect(xrControllerMockLeft.controller.children).toHaveLength(1)
62+
expect(xrControllerMockLeft.controller.children[0].type).toBe('Line')
63+
// right
64+
expect(xrControllerMockRight.grip.children).toHaveLength(1)
65+
expect(xrControllerMockRight.grip.children[0]).toBeInstanceOf(XRControllerModel)
66+
expect(xrControllerMockRight.controller.children).toHaveLength(1)
67+
expect(xrControllerMockRight.controller.children[0].type).toBe('Line')
68+
})
69+
70+
it('should remove xr controller model when controller is removed from state', async () => {
71+
const store = createStoreMock()
72+
const xrControllerMock = new XRControllerMock(0)
73+
xrControllerMock.inputSource = new XRInputSourceMock()
74+
store.setState({ controllers: [xrControllerMock] })
75+
76+
const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })
77+
78+
await act(async () => {
79+
store.setState({ controllers: [] })
80+
})
81+
82+
// We aren't rendering anything as a direct children, only in portals
83+
const graph = renderer.toGraph()
84+
expect(graph).toHaveLength(0)
85+
// Checking portals
86+
expect(xrControllerMock.grip.children).toHaveLength(0)
87+
expect(xrControllerMock.controller.children).toHaveLength(0)
88+
})
89+
90+
it('should handle xr controller model given one controller in state', async () => {
91+
const store = createStoreMock()
92+
const xrControllerMock = new XRControllerMock(0)
93+
xrControllerMock.inputSource = new XRInputSourceMock()
94+
store.setState({ controllers: [xrControllerMock] })
95+
96+
await render(<Controllers />, { wrapper: createStoreProvider(store) })
97+
98+
const xrControllerModelFactory = XRControllerModelFactoryMock.instance
99+
expect(xrControllerModelFactory).toBeDefined()
100+
expect(xrControllerMock.xrControllerModel).toBeInstanceOf(XRControllerModel)
101+
expect(xrControllerModelFactory?.initializeControllerModel).toBeCalled()
102+
})
103+
104+
it('should handle xr controller model when controller is removed from state', async () => {
105+
const store = createStoreMock()
106+
const xrControllerMock = new XRControllerMock(0)
107+
xrControllerMock.inputSource = new XRInputSourceMock()
108+
store.setState({ controllers: [xrControllerMock] })
109+
110+
await render(<Controllers />, { wrapper: createStoreProvider(store) })
111+
112+
const xrControllerModel = xrControllerMock.xrControllerModel
113+
const disconnectSpy = vi.spyOn(xrControllerModel!, 'disconnect')
114+
115+
await act(async () => {
116+
store.setState({ controllers: [] })
117+
})
118+
119+
const xrControllerModelFactory = XRControllerModelFactoryMock.instance
120+
expect(xrControllerModelFactory).toBeDefined()
121+
expect(xrControllerMock.xrControllerModel).toBeNull()
122+
expect(disconnectSpy).toBeCalled()
123+
})
124+
125+
it('should not reconnect when component is rerendered', async () => {
126+
const store = createStoreMock()
127+
const xrControllerMock = new XRControllerMock(0)
128+
xrControllerMock.inputSource = new XRInputSourceMock()
129+
store.setState({ controllers: [xrControllerMock] })
130+
131+
const { rerender } = await render(<Controllers />, { wrapper: createStoreProvider(store) })
132+
133+
const xrControllerModel = xrControllerMock.xrControllerModel
134+
const disconnectSpy = vi.spyOn(xrControllerModel!, 'disconnect')
135+
136+
await rerender(<Controllers />)
137+
138+
const xrControllerModelFactory = XRControllerModelFactoryMock.instance
139+
expect(xrControllerModelFactory).toBeDefined()
140+
expect(xrControllerMock.xrControllerModel).not.toBeNull()
141+
expect(disconnectSpy).not.toBeCalled()
142+
expect(xrControllerModelFactory?.initializeControllerModel).toBeCalledTimes(1)
143+
})
144+
})

src/Controllers.tsx

Lines changed: 39 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ import * as THREE from 'three'
33
import { useFrame, Object3DNode, extend, createPortal } from '@react-three/fiber'
44
import { useXR } from './XR'
55
import { XRController } from './XRController'
6-
import { useIsomorphicLayoutEffect } from './utils'
7-
import { XRControllerModel, XRControllerModelFactory } from './XRControllerModelFactory'
8-
import { XRControllerEvent } from './XREvents'
6+
import { XRControllerModelFactory } from './XRControllerModelFactory'
7+
import { useCallback } from 'react'
8+
import { XRControllerModel } from './XRControllerModel'
99

1010
export interface RayProps extends Partial<JSX.IntrinsicElements['object3D']> {
1111
/** The XRController to attach the ray to */
1212
target: XRController
1313
/** Whether to hide the ray on controller blur. Default is `false` */
1414
hideOnBlur?: boolean
1515
}
16+
1617
export const Ray = React.forwardRef<THREE.Line, RayProps>(function Ray({ target, hideOnBlur = false, ...props }, forwardedRef) {
1718
const hoverState = useXR((state) => state.hoverState)
1819
const ray = React.useRef<THREE.Line>(null!)
@@ -24,6 +25,10 @@ export const Ray = React.forwardRef<THREE.Line, RayProps>(function Ray({ target,
2425

2526
// Show ray line when hovering objects
2627
useFrame(() => {
28+
if (!target.inputSource) {
29+
return
30+
}
31+
2732
let rayLength = 1
2833

2934
const intersection: THREE.Intersection = hoverState[target.inputSource.handedness].values().next().value
@@ -46,47 +51,10 @@ export const Ray = React.forwardRef<THREE.Line, RayProps>(function Ray({ target,
4651

4752
const modelFactory = new XRControllerModelFactory()
4853

49-
class ControllerModel extends THREE.Group {
50-
readonly target: XRController
51-
readonly xrControllerModel: XRControllerModel
52-
53-
constructor(target: XRController) {
54-
super()
55-
this.xrControllerModel = new XRControllerModel()
56-
this.target = target
57-
this.add(this.xrControllerModel)
58-
59-
this._onConnected = this._onConnected.bind(this)
60-
this._onDisconnected = this._onDisconnected.bind(this)
61-
62-
this.target.controller.addEventListener('connected', this._onConnected)
63-
this.target.controller.addEventListener('disconnected', this._onDisconnected)
64-
}
65-
66-
private _onConnected(event: XRControllerEvent) {
67-
if (event.data?.hand) {
68-
return
69-
}
70-
modelFactory.initializeControllerModel(this.xrControllerModel, event)
71-
}
72-
73-
private _onDisconnected(event: XRControllerEvent) {
74-
if (event.data?.hand) {
75-
return
76-
}
77-
this.xrControllerModel.disconnect()
78-
}
79-
80-
dispose() {
81-
this.target.controller.removeEventListener('connected', this._onConnected)
82-
this.target.controller.removeEventListener('disconnected', this._onDisconnected)
83-
}
84-
}
85-
8654
declare global {
8755
namespace JSX {
8856
interface IntrinsicElements {
89-
controllerModel: Object3DNode<ControllerModel, typeof ControllerModel>
57+
xRControllerModel: Object3DNode<XRControllerModel, typeof XRControllerModel>
9058
}
9159
}
9260
}
@@ -97,6 +65,34 @@ export interface ControllersProps {
9765
/** Whether to hide controllers' rays on blur. Default is `false` */
9866
hideRaysOnBlur?: boolean
9967
}
68+
69+
const ControllerModel = ({ target }: { target: XRController }) => {
70+
const handleControllerModel = useCallback(
71+
(xrControllerModel: XRControllerModel | null) => {
72+
if (xrControllerModel) {
73+
target.xrControllerModel = xrControllerModel
74+
if (target.inputSource?.hand) {
75+
return
76+
}
77+
if (target.inputSource) {
78+
modelFactory.initializeControllerModel(xrControllerModel, target.inputSource)
79+
} else {
80+
console.warn('no input source on XRController when handleControllerModel')
81+
}
82+
} else {
83+
if (target.inputSource?.hand) {
84+
return
85+
}
86+
target.xrControllerModel?.disconnect()
87+
target.xrControllerModel = null
88+
}
89+
},
90+
[target]
91+
)
92+
93+
return <xRControllerModel ref={handleControllerModel} />
94+
}
95+
10096
export function Controllers({ rayMaterial = {}, hideRaysOnBlur = false }: ControllersProps) {
10197
const controllers = useXR((state) => state.controllers)
10298
const isHandTracking = useXR((state) => state.isHandTracking)
@@ -111,20 +107,13 @@ export function Controllers({ rayMaterial = {}, hideRaysOnBlur = false }: Contro
111107
),
112108
[JSON.stringify(rayMaterial)] // eslint-disable-line react-hooks/exhaustive-deps
113109
)
114-
React.useMemo(() => extend({ ControllerModel }), [])
115-
116-
// Send fake connected event (no-op) so models start loading
117-
useIsomorphicLayoutEffect(() => {
118-
for (const target of controllers) {
119-
target.controller.dispatchEvent({ type: 'connected', data: target.inputSource, fake: true })
120-
}
121-
}, [controllers])
110+
React.useMemo(() => extend({ XRControllerModel }), [])
122111

123112
return (
124113
<>
125114
{controllers.map((target, i) => (
126115
<React.Fragment key={i}>
127-
{createPortal(<controllerModel args={[target]} />, target.grip)}
116+
{createPortal(<ControllerModel target={target} />, target.grip)}
128117
{createPortal(
129118
<Ray visible={!isHandTracking} hideOnBlur={hideRaysOnBlur} target={target} {...rayMaterialProps} />,
130119
target.controller

src/Interactions.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render } from './testUtils/testUtilsThree'
3+
import * as React from 'react'
4+
import { createStoreMock, createStoreProvider } from './mocks/storeMock'
5+
import { InteractionManager, Interactive } from './Interactions'
6+
import { XRControllerMock } from './mocks/XRControllerMock'
7+
import { act } from '@react-three/test-renderer'
8+
import { XRInputSourceMock } from './mocks/XRInputSourceMock'
9+
import { Intersection } from '@react-three/fiber'
10+
import { Vector3 } from 'three'
11+
12+
describe('Interactions', () => {
13+
it('should call onSelect when select event is dispatched', async () => {
14+
const store = createStoreMock()
15+
const xrControllerMock = new XRControllerMock(0)
16+
const xrInputSourceMock = new XRInputSourceMock({ handedness: 'right' })
17+
xrControllerMock.inputSource = xrInputSourceMock
18+
const rightHoverState = new Map()
19+
store.setState({
20+
controllers: [xrControllerMock],
21+
hoverState: {
22+
none: new Map(),
23+
left: new Map(),
24+
right: rightHoverState
25+
}
26+
})
27+
28+
const selectSpy = vi.fn()
29+
const { renderer } = await render(
30+
<InteractionManager>
31+
<Interactive onSelect={selectSpy}>
32+
<mesh position={[0, 0, -1]}>
33+
<planeGeometry args={[1, 1]} />
34+
</mesh>
35+
</Interactive>
36+
</InteractionManager>,
37+
{ wrapper: createStoreProvider(store) }
38+
)
39+
40+
const mesh = renderer.scene.findByType('Mesh').instance
41+
const interactiveGroup = renderer.scene.findByType('Group').instance
42+
expect(mesh).toBeDefined()
43+
expect(interactiveGroup).toBeDefined()
44+
const intersection: Intersection = {
45+
eventObject: mesh,
46+
distance: 1,
47+
point: new Vector3(0, 0, 0),
48+
object: mesh
49+
}
50+
51+
rightHoverState.set(mesh, intersection)
52+
rightHoverState.set(interactiveGroup, intersection)
53+
54+
await act(async () => {
55+
xrControllerMock.controller.dispatchEvent({ type: 'select', data: {} })
56+
})
57+
58+
expect(selectSpy).toBeCalled()
59+
})
60+
})

src/Interactions.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export function InteractionManager({ children }: { children: React.ReactNode })
5656
if (interactions.size === 0) return
5757

5858
for (const target of controllers) {
59+
if (!target.inputSource?.handedness) {
60+
return
61+
}
5962
const hovering = hoverState[target.inputSource.handedness]
6063
const hits = new Set()
6164
let intersections = intersect(target.controller)
@@ -109,6 +112,9 @@ export function InteractionManager({ children }: { children: React.ReactNode })
109112

110113
const triggerEvent = React.useCallback(
111114
(interaction: XRInteractionType) => (e: XREvent<XRControllerEvent>) => {
115+
if (!e.target.inputSource?.handedness) {
116+
return
117+
}
112118
const hovering = hoverState[e.target.inputSource.handedness]
113119
const intersections = Array.from(new Set(hovering.values()))
114120

src/Teleportation.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const TeleportationPlane = React.forwardRef<THREE.Group, TeleportationPla
7373

7474
const isInteractive = React.useCallback(
7575
(e: XRInteractionEvent): boolean => {
76-
const { handedness } = e.target.inputSource
76+
const handedness = e.target.inputSource?.handedness
7777
return !!((handedness !== 'left' || leftHand) && (handedness !== 'right' || rightHand))
7878
},
7979
[leftHand, rightHand]

src/XR.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
22
import { XRButton } from './XR'
33
import * as React from 'react'
44
import { XRSystemMock } from './mocks/XRSystemMock'
5-
import { render } from './testUtils'
5+
import { render } from './testUtils/testUtilsDom'
66

77
describe('XR', () => {
88
let xrSystemMock = new XRSystemMock()

src/XR.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@ export function useXR<T = XRState>(
476476
export function useController(handedness: XRHandedness) {
477477
const controllers = useXR((state) => state.controllers)
478478
const controller = React.useMemo(
479-
() => controllers.find(({ inputSource }) => inputSource.handedness === handedness),
479+
() => controllers.find(({ inputSource }) => inputSource?.handedness && inputSource.handedness === handedness),
480480
[handedness, controllers]
481481
)
482482

0 commit comments

Comments
 (0)