diff --git a/src/libs/MotionControllers.ts b/src/libs/MotionControllers.ts index 26754df9..9be02ac5 100644 --- a/src/libs/MotionControllers.ts +++ b/src/libs/MotionControllers.ts @@ -4,42 +4,53 @@ import type { Object3D, XRGamepad, XRHandedness, XRInputSource } from 'three' -// https://github.com/immersive-web/webxr-input-profiles/blob/73d647040a827975a2100325afb3d13e0895b26b/packages/assets/schemas/visualResponses.schema.json -interface InputProfileVisualResponse { +interface GamepadIndices { + button: number + xAxis?: number + yAxis?: number +} + +interface VisualResponseDescription { componentProperty: string states: string[] valueNodeProperty: string valueNodeName: string - property: string minNodeName?: string maxNodeName?: string } -interface InputProfileGamepadIndices { - button: number - xAxis: number - yAxis: number -} +type VisualResponses = Record -// https://github.com/immersive-web/webxr-input-profiles/blob/73d647040a827975a2100325afb3d13e0895b26b/packages/assets/schemas/layout.schema.json -interface InputProfileComponent { +interface ComponentDescription { type: string + gamepadIndices: GamepadIndices rootNodeName: string - touchPointNodeName: string - visualResponses: Record - gamepadIndices: InputProfileGamepadIndices + visualResponses: VisualResponses + touchPointNodeName?: string } -interface InputProfileLayout { + +interface Components { + [componentKey: string]: ComponentDescription +} + +interface LayoutDescription { + selectComponentId: string + components: Components + gamepadMapping: string rootNodeName: string assetPath: string - components: Record } -// https://github.com/immersive-web/webxr-input-profiles/blob/73d647040a827975a2100325afb3d13e0895b26b/packages/assets/schemas/profile.schema.json -interface InputProfile { +type Layouts = Partial> + +export interface Profile { profileId: string fallbackProfileIds: string[] - layouts: Partial> + layouts: Layouts +} + +interface ProfilesList { + [profileId: string]: { path: string; deprecated?: boolean } | undefined } const MotionControllerConstants = { @@ -93,7 +104,7 @@ async function fetchJsonFile(path: string): Promise { } } -async function fetchProfilesList(basePath: string): Promise { +async function fetchProfilesList(basePath: string): Promise { if (!basePath) { throw new Error('No basePath supplied') } @@ -108,7 +119,7 @@ async function fetchProfile( basePath: string, defaultProfile: string | null = null, getAssetPath = true, -): Promise { +): Promise<{ profile: Profile; assetPath: string | undefined }> { if (!xrInputSource) { throw new Error('No xrInputSource supplied') } @@ -153,7 +164,7 @@ async function fetchProfile( const profile = await fetchJsonFile(match.profilePath) - let assetPath + let assetPath: string | undefined = undefined if (getAssetPath) { let layout if ((xrInputSource.handedness as string) === 'any') { @@ -189,7 +200,10 @@ const defaultComponentValues = { * @param {number | undefined} x The original x coordinate in the range -1 to 1 * @param {number | undefined} y The original y coordinate in the range -1 to 1 */ -function normalizeAxes(x = 0, y = 0): { normalizedXAxis: number; normalizedYAxis: number } { +function normalizeAxes( + x: number | undefined = 0, + y: number | undefined = 0, +): { normalizedXAxis: number; normalizedYAxis: number } { let xAxis = x let yAxis = y @@ -219,18 +233,18 @@ function normalizeAxes(x = 0, y = 0): { normalizedXAxis: number; normalizedYAxis * to the named input changing, this object computes the appropriate weighting to use for * interpolating between the range of motion nodes. */ -class VisualResponse { +class VisualResponse implements VisualResponseDescription { value: number | boolean componentProperty: string states: string[] valueNodeName: string valueNodeProperty: string - minNodeName: string | undefined - maxNodeName: string | undefined + minNodeName?: string + maxNodeName?: string valueNode: Object3D | undefined minNode: Object3D | undefined maxNode: Object3D | undefined - constructor(visualResponseDescription: InputProfileVisualResponse) { + constructor(visualResponseDescription: VisualResponseDescription) { this.componentProperty = visualResponseDescription.componentProperty this.states = visualResponseDescription.states this.valueNodeName = visualResponseDescription.valueNodeName @@ -289,20 +303,27 @@ class VisualResponse { } } -class Component { +class Component implements ComponentDescription { id: string + values: { + state: string + button: number | undefined + xAxis: number | undefined + yAxis: number | undefined + } + type: string + gamepadIndices: GamepadIndices rootNodeName: string - touchPointNodeName: string visualResponses: Record - values: { state: string; button: number | undefined; xAxis: number | undefined; yAxis: number | undefined } - gamepadIndices: InputProfileGamepadIndices - touchPointNode: Object3D | undefined + touchPointNodeName?: string | undefined + touchPointNode?: Object3D + /** * @param {string} componentId - Id of the component * @param {InputProfileComponent} componentDescription - Description of the component to be created */ - constructor(componentId: string, componentDescription: InputProfileComponent) { + constructor(componentId: string, componentDescription: ComponentDescription) { if ( !componentId || !componentDescription || @@ -408,15 +429,15 @@ class Component { class MotionController { xrInputSource: XRInputSource assetUrl: string - layoutDescription: InputProfileLayout | undefined + layoutDescription: LayoutDescription id: string components: Record /** - * @param {Object} xrInputSource - The XRInputSource to build the MotionController around - * @param {Object} profile - The best matched profile description for the supplied xrInputSource - * @param {Object} assetUrl + * @param {XRInputSource} xrInputSource - The XRInputSource to build the MotionController around + * @param {Profile} profile - The best matched profile description for the supplied xrInputSource + * @param {string} assetUrl */ - constructor(xrInputSource: XRInputSource, profile: InputProfile, assetUrl: string) { + constructor(xrInputSource: XRInputSource, profile: Profile, assetUrl: string) { if (!xrInputSource) { throw new Error('No xrInputSource supplied') } @@ -425,15 +446,20 @@ class MotionController { throw new Error('No profile supplied') } + if (!profile.layouts[xrInputSource.handedness]) { + throw new Error('No layout for ' + xrInputSource.handedness + ' handedness') + } + this.xrInputSource = xrInputSource this.assetUrl = assetUrl this.id = profile.profileId // Build child components as described in the profile description - this.layoutDescription = profile.layouts[xrInputSource.handedness] + this.layoutDescription = profile.layouts[xrInputSource.handedness]! + this.components = {} - Object.keys(this.layoutDescription!.components).forEach((componentId) => { - const componentDescription = this.layoutDescription!.components[componentId] + Object.keys(this.layoutDescription.components).forEach((componentId) => { + const componentDescription = this.layoutDescription.components[componentId] this.components[componentId] = new Component(componentId, componentDescription) }) diff --git a/src/webxr/XRControllerModelFactory.ts b/src/webxr/XRControllerModelFactory.ts index 0efee12a..c66f45fa 100644 --- a/src/webxr/XRControllerModelFactory.ts +++ b/src/webxr/XRControllerModelFactory.ts @@ -7,6 +7,15 @@ import { fetchProfile, MotionController, MotionControllerConstants } from '../li const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles' const DEFAULT_PROFILE = 'generic-trigger' +const applyEnvironmentMap = (envMap: Texture, obj: Object3D): void => { + obj.traverse((child) => { + if (child instanceof Mesh && 'envMap' in child.material) { + child.material.envMap = envMap + child.material.needsUpdate = true + } + }) +} + class XRControllerModel extends Object3D { envMap: Texture | null motionController: MotionController | null @@ -23,12 +32,7 @@ class XRControllerModel extends Object3D { } this.envMap = envMap - this.traverse((child) => { - if ((child as Mesh).isMesh) { - ;((child as Mesh).material as MeshBasicMaterial).envMap = this.envMap - ;((child as Mesh).material as MeshBasicMaterial).needsUpdate = true - } - }) + applyEnvironmentMap(this.envMap, this) return this } @@ -56,12 +60,20 @@ class XRControllerModel extends Object3D { if (!valueNode) return // Calculate the new properties based on the weight supplied - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY) { - valueNode.visible = value as boolean - } else if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM) { - valueNode.quaternion.slerpQuaternions(minNode!.quaternion, maxNode!.quaternion, value as number) - - valueNode.position.lerpVectors(minNode!.position, maxNode!.position, value as number) + 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) } }) }) @@ -78,7 +90,7 @@ function findNodes(motionController: MotionController, scene: Object3D): void { Object.values(motionController.components).forEach((component) => { const { type, touchPointNodeName, visualResponses } = component - if (type === MotionControllerConstants.ComponentType.TOUCHPAD) { + if (type === MotionControllerConstants.ComponentType.TOUCHPAD && touchPointNodeName) { component.touchPointNode = scene.getObjectByName(touchPointNodeName) if (component.touchPointNode) { // Attach a touch dot to the touchpad. @@ -96,9 +108,13 @@ function findNodes(motionController: MotionController, scene: Object3D): void { const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse // If animating a transform, find the two nodes to be interpolated between. - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM) { - visualResponse.minNode = scene.getObjectByName(minNodeName!) - visualResponse.maxNode = scene.getObjectByName(maxNodeName!) + 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) { @@ -127,12 +143,7 @@ function addAssetSceneToControllerModel(controllerModel: XRControllerModel, scen // Apply any environment map that the mesh already has set. if (controllerModel.envMap) { - scene.traverse((child) => { - if ((child as Mesh).isMesh) { - ;((child as Mesh).material as MeshBasicMaterial).envMap = this.envMap - ;((child as Mesh).material as MeshBasicMaterial).needsUpdate = true - } - }) + applyEnvironmentMap(controllerModel.envMap, scene) } // Add the glTF scene to the controllerModel. @@ -143,9 +154,9 @@ class XRControllerModelFactory { gltfLoader: GLTFLoader path: string private _assetCache: Record - constructor(gltfLoader: GLTFLoader = null) { + constructor(gltfLoader: GLTFLoader = null, path = DEFAULT_PROFILES_PATH) { this.gltfLoader = gltfLoader - this.path = DEFAULT_PROFILES_PATH + this.path = path this._assetCache = {} // If a GLTFLoader wasn't supplied to the constructor create a new one. @@ -154,7 +165,11 @@ class XRControllerModelFactory { } } - createControllerModel(controller: Group): XRControllerModel { + createControllerModel( + controller: Group, + onMotionControllerCreated?: (motionContoller: MotionController) => void, + onMotionControllerDestroyed?: () => void, + ): XRControllerModel { const controllerModel = new XRControllerModel() let scene: Object3D | null = null @@ -165,13 +180,17 @@ class XRControllerModelFactory { fetchProfile(xrInputSource, this.path, DEFAULT_PROFILE) .then(({ profile, assetPath }) => { + if (!assetPath) { + throw new Error('no asset path') + } controllerModel.motionController = new MotionController(xrInputSource, profile, assetPath) + onMotionControllerCreated?.(controllerModel.motionController) const cachedAsset = this._assetCache[controllerModel.motionController.assetUrl] if (cachedAsset) { scene = cachedAsset.scene.clone() - addAssetSceneToControllerModel(controllerModel, scene!) + addAssetSceneToControllerModel(controllerModel, scene) } else { if (!this.gltfLoader) { throw new Error('GLTFLoader not set.') @@ -181,15 +200,20 @@ class XRControllerModelFactory { this.gltfLoader.load( controllerModel.motionController.assetUrl, (asset: { scene: Object3D }) => { - this._assetCache[controllerModel.motionController!.assetUrl] = asset + if (!controllerModel.motionController) { + console.warn('motionController gone while gltf load, bailing...') + return + } + + this._assetCache[controllerModel.motionController.assetUrl] = asset scene = asset.scene.clone() - addAssetSceneToControllerModel(controllerModel, scene!) + addAssetSceneToControllerModel(controllerModel, scene) }, null, () => { - throw new Error(`Asset ${controllerModel.motionController!.assetUrl} missing or malformed.`) + throw new Error(`Asset ${controllerModel.motionController?.assetUrl} missing or malformed.`) }, ) } @@ -201,8 +225,11 @@ class XRControllerModelFactory { controller.addEventListener('disconnected', () => { controllerModel.motionController = null - controllerModel.remove(scene!) + if (scene) { + controllerModel.remove(scene) + } scene = null + onMotionControllerDestroyed?.() }) return controllerModel