Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
saitonakamura committed Jul 23, 2023
1 parent 381c399 commit b838d5b
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 217 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"devDependencies": {
"@react-three/drei": "^9.13.2",
"@react-three/fiber": "^8.0.27",
"@react-three/test-renderer": "^8.2.0",
"@types/react": "^18.0.14",
"@types/react-dom": "^18.0.5",
"@types/react-test-renderer": "^18.0.0",
Expand Down
92 changes: 92 additions & 0 deletions src/Controllers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { createStoreMock, createStoreProvider } from './mocks/storeMock'
import { render } from './testUtilsThree'
import { Controllers } from './Controllers'
import { XRControllerMock } from './mocks/XRControllerMock'
import { XRControllerModel } from './XRControllerModel'
import { XRControllerModelFactoryMock } from './mocks/XRControllerModelFactoryMock'
// import { XRControllerModelMock } from './mocks/XRControllerModelMock'

// vi.mock('./XRControllerModel', async () => {
// const { XRControllerModelMock } = await vi.importActual('./mocks/XRControllerMock')
// return { XRControllerModel: XRControllerModelMock }
// })

vi.mock('./XRControllerModelFactory', async () => {
const { XRControllerModelFactoryMock } = await vi.importActual('./mocks/XRControllerModelFactoryMock')
return { XRControllerModelFactory: XRControllerModelFactoryMock }
})

describe('Controllers', () => {
it('should not render anything if controllers in state are empty', async () => {
const store = createStoreMock()
const xrControllerMock = new XRControllerMock(0)
store.setState({ controllers: [] })

const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })

// We aren't rendering anything as a direct children, only in portals
const graph = renderer.toGraph()
expect(graph).toHaveLength(0)
// Checking portals
expect(xrControllerMock.grip.children).toHaveLength(0)
expect(xrControllerMock.controller.children).toHaveLength(0)
})

it('should render one xr controller model and one ray given one controller in state', async () => {
const store = createStoreMock()
const xrControllerMock = new XRControllerMock(0)
store.setState({ controllers: [xrControllerMock] })

const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })

// We aren't rendering anything as a direct children, only in portals
const graph = renderer.toGraph()
expect(graph).toHaveLength(0)
// Checking portals
expect(xrControllerMock.grip.children).toHaveLength(1)
expect(xrControllerMock.grip.children[0]).toBeInstanceOf(XRControllerModel)
expect(xrControllerMock.controller.children).toHaveLength(1)
expect(xrControllerMock.controller.children[0].type).toBe('Line')
})

it('should render two xr controller models and two rays given one controller in state', async () => {
const store = createStoreMock()
const xrControllerMockLeft = new XRControllerMock(0)
const xrControllerMockRight = new XRControllerMock(1)
store.setState({ controllers: [xrControllerMockLeft, xrControllerMockRight] })

const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })

// We aren't rendering anything as a direct children, only in portals
const graph = renderer.toGraph()
expect(graph).toHaveLength(0)
// Checking portals
// left
expect(xrControllerMockLeft.grip.children).toHaveLength(1)
expect(xrControllerMockLeft.grip.children[0]).toBeInstanceOf(XRControllerModel)
expect(xrControllerMockLeft.controller.children).toHaveLength(1)
expect(xrControllerMockLeft.controller.children[0].type).toBe('Line')
// right
expect(xrControllerMockRight.grip.children).toHaveLength(1)
expect(xrControllerMockRight.grip.children[0]).toBeInstanceOf(XRControllerModel)
expect(xrControllerMockRight.controller.children).toHaveLength(1)
expect(xrControllerMockRight.controller.children[0].type).toBe('Line')
})

it('should handle xr controller model given one controller in state', async () => {
const store = createStoreMock()
const xrControllerMock = new XRControllerMock(0)
xrControllerMock.inputSource = {}
store.setState({ controllers: [xrControllerMock] })
// const initializeControllerModelSpy = vi.spyOn()

const { renderer } = await render(<Controllers />, { wrapper: createStoreProvider(store) })

const xrControllerModelFactory = XRControllerModelFactoryMock.instance
expect(xrControllerModelFactory).toBeDefined()
expect(xrControllerMock.xrControllerModel).toBeInstanceOf(XRControllerModel)
expect(xrControllerModelFactory?.initializeControllerModel).toBeCalled()
})
})
3 changes: 2 additions & 1 deletion src/Controllers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import * as THREE from 'three'
import { useFrame, Object3DNode, extend, createPortal } from '@react-three/fiber'
import { useXR } from './XR'
import { XRController } from './XRController'
import { XRControllerModel, XRControllerModelFactory } from './XRControllerModelFactory'
import { XRControllerModelFactory } from './XRControllerModelFactory'
import { useCallback } from 'react'
import { XRControllerModel } from './XRControllerModel'

export interface RayProps extends Partial<JSX.IntrinsicElements['object3D']> {
/** The XRController to attach the ray to */
Expand Down
2 changes: 1 addition & 1 deletion src/XRController.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { XRControllerEvent } from './XREvents'
import { XRControllerModel } from './XRControllerModelFactory'
import { XRControllerModel } from './XRControllerModel'

/** Counterpart of WebXRController from three ks
* in a sense that it's long living */
Expand Down
201 changes: 201 additions & 0 deletions src/XRControllerModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
Group,
Texture,
Object3D,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
MeshPhongMaterial,
MeshStandardMaterial,
SphereGeometry
} from 'three'
import { MotionController, MotionControllerConstants } from 'three-stdlib'

const isEnvMapApplicable = (
material: any
): material is MeshBasicMaterial | MeshStandardMaterial | MeshPhongMaterial | MeshLambertMaterial => 'envMap' in material

const applyEnvironmentMap = (envMap: Texture, envMapIntensity: number, obj: Object3D): void => {
obj.traverse((child) => {
if (child instanceof Mesh && isEnvMapApplicable(child.material)) {
child.material.envMap = envMap
if ('envMapIntensity' in child.material) child.material.envMapIntensity = envMapIntensity
child.material.needsUpdate = true
}
})
}

/**
* Walks the model's tree to find the nodes needed to animate the components and
* saves them to the motionContoller components for use in the frame loop. When
* touchpads are found, attaches a touch dot to them.
*/
function findNodes(motionController: MotionController, scene: Object3D): void {
// Loop through the components and find the nodes needed for each components' visual responses
Object.values(motionController.components).forEach((component) => {
const { type, touchPointNodeName, visualResponses } = component

if (type === MotionControllerConstants.ComponentType.TOUCHPAD && touchPointNodeName) {
component.touchPointNode = scene.getObjectByName(touchPointNodeName)
if (component.touchPointNode) {
// Attach a touch dot to the touchpad.
const sphereGeometry = new SphereGeometry(0.001)
const material = new MeshBasicMaterial({ color: 0x0000ff })
const sphere = new Mesh(sphereGeometry, material)
component.touchPointNode.add(sphere)
} else {
console.warn(`Could not find touch dot, ${component.touchPointNodeName}, in touchpad component ${component.id}`)
}
}

// Loop through all the visual responses to be applied to this component
Object.values(visualResponses).forEach((visualResponse) => {
const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse

// If animating a transform, find the two nodes to be interpolated between.
if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && minNodeName && maxNodeName) {
visualResponse.minNode = scene.getObjectByName(minNodeName)
visualResponse.maxNode = scene.getObjectByName(maxNodeName)

// If the extents cannot be found, skip this animation
if (!visualResponse.minNode) {
console.warn(`Could not find ${minNodeName} in the model`)
return
}

if (!visualResponse.maxNode) {
console.warn(`Could not find ${maxNodeName} in the model`)
return
}
}

// If the target node cannot be found, skip this animation
visualResponse.valueNode = scene.getObjectByName(valueNodeName)
if (!visualResponse.valueNode) {
console.warn(`Could not find ${valueNodeName} in the model`)
}
})
})
}

function addAssetSceneToControllerModel(controllerModel: XRControllerModel, scene: Object3D): void {
// Find the nodes needed for animation and cache them on the motionController.
findNodes(controllerModel.motionController!, scene)

// Apply any environment map that the mesh already has set.
if (controllerModel.envMap) {
applyEnvironmentMap(controllerModel.envMap, controllerModel.envMapIntensity, scene)
}

// Add the glTF scene to the controllerModel.
controllerModel.add(scene)
}

export class XRControllerModel extends Group {
envMap: Texture | null
envMapIntensity: number
motionController: MotionController | null
scene: Object3D | null

constructor() {
super()

this.motionController = null
this.envMap = null
this.envMapIntensity = 1
this.scene = null
}

setEnvironmentMap(envMap: Texture, envMapIntensity = 1): XRControllerModel {
if (this.envMap === envMap && this.envMapIntensity === envMapIntensity) {
return this
}

this.envMap = envMap
this.envMapIntensity = envMapIntensity
applyEnvironmentMap(envMap, envMapIntensity, this)

return this
}

connectModel(scene: Object3D): void {
if (!this.motionController) {
console.warn('scene tried to add, but no motion controller')
return
}

this.scene = scene
addAssetSceneToControllerModel(this, scene)
this.dispatchEvent({
type: 'modelconnected',
data: scene
})
}

connectMotionController(motionController: MotionController): void {
this.motionController = motionController
this.dispatchEvent({
type: 'motionconnected',
data: motionController
})
}

/**
* Polls data from the XRInputSource and updates the model's components to match
* the real world data
*/
updateMatrixWorld(force: boolean): void {
super.updateMatrixWorld(force)

if (!this.motionController) return

// Cause the MotionController to poll the Gamepad for data
this.motionController.updateFromGamepad()

// Update the 3D model to reflect the button, thumbstick, and touchpad state
Object.values(this.motionController.components).forEach((component) => {
// Update node data based on the visual responses' current states
Object.values(component.visualResponses).forEach((visualResponse) => {
const { valueNode, minNode, maxNode, value, valueNodeProperty } = visualResponse

// Skip if the visual response node is not found. No error is needed,
// because it will have been reported at load time.
if (!valueNode) return

// Calculate the new properties based on the weight supplied
if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY && typeof value === 'boolean') {
valueNode.visible = value
} else if (
valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM &&
minNode &&
maxNode &&
typeof value === 'number'
) {
valueNode.quaternion.slerpQuaternions(minNode.quaternion, maxNode.quaternion, value)

valueNode.position.lerpVectors(minNode.position, maxNode.position, value)
}
})
})
}

disconnect(): void {
this.dispatchEvent({
type: 'motiondisconnected',
data: this.motionController
})
this.dispatchEvent({
type: 'modeldisconnected',
data: this.scene
})
this.motionController = null
if (this.scene) {
this.remove(this.scene)
}
this.scene = null
}

dispose(): void {
this.disconnect()
}
}
Loading

0 comments on commit b838d5b

Please sign in to comment.