From 20d4997d0b2ce741d854678d3ea97de7b1568e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20=E3=82=B5=E3=82=A4=E3=83=88=E3=83=BC=20=E4=B8=AD?= =?UTF-8?q?=E6=9D=91=20Bashurov?= Date: Fri, 7 Oct 2022 15:06:53 +0000 Subject: [PATCH] feat: Migrated vendored MotionControllers and XRControllerModelFactory to ts --- ...ionControllers.js => MotionControllers.ts} | 175 ++++++++++++++---- ...Factory.js => XRControllerModelFactory.ts} | 93 ++++++---- 2 files changed, 201 insertions(+), 67 deletions(-) rename src/libs/{MotionControllers.js => MotionControllers.ts} (72%) rename src/webxr/{XRControllerModelFactory.js => XRControllerModelFactory.ts} (70%) diff --git a/src/libs/MotionControllers.js b/src/libs/MotionControllers.ts similarity index 72% rename from src/libs/MotionControllers.js rename to src/libs/MotionControllers.ts index 1632542b..315b8ca3 100644 --- a/src/libs/MotionControllers.js +++ b/src/libs/MotionControllers.ts @@ -2,6 +2,57 @@ * @webxr-input-profiles/motion-controllers 1.0.0 https://github.com/immersive-web/webxr-input-profiles */ +import type { Object3D, XRGamepad, XRHandedness, XRInputSource } from 'three' + +interface GamepadIndices { + button: number + xAxis?: number + yAxis?: number +} + +interface VisualResponseDescription { + componentProperty: string + states: string[] + valueNodeProperty: string + valueNodeName: string + minNodeName?: string + maxNodeName?: string +} + +type VisualResponses = Record + +interface ComponentDescription { + type: string + gamepadIndices: GamepadIndices + rootNodeName: string + visualResponses: VisualResponses + touchPointNodeName?: string +} + +interface Components { + [componentKey: string]: ComponentDescription +} + +interface LayoutDescription { + selectComponentId: string + components: Components + gamepadMapping: string + rootNodeName: string + assetPath: string +} + +type Layouts = Partial> + +export interface Profile { + profileId: string + fallbackProfileIds: string[] + layouts: Layouts +} + +interface ProfilesList { + [profileId: string]: { path: string; deprecated?: boolean } | undefined +} + const MotionControllerConstants = { Handedness: Object.freeze({ NONE: 'none', @@ -44,7 +95,7 @@ const MotionControllerConstants = { * @description Static helper function to fetch a JSON file and turn it into a JS object * @param {string} path - Path to JSON file to be fetched */ -async function fetchJsonFile(path) { +async function fetchJsonFile(path: string): Promise { const response = await fetch(path) if (!response.ok) { throw new Error(response.statusText) @@ -53,17 +104,22 @@ async function fetchJsonFile(path) { } } -async function fetchProfilesList(basePath) { +async function fetchProfilesList(basePath: string): Promise { if (!basePath) { throw new Error('No basePath supplied') } const profileListFileName = 'profilesList.json' - const profilesList = await fetchJsonFile(`${basePath}/${profileListFileName}`) + const profilesList = await fetchJsonFile(`${basePath}/${profileListFileName}`) return profilesList } -async function fetchProfile(xrInputSource, basePath, defaultProfile = null, getAssetPath = true) { +async function fetchProfile( + xrInputSource: XRInputSource, + basePath: string, + defaultProfile: string | null = null, + getAssetPath = true, +): Promise<{ profile: Profile; assetPath: string | undefined }> { if (!xrInputSource) { throw new Error('No xrInputSource supplied') } @@ -76,7 +132,7 @@ async function fetchProfile(xrInputSource, basePath, defaultProfile = null, getA const supportedProfilesList = await fetchProfilesList(basePath) // Find the relative path to the first requested profile that is recognized - let match + let match: { profileId: string; profilePath: string; deprecated: boolean } | undefined = undefined xrInputSource.profiles.some((profileId) => { const supportedProfile = supportedProfilesList[profileId] if (supportedProfile) { @@ -106,13 +162,13 @@ async function fetchProfile(xrInputSource, basePath, defaultProfile = null, getA } } - const profile = await fetchJsonFile(match.profilePath) + const profile = await fetchJsonFile(match.profilePath) - let assetPath + let assetPath: string | undefined = undefined if (getAssetPath) { let layout - if (xrInputSource.handedness === 'any') { - layout = profile.layouts[Object.keys(profile.layouts)[0]] + if ((xrInputSource.handedness as string) === 'any') { + layout = profile.layouts[Object.keys(profile.layouts)[0] as XRHandedness] } else { layout = profile.layouts[xrInputSource.handedness] } @@ -141,10 +197,13 @@ const defaultComponentValues = { * API) to the range 0 to 1 (for interpolation). Also caps the X, Y values to be bounded within * a circle. This ensures that thumbsticks are not animated outside the bounds of their physical * range of motion and touchpads do not report touch locations off their physical bounds. - * @param {number} x The original x coordinate in the range -1 to 1 - * @param {number} y The original y coordinate in the range -1 to 1 + * @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) { +function normalizeAxes( + x: number | undefined = 0, + y: number | undefined = 0, +): { normalizedXAxis: number; normalizedYAxis: number } { let xAxis = x let yAxis = y @@ -174,8 +233,18 @@ function normalizeAxes(x = 0, y = 0) { * to the named input changing, this object computes the appropriate weighting to use for * interpolating between the range of motion nodes. */ -class VisualResponse { - constructor(visualResponseDescription) { +class VisualResponse implements VisualResponseDescription { + value: number | boolean + componentProperty: string + states: string[] + valueNodeName: string + valueNodeProperty: string + minNodeName?: string + maxNodeName?: string + valueNode: Object3D | undefined + minNode: Object3D | undefined + maxNode: Object3D | undefined + constructor(visualResponseDescription: VisualResponseDescription) { this.componentProperty = visualResponseDescription.componentProperty this.states = visualResponseDescription.states this.valueNodeName = visualResponseDescription.valueNodeName @@ -194,12 +263,22 @@ class VisualResponse { /** * Computes the visual response's interpolation weight based on component state * @param {Object} componentValues - The component from which to update - * @param {number} xAxis - The reported X axis value of the component - * @param {number} yAxis - The reported Y axis value of the component - * @param {number} button - The reported value of the component's button + * @param {number | undefined} xAxis - The reported X axis value of the component + * @param {number | undefined} yAxis - The reported Y axis value of the component + * @param {number | undefined} button - The reported value of the component's button * @param {string} state - The component's active state */ - updateFromComponent({ xAxis, yAxis, button, state }) { + updateFromComponent({ + xAxis, + yAxis, + button, + state, + }: { + xAxis?: number + yAxis?: number + button?: number + state: string + }): void { const { normalizedXAxis, normalizedYAxis } = normalizeAxes(xAxis, yAxis) switch (this.componentProperty) { case MotionControllerConstants.ComponentProperty.X_AXIS: @@ -209,7 +288,7 @@ class VisualResponse { this.value = this.states.includes(state) ? normalizedYAxis : 0.5 break case MotionControllerConstants.ComponentProperty.BUTTON: - this.value = this.states.includes(state) ? button : 0 + this.value = this.states.includes(state) && button ? button : 0 break case MotionControllerConstants.ComponentProperty.STATE: if (this.valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY) { @@ -224,12 +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 + visualResponses: Record + touchPointNodeName?: string | undefined + touchPointNode?: Object3D + /** - * @param {Object} componentId - Id of the component - * @param {Object} componentDescription - Description of the component to be created + * @param {string} componentId - Id of the component + * @param {InputProfileComponent} componentDescription - Description of the component to be created */ - constructor(componentId, componentDescription) { + constructor(componentId: string, componentDescription: ComponentDescription) { if ( !componentId || !componentDescription || @@ -263,7 +357,7 @@ class Component { } } - get data() { + get data(): { id: Component['id'] } & Component['values'] { const data = { id: this.id, ...this.values } return data } @@ -272,7 +366,7 @@ class Component { * @description Poll for updated data based on current gamepad state * @param {Object} gamepad - The gamepad object from which the component data should be polled */ - updateFromGamepad(gamepad) { + updateFromGamepad(gamepad: XRGamepad): void { // Set the state to default before processing other data sources this.values.state = MotionControllerConstants.ComponentState.DEFAULT @@ -327,19 +421,23 @@ class Component { }) } } - /** * @description Builds a motion controller with components and visual responses based on the * supplied profile description. Data is polled from the xrInputSource's gamepad. * @author Nell Waliczek / https://github.com/NellWaliczek */ class MotionController { + xrInputSource: XRInputSource + assetUrl: string + 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, profile, assetUrl) { + constructor(xrInputSource: XRInputSource, profile: Profile, assetUrl: string) { if (!xrInputSource) { throw new Error('No xrInputSource supplied') } @@ -348,12 +446,17 @@ 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] @@ -364,19 +467,19 @@ class MotionController { this.updateFromGamepad() } - get gripSpace() { + get gripSpace(): XRInputSource['gripSpace'] { return this.xrInputSource.gripSpace } - get targetRaySpace() { + get targetRaySpace(): XRInputSource['targetRaySpace'] { return this.xrInputSource.targetRaySpace } /** * @description Returns a subset of component data for simplified debugging */ - get data() { - const data = [] + get data(): Array { + const data: Array = [] Object.values(this.components).forEach((component) => { data.push(component.data) }) @@ -386,7 +489,7 @@ class MotionController { /** * @description Poll for updated data based on current gamepad state */ - updateFromGamepad() { + updateFromGamepad(): void { Object.values(this.components).forEach((component) => { component.updateFromGamepad(this.xrInputSource.gamepad) }) diff --git a/src/webxr/XRControllerModelFactory.js b/src/webxr/XRControllerModelFactory.ts similarity index 70% rename from src/webxr/XRControllerModelFactory.js rename to src/webxr/XRControllerModelFactory.ts index 5fbfc83c..b3bcfcbb 100644 --- a/src/webxr/XRControllerModelFactory.js +++ b/src/webxr/XRControllerModelFactory.ts @@ -1,11 +1,24 @@ -import { Mesh, MeshBasicMaterial, Object3D, SphereGeometry } from 'three' +import { Mesh, Object3D, SphereGeometry, MeshBasicMaterial } from 'three' +import type { Texture, Group } from 'three' +// @ts-ignore import { GLTFLoader } from '../loaders/GLTFLoader' -import { MotionControllerConstants, fetchProfile, MotionController } from '../libs/MotionControllers' +import { fetchProfile, MotionController, MotionControllerConstants } from '../libs/MotionControllers' 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 constructor() { super() @@ -13,18 +26,13 @@ class XRControllerModel extends Object3D { this.envMap = null } - setEnvironmentMap(envMap) { + setEnvironmentMap(envMap: Texture): XRControllerModel { if (this.envMap == envMap) { return this } this.envMap = envMap - this.traverse((child) => { - if (child.isMesh) { - child.material.envMap = this.envMap - child.material.needsUpdate = true - } - }) + applyEnvironmentMap(this.envMap, this) return this } @@ -33,7 +41,7 @@ class XRControllerModel extends Object3D { * Polls data from the XRInputSource and updates the model's components to match * the real world data */ - updateMatrixWorld(force) { + updateMatrixWorld(force: boolean): void { super.updateMatrixWorld(force) if (!this.motionController) return @@ -52,9 +60,17 @@ class XRControllerModel extends Object3D { if (!valueNode) return // Calculate the new properties based on the weight supplied - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY) { + if ( + valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY && + typeof value === 'boolean' + ) { valueNode.visible = value - } else if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM) { + } 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) @@ -69,12 +85,12 @@ class XRControllerModel extends Object3D { * 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, scene) { +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) { + if (type === MotionControllerConstants.ComponentType.TOUCHPAD && touchPointNodeName) { component.touchPointNode = scene.getObjectByName(touchPointNodeName) if (component.touchPointNode) { // Attach a touch dot to the touchpad. @@ -92,7 +108,11 @@ function findNodes(motionController, scene) { const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse // If animating a transform, find the two nodes to be interpolated between. - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM) { + if ( + valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && + minNodeName && + maxNodeName + ) { visualResponse.minNode = scene.getObjectByName(minNodeName) visualResponse.maxNode = scene.getObjectByName(maxNodeName) @@ -117,18 +137,13 @@ function findNodes(motionController, scene) { }) } -function addAssetSceneToControllerModel(controllerModel, scene) { +function addAssetSceneToControllerModel(controllerModel: XRControllerModel, scene: Object3D): void { // Find the nodes needed for animation and cache them on the motionController. - findNodes(controllerModel.motionController, scene) + findNodes(controllerModel.motionController!, scene) // Apply any environment map that the mesh already has set. if (controllerModel.envMap) { - scene.traverse((child) => { - if (child.isMesh) { - child.material.envMap = controllerModel.envMap - child.material.needsUpdate = true - } - }) + applyEnvironmentMap(controllerModel.envMap, scene) } // Add the glTF scene to the controllerModel. @@ -136,7 +151,10 @@ function addAssetSceneToControllerModel(controllerModel, scene) { } class XRControllerModelFactory { - constructor(gltfLoader = null) { + gltfLoader: GLTFLoader + path: string + private _assetCache: Record + constructor(gltfLoader: GLTFLoader = null) { this.gltfLoader = gltfLoader this.path = DEFAULT_PROFILES_PATH this._assetCache = {} @@ -147,9 +165,9 @@ class XRControllerModelFactory { } } - createControllerModel(controller) { + createControllerModel(controller: Group): XRControllerModel { const controllerModel = new XRControllerModel() - let scene = null + let scene: Object3D | null = null controller.addEventListener('connected', (event) => { const xrInputSource = event.data @@ -158,9 +176,15 @@ 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) - const cachedAsset = this._assetCache[controllerModel.motionController.assetUrl] + const assetUrl = controllerModel.motionController.assetUrl + + const cachedAsset = this._assetCache[assetUrl] if (cachedAsset) { scene = cachedAsset.scene.clone() @@ -173,8 +197,13 @@ class XRControllerModelFactory { this.gltfLoader.setPath('') this.gltfLoader.load( controllerModel.motionController.assetUrl, - (asset) => { - this._assetCache[controllerModel.motionController.assetUrl] = asset + (asset: { scene: Object3D }) => { + if (!controllerModel.motionController) { + console.warn('motionController gone while gltf load, bailing...') + return + } + + this._assetCache[assetUrl] = asset scene = asset.scene.clone() @@ -182,7 +211,7 @@ class XRControllerModelFactory { }, null, () => { - throw new Error(`Asset ${controllerModel.motionController.assetUrl} missing or malformed.`) + throw new Error(`Asset ${assetUrl} missing or malformed.`) }, ) } @@ -194,7 +223,9 @@ class XRControllerModelFactory { controller.addEventListener('disconnected', () => { controllerModel.motionController = null - controllerModel.remove(scene) + if (scene) { + controllerModel.remove(scene) + } scene = null })