├── .gitignore ├── .nvmrc ├── .prettierrc ├── README.md ├── netlify.toml ├── package.json ├── public └── index.html ├── src ├── Axes.tsx ├── Button.tsx ├── Crate.tsx ├── DefaultHandControllers.tsx ├── Grab.tsx ├── HandModel.ts ├── Level.tsx ├── Roboto.json ├── Text.tsx ├── index.tsx ├── poses │ ├── default.ts │ ├── idle.ts │ └── pinch.ts ├── react-app-env.d.ts ├── store.tsx └── styles.css ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .yalc 4 | yalc.lock -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.18.3 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": true 10 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-xr-default-hands 2 | 3 | Hands, but by default, in react-xr with additional hand tracking support for grabbing geometries! 4 | Try it out: https://hands.dries.io 5 | More info: https://twitter.com/CROEWENS/status/1411746127418888193 6 | 7 | ![](https://thumbs.gfycat.com/ImpassionedInferiorJay-size_restricted.gif) 8 | 9 | 10 | This was a prototype that I built for leveraging hand tracking in my WebXR game called [Plockle](https://plockle.com). 11 | ![](https://thumbs.gfycat.com/BeautifulThornyGalah-size_restricted.gif) 12 | 13 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-xr-default-hands", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "lib/index.html", 7 | "dependencies": { 8 | "@react-spring/three": "^9.2.3", 9 | "@react-three/drei": "^6.2.0", 10 | "@react-three/fiber": "^7.0.1", 11 | "@react-three/xr": "^3.1.2", 12 | "@types/react": "^17.0.13", 13 | "@types/react-dom": "^17.0.8", 14 | "@types/react-router-dom": "^5.1.7", 15 | "@types/three": "0.129.1", 16 | "immer": "^9.0.3", 17 | "parcel": "^2.0.0-beta.3.1", 18 | "react": "^18.0.0-alpha-ed6c091fe-20210701", 19 | "react-dom": "^18.0.0-alpha-ed6c091fe-20210701", 20 | "react-merge-refs": "^1.1.0", 21 | "react-router-dom": "^5.2.0", 22 | "rimraf": "^3.0.2", 23 | "three": "0.129.0", 24 | "typescript": "^4.3.2", 25 | "zustand": "^3.5.2" 26 | }, 27 | "devDependencies": {}, 28 | "scripts": { 29 | "start": "yarn clean:cache && parcel public/index.html", 30 | "start:https": "yarn start --https", 31 | "build": "parcel build public/index.html", 32 | "clean": "rimraf node_modules .cache dist", 33 | "clean:cache": "rimraf .parcel-cache" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hand tracking demo 8 | 9 | 10 | 11 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Axes.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from '@react-three/fiber' 2 | import { XRController } from '@react-three/xr' 3 | import React, { useRef } from 'react' 4 | import { BufferGeometry, LineBasicMaterial, Mesh, MeshBasicMaterial, Object3D, SphereGeometry, Vector3 } from 'three' 5 | 6 | import { HandModel } from './HandModel' 7 | 8 | interface Props { 9 | controller: XRController 10 | model: HandModel 11 | } 12 | 13 | export function Axes({ controller, model }: Props) { 14 | useFrame(() => { 15 | if (!model || model?.bones.length === 0) { 16 | return 17 | } 18 | 19 | const indexTip = model!.bones.find((bone) => (bone as any).jointName === 'index-finger-tip')! as Object3D 20 | const thumbTip = model!.bones.find((bone) => (bone as any).jointName === 'thumb-tip')! as Object3D 21 | 22 | const indexJoint = model!.bones.find((bone) => (bone as any).jointName === 'index-finger-phalanx-proximal')! as Object3D 23 | const thumbJoint = model!.bones.find((bone) => (bone as any).jointName === 'thumb-phalanx-proximal')! as Object3D 24 | 25 | const position: Vector3 = indexTip.getWorldPosition(new Vector3()).add(thumbTip.getWorldPosition(new Vector3())).multiplyScalar(0.5) 26 | 27 | const indexKnuckle = model!.bones.find((bone) => (bone as any).jointName === 'index-finger-metacarpal')! as Object3D 28 | const pinkyKnuckle = model!.bones.find((bone) => (bone as any).jointName === 'pinky-finger-metacarpal')! as Object3D 29 | 30 | indexTipRef.current?.position.copy(indexTip.getWorldPosition(new Vector3())) 31 | indexJointRef.current?.position.copy(indexJoint.getWorldPosition(new Vector3())) 32 | thumbTipRef.current?.position.copy(thumbTip.getWorldPosition(new Vector3())) 33 | thumbJointRef.current?.position.copy(thumbJoint.getWorldPosition(new Vector3())) 34 | indexKnuckleRef.current?.position.copy(indexKnuckle.getWorldPosition(new Vector3())) 35 | pinkyKnuckleRef.current?.position.copy(pinkyKnuckle.getWorldPosition(new Vector3())) 36 | positionRef.current?.position.copy(position.clone()) 37 | 38 | const z = thumbJoint.getWorldPosition(new Vector3()).sub(indexJoint.getWorldPosition(new Vector3())).normalize() 39 | 40 | const zPoints: Vector3[] = [position.clone(), position.clone().add(z)] 41 | const zGeom = new BufferGeometry().setFromPoints(zPoints) 42 | zRef.current.geometry = zGeom 43 | 44 | const y = indexKnuckle.getWorldPosition(new Vector3()).sub(pinkyKnuckle.getWorldPosition(new Vector3())).normalize() 45 | 46 | // const yPoints: Vector3[] = [ 47 | // applyControllerOffsetRotation(position.clone().sub(y.clone().divideScalar(2))), 48 | // applyControllerOffsetRotation(position.clone().add(y).sub(y.clone().divideScalar(2))) 49 | // ] 50 | // const yGeom = new BufferGeometry().setFromPoints(yPoints) 51 | // yRef.current.geometry = yGeom 52 | 53 | const x = new Vector3().crossVectors(z, y).negate() 54 | 55 | const xPoints: Vector3[] = [position.clone(), position.clone().add(x.clone())] 56 | const xGeom = new BufferGeometry().setFromPoints(xPoints) 57 | xRef.current.geometry = xGeom 58 | 59 | const y2 = new Vector3().crossVectors(x, z).negate() 60 | 61 | const y2Points: Vector3[] = [position.clone(), position.clone().add(y2.clone())] 62 | const y2Geom = new BufferGeometry().setFromPoints(y2Points) 63 | y2Ref.current.geometry = y2Geom 64 | 65 | const distance = indexTip.getWorldPosition(new Vector3()).sub(thumbTip.getWorldPosition(new Vector3())) 66 | 67 | thumbTipCollidingRef.current?.position.copy( 68 | thumbTip.getWorldPosition(new Vector3()).add(new Vector3().copy(distance.clone().divideScalar(20))) 69 | ) 70 | 71 | indexTipCollidingRef.current?.position.copy( 72 | indexTip.getWorldPosition(new Vector3()).sub(new Vector3().copy(distance.clone().divideScalar(20))) 73 | ) 74 | }) 75 | 76 | const thumbTipRef = useRef(null) 77 | const thumbJointRef = useRef(null) 78 | const thumbTipCollidingRef = useRef(null) 79 | const indexTipRef = useRef(null) 80 | const indexJointRef = useRef(null) 81 | const indexTipCollidingRef = useRef(null) 82 | const indexKnuckleRef = useRef(null) 83 | const pinkyKnuckleRef = useRef(null) 84 | const positionRef = useRef(null) 85 | 86 | const zRef = useRef() 87 | const yRef = useRef() 88 | const y2Ref = useRef() 89 | const xRef = useRef() 90 | 91 | return ( 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | {/* @ts-ignore */} 104 | 105 | {/* @ts-ignore 106 | */} 107 | {/* @ts-ignore */} 108 | 109 | {/* @ts-ignore */} 110 | 111 | 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /src/Button.tsx: -------------------------------------------------------------------------------- 1 | import { RoundedBox } from '@react-three/drei' 2 | import { Interactive } from '@react-three/xr' 3 | import React, { Suspense, useState } from 'react' 4 | import { a, useSpring } from '@react-spring/three' 5 | import Text from './Text' 6 | 7 | export function Button({ children, label, onClick, highlight = true, fontSize = 0.07, args, ...rest }: any) { 8 | const [hovered, setHovered] = useState(false) 9 | const [style] = useSpring( 10 | { 11 | scale: hovered ? 1.05 : 1, 12 | color: hovered ? '#66f' : highlight ? '#f3f3f3' : '#6e6e6e' 13 | }, 14 | [hovered, highlight] 15 | ) 16 | 17 | return ( 18 | setHovered(true)} onBlur={() => setHovered(false)} onSelect={onClick ? onClick : () => null}> 19 | 20 | 21 | 22 | 23 | 24 | {label || children} 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/Crate.tsx: -------------------------------------------------------------------------------- 1 | import { useGLTF } from '@react-three/drei' 2 | import React, { useEffect, useRef, useState } from 'react' 3 | import { Box3, Matrix3, Mesh, MeshStandardMaterial, Object3D, Vector3 } from 'three' 4 | import { OBB } from 'three/examples/jsm/math/OBB' 5 | 6 | import { Grab } from './Grab' 7 | import { useStore } from './store' 8 | 9 | export default function Crate(props: any) { 10 | const group = useRef() 11 | const { nodes, materials } = useGLTF('https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/crate/model.gltf') as any 12 | 13 | const materialsRef = useRef(materials) 14 | const [isGrabbed, setGrabbed] = useState(false) 15 | 16 | const set = useStore((store) => store.set) 17 | 18 | useEffect(() => { 19 | materialsRef.current = Object.values(materials).reduce((object: object, material: MeshStandardMaterial) => { 20 | object[material.name] = material.clone() 21 | material.metalness = 0.5 22 | return object 23 | }, {}) 24 | }, [materials]) 25 | 26 | useEffect(() => { 27 | if (isGrabbed) { 28 | Object.values(materialsRef.current).forEach((material: MeshStandardMaterial) => { 29 | material.metalness = 0.1 30 | }) 31 | } else { 32 | Object.values(materialsRef.current).forEach((material: MeshStandardMaterial) => { 33 | material.metalness = 0.5 34 | }) 35 | } 36 | }, [isGrabbed]) 37 | 38 | return ( 39 | { 41 | setGrabbed(isGrabbed) 42 | if (isGrabbed) { 43 | set((store) => { 44 | store.hands.interacting!.current[controller.inputSource.handedness] = group.current 45 | }) 46 | } 47 | }} 48 | callback={({ controller, model }) => { 49 | // do initial position check (if futher, don't check for collisions) 50 | const position = model.getHandPosition() 51 | const cratePosition = group.current.getWorldPosition(new Vector3()) 52 | 53 | // calculate based on bounding box 54 | // for now hardcodedackage 55 | if (position.distanceTo(cratePosition) > 0.2) { 56 | // console.log('IGNORED') 57 | return 58 | } 59 | 60 | let mesh: Mesh | undefined = undefined 61 | group.current!.traverse((object) => { 62 | if (!mesh && object instanceof Mesh && object.geometry) { 63 | mesh = object 64 | } 65 | }) 66 | if (!mesh) { 67 | return 68 | } 69 | 70 | const obb = new OBB( 71 | new Vector3().setFromMatrixPosition(group.current!.matrixWorld), 72 | ((mesh as Mesh).geometry!.boundingBox as Box3).getSize(new Vector3()).multiply(group.current!.scale).divideScalar(2), 73 | new Matrix3().setFromMatrix4(group.current!.matrixWorld.clone().makeScale(1, 1, 1)) 74 | ) 75 | 76 | const matrix = model!.getHandRotationMatrix() 77 | 78 | const indexTip = model!.bones.find((bone) => (bone as any).jointName === 'index-finger-tip')! as Object3D 79 | const thumbTip = model!.bones.find((bone) => (bone as any).jointName === 'thumb-tip')! as Object3D 80 | 81 | const thumbOBB = new OBB( 82 | indexTip.getWorldPosition(new Vector3()), 83 | new Vector3(0.05, 0.05, 0.05).divideScalar(2), 84 | new Matrix3().setFromMatrix4(matrix) 85 | ) 86 | const indexOBB = new OBB( 87 | thumbTip.getWorldPosition(new Vector3()), 88 | new Vector3(0.05, 0.05, 0.05).divideScalar(2), 89 | new Matrix3().setFromMatrix4(matrix) 90 | ) 91 | 92 | return obb.intersectsOBB(thumbOBB, Number.EPSILON) && obb.intersectsOBB(indexOBB, Number.EPSILON) 93 | }} 94 | ref={group} 95 | {...props} 96 | dispose={null}> 97 | 98 | 99 | 100 | 101 | 102 | ) 103 | } 104 | 105 | useGLTF.preload('https://vazxmixjsiawhamofees.supabase.co/storage/v1/object/public/models/crate/model.gltf') 106 | -------------------------------------------------------------------------------- /src/DefaultHandControllers.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from '@react-three/fiber' 2 | import { useXR, useXREvent, XREvent } from '@react-three/xr' 3 | import React, { useEffect, useRef, useState } from 'react' 4 | import { BoxBufferGeometry, Color, Intersection, Mesh, MeshBasicMaterial, XRHandedness } from 'three' 5 | 6 | import { Axes } from './Axes' 7 | import { HandModel } from './HandModel' 8 | import { useStore } from './store' 9 | 10 | enum HandAction { 11 | 'release', 12 | 'grab' 13 | } 14 | 15 | export function DefaultHandControllers({ modelPaths }: { modelPaths?: { [key in XRHandedness]?: string } }) { 16 | const { controllers, isHandTracking, isPresenting, hoverState } = useXR() 17 | 18 | const models = useStore((store) => store.hands.models) 19 | const set = useStore((store) => store.set) 20 | 21 | const handActions = useRef<{ [key in XRHandedness]?: { date: number; distance: number; action: HandAction }[] }>({ left: [], right: [] }) 22 | 23 | const [pinched, setPinched] = useState<{ [key in XRHandedness]?: boolean }>({ left: false, right: false }) 24 | const [rays] = React.useState(new Map()) 25 | 26 | const modelsRef = useRef<{ [key in XRHandedness]?: HandModel }>({ 27 | left: undefined, 28 | right: undefined 29 | }) 30 | 31 | const interactingRef = useRef<{ [key in XRHandedness]?: HandModel }>({ 32 | left: undefined, 33 | right: undefined 34 | }) 35 | 36 | useEffect(() => { 37 | set((store) => { 38 | store.hands.models = modelsRef 39 | store.hands.interacting = interactingRef 40 | }) 41 | }, []) 42 | 43 | useEffect(() => { 44 | // handle cleanups 45 | if (models?.current) { 46 | controllers.forEach((c) => { 47 | let model = models?.current[c.inputSource.handedness] 48 | if (!model) { 49 | const model = new HandModel(c.controller, c.inputSource, modelPaths) 50 | models!.current[c.inputSource.handedness] = model 51 | 52 | const ray = new Mesh() 53 | ray.rotation.set(Math.PI / 2, 0, 0) 54 | ray.material = new MeshBasicMaterial({ color: new Color(0xffffff), opacity: 0.8, transparent: true }) 55 | ray.geometry = new BoxBufferGeometry(0.002, 1, 0.002) 56 | 57 | rays.set(c.controller.id, ray) 58 | c.controller.add(ray) 59 | } 60 | }) 61 | } 62 | }, [controllers]) 63 | 64 | useEffect(() => { 65 | // fix this firing twice when going in vr mode 66 | // this doesn't do anything && Object.values(models!.current).filter((model) => !!model).length === controllers.length 67 | if (isPresenting) { 68 | controllers.forEach((c, index) => { 69 | let model = models!.current[c.inputSource.handedness] 70 | if (model) { 71 | if (isHandTracking) { 72 | model.load(c.hand, c.inputSource, true) 73 | } else { 74 | model.load(c.controller, c.inputSource, false) 75 | } 76 | models!.current[c.inputSource.handedness] = model 77 | } 78 | }) 79 | } 80 | }, [controllers, isHandTracking, models]) 81 | 82 | useFrame(() => { 83 | controllers.map((c, index) => { 84 | if (isHandTracking) { 85 | const model = models?.current[c.inputSource.handedness] 86 | if (model && !model?.loading) { 87 | const distance = model!.getThumbIndexDistance() 88 | 89 | // get todo actions 90 | const actions = handActions.current[c.inputSource.handedness]!.filter(({ date }) => Date.now() > date) 91 | // remove from initial list 92 | actions.forEach((x) => 93 | handActions.current[c.inputSource.handedness]!.splice(handActions.current[c.inputSource.handedness]!.indexOf(x), 1) 94 | ) 95 | 96 | // mutates the actions, but we don't need those anymore 97 | // otherwise .slice(-1) 98 | const action = actions.pop() 99 | 100 | const isPinched = pinched[c.inputSource.handedness] 101 | handActions.current[c.inputSource.handedness]!.push({ 102 | date: Date.now() + 200, 103 | action: isPinched ? HandAction.release : HandAction.grab, 104 | distance: distance + (isPinched ? 0.05 : -0.05) 105 | }) 106 | 107 | // might be that we still push a "grab", even though we should wait for a release 108 | if (!isPinched && ((action?.action === HandAction.grab && action.distance > distance) || distance < 0.01)) { 109 | c.controller.dispatchEvent({ type: 'selectstart', fake: true }) 110 | setPinched({ ...pinched, [c.inputSource.handedness]: true }) 111 | } 112 | 113 | if (isPinched && ((action?.action === HandAction.release && action.distance < distance) || distance > 0.1)) { 114 | c.controller.dispatchEvent({ type: 'selectend', fake: true }) 115 | setPinched({ ...pinched, [c.inputSource.handedness]: false }) 116 | } 117 | } 118 | } 119 | 120 | const ray = rays.get(c.controller.id) 121 | if (!ray) return 122 | 123 | const intersection: Intersection = hoverState[c.inputSource.handedness].values().next().value 124 | if (!intersection || c.inputSource.handedness === 'none') { 125 | ray.visible = false 126 | return 127 | } 128 | 129 | const rayLength = intersection?.distance ?? 5 130 | 131 | // Tiny offset to clip ray on AR devices 132 | // that don't have handedness set to 'none' 133 | const offset = -0.01 134 | ray.visible = true 135 | ray.scale.y = rayLength + offset 136 | ray.position.z = -rayLength / 2 - offset 137 | }) 138 | }) 139 | 140 | useXREvent('selectstart', (e: XREvent) => { 141 | if (!isHandTracking) { 142 | const model = models?.current[e.controller.inputSource.handedness] 143 | if (model) { 144 | model.setPose('pinch') 145 | } 146 | } 147 | }) 148 | 149 | useXREvent('selectend', (e: XREvent) => { 150 | if (!isHandTracking) { 151 | const model = models?.current[e.controller.inputSource.handedness] 152 | if (model) { 153 | model.setPose('idle') 154 | } 155 | } 156 | }) 157 | 158 | return ( 159 | <> 160 | {process.env.NODE_ENV === 'development' && 161 | controllers.map((c, index) => 162 | models?.current[c.inputSource.handedness] ? : null 163 | )} 164 | 165 | ) 166 | } 167 | -------------------------------------------------------------------------------- /src/Grab.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from '@react-three/fiber' 2 | import { useXR, useXREvent, XRController, XREvent } from '@react-three/xr' 3 | import React, { forwardRef, ReactNode, useRef } from 'react' 4 | import mergeRefs from 'react-merge-refs' 5 | import { Box3, Matrix3, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from 'three' 6 | import { OBB } from 'three/examples/jsm/math/OBB' 7 | 8 | import { HandModel } from './HandModel' 9 | import { useStore } from './store' 10 | 11 | export const Grab = forwardRef( 12 | ( 13 | { 14 | children, 15 | disabled = false, 16 | onChange, 17 | // instead of checking for a generic way here, do the calculation in the Interactable itself. 18 | callback, 19 | ...props 20 | }: { 21 | children: ReactNode 22 | disabled?: boolean 23 | onChange: ({ isGrabbed, controller }: { isGrabbed: boolean; controller: XRController }) => void 24 | callback: ({ controller, model }: { controller: XRController; model: HandModel }) => boolean 25 | }, 26 | passedRef 27 | ) => { 28 | const grabbingController = useRef() 29 | const ref = useRef() 30 | const previousTransform = useRef(undefined) 31 | const { isHandTracking } = useXR() 32 | 33 | const set = useStore((store) => store.set) 34 | 35 | const interacting = useStore((store) => ({ 36 | left: store.hands.interacting?.current.left, 37 | right: store.hands.interacting?.current.right 38 | })) 39 | 40 | const models = useStore((store) => store.hands.models) 41 | 42 | useXREvent('selectend', (e: XREvent) => { 43 | if ( 44 | e.controller === grabbingController.current && 45 | interacting[e.controller.inputSource.handedness] && 46 | ((isHandTracking && e.originalEvent.fake) || !isHandTracking) 47 | ) { 48 | set((store) => { 49 | store.hands.interacting!.current[grabbingController.current!.inputSource.handedness] = undefined 50 | }) 51 | grabbingController.current = undefined 52 | previousTransform.current = undefined 53 | onChange({ isGrabbed: false, controller: e.controller }) 54 | } 55 | }) 56 | 57 | useXREvent('selectstart', (e: XREvent) => { 58 | // if the controller is already interacting, don't do anything 59 | // if hand tracking is enabled, but it's not a fake event, don't do anything 60 | if ((disabled && interacting[e.controller.inputSource.handedness]) || (isHandTracking && !e.originalEvent.fake)) { 61 | return 62 | } 63 | 64 | const model = models?.current[e.controller.inputSource.handedness] 65 | 66 | if (!model) { 67 | return 68 | } 69 | 70 | let colliding = false 71 | 72 | if (!callback) { 73 | // NOT USED FOR NOW HERE, WE USE THE CALLBACK FOR TESTING 74 | let mesh: Mesh | undefined = undefined 75 | ref.current!.traverse((object) => { 76 | if (!mesh && object instanceof Mesh && object.geometry) { 77 | mesh = object 78 | } 79 | }) 80 | if (!mesh) { 81 | return 82 | } 83 | 84 | const obb = new OBB( 85 | new Vector3().setFromMatrixPosition(ref.current!.matrixWorld), 86 | ((mesh as Mesh).geometry!.boundingBox as Box3).getSize(new Vector3()).multiply(ref.current!.scale).divideScalar(2), 87 | new Matrix3().setFromMatrix4(ref.current!.matrixWorld.clone().makeScale(1, 1, 1)) 88 | ) 89 | 90 | const matrix = model!.getHandRotationMatrix() 91 | 92 | const indexTip = model!.bones.find((bone) => (bone as any).jointName === 'index-finger-tip')! as Object3D 93 | const thumbTip = model!.bones.find((bone) => (bone as any).jointName === 'thumb-tip')! as Object3D 94 | 95 | const thumbOBB = new OBB( 96 | indexTip.getWorldPosition(new Vector3()), 97 | new Vector3(0.05, 0.05, 0.05).divideScalar(2), 98 | new Matrix3().setFromMatrix4(matrix) 99 | ) 100 | const indexOBB = new OBB( 101 | thumbTip.getWorldPosition(new Vector3()), 102 | new Vector3(0.05, 0.05, 0.05).divideScalar(2), 103 | new Matrix3().setFromMatrix4(matrix) 104 | ) 105 | 106 | colliding = obb.intersectsOBB(thumbOBB, Number.EPSILON) && obb.intersectsOBB(indexOBB, Number.EPSILON) 107 | } else { 108 | colliding = callback({ controller: e.controller, model: model }) 109 | } 110 | 111 | if (colliding) { 112 | grabbingController.current = e.controller 113 | const transform = model.getHandTransform() 114 | previousTransform.current = transform.clone() 115 | onChange({ isGrabbed: true, controller: e.controller }) 116 | } 117 | }) 118 | 119 | useFrame(() => { 120 | if (!grabbingController.current || !previousTransform.current || !ref.current) { 121 | return 122 | } 123 | 124 | const model = models?.current[grabbingController.current.inputSource.handedness] 125 | 126 | if (!model) { 127 | return 128 | } 129 | 130 | let transform = model.getHandTransform() 131 | 132 | // apply previous transform 133 | ref.current!.applyMatrix4(previousTransform.current.clone().invert()) 134 | 135 | if (isHandTracking) { 136 | // get quaternion from previous matrix 137 | const previousQuaternion = new Quaternion() 138 | previousTransform.current.decompose(new Vector3(), previousQuaternion, new Vector3(1, 1, 1)) 139 | 140 | // get quaternion from current matrix 141 | const currentQuaternion = new Quaternion() 142 | transform.decompose(new Vector3(), currentQuaternion, new Vector3(1, 1, 1)) 143 | 144 | // slerp to current quaternion 145 | previousQuaternion.slerp(currentQuaternion, 0.1) 146 | 147 | const position = model.getHandPosition() 148 | transform = new Matrix4().compose(position, previousQuaternion, new Vector3(1, 1, 1)) 149 | } 150 | 151 | ref.current!.applyMatrix4(transform) 152 | 153 | ref.current!.updateWorldMatrix(false, true) 154 | previousTransform.current = transform.clone() 155 | }) 156 | 157 | return ( 158 | 163 | > 164 | {children} 165 | 166 | ) 167 | } 168 | ) 169 | -------------------------------------------------------------------------------- /src/HandModel.ts: -------------------------------------------------------------------------------- 1 | import { Euler, Group, Matrix4, Mesh, Object3D, Quaternion, Vector3, XRHandedness, XRInputSource } from 'three' 2 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 3 | 4 | import { defaultPose } from './poses/default' 5 | import { idlePose } from './poses/idle' 6 | import { pinchPose } from './poses/pinch' 7 | 8 | const DEFAULT_HAND_PROFILE_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles/generic-hand/' 9 | 10 | const XRHandJoints = [ 11 | 'wrist', 12 | 'thumb-metacarpal', 13 | 'thumb-phalanx-proximal', 14 | 'thumb-phalanx-distal', 15 | 'thumb-tip', 16 | 'index-finger-metacarpal', 17 | 'index-finger-phalanx-proximal', 18 | 'index-finger-phalanx-intermediate', 19 | 'index-finger-phalanx-distal', 20 | 'index-finger-tip', 21 | 'middle-finger-metacarpal', 22 | 'middle-finger-phalanx-proximal', 23 | 'middle-finger-phalanx-intermediate', 24 | 'middle-finger-phalanx-distal', 25 | 'middle-finger-tip', 26 | 'ring-finger-metacarpal', 27 | 'ring-finger-phalanx-proximal', 28 | 'ring-finger-phalanx-intermediate', 29 | 'ring-finger-phalanx-distal', 30 | 'ring-finger-tip', 31 | 'pinky-finger-metacarpal', 32 | 'pinky-finger-phalanx-proximal', 33 | 'pinky-finger-phalanx-intermediate', 34 | 'pinky-finger-phalanx-distal', 35 | 'pinky-finger-tip' 36 | ] 37 | 38 | export type XRPose = 'pinch' | 'idle' | 'default' 39 | 40 | const poses: { [key in XRPose]: object } = { 41 | idle: idlePose, 42 | pinch: pinchPose, 43 | default: defaultPose 44 | } 45 | 46 | class HandModel extends Object3D { 47 | controller: Group 48 | bones: Object3D[] = [] 49 | 50 | inputSource: XRInputSource 51 | path: string 52 | 53 | model: Object3D 54 | isHandTracking: boolean 55 | 56 | modelPaths?: { [key in XRHandedness]?: string } 57 | 58 | loading: boolean 59 | loaded: boolean 60 | 61 | constructor(controller: Group, inputSource: XRInputSource, modelPaths?: { [key in XRHandedness]?: string }) { 62 | super() 63 | 64 | this.controller = controller 65 | this.inputSource = inputSource 66 | this.modelPaths = modelPaths 67 | } 68 | 69 | load(controller: Group, inputSource: XRInputSource, isHandTracking: boolean) { 70 | this.controller.remove(this) 71 | 72 | this.controller = controller 73 | this.inputSource = inputSource 74 | this.isHandTracking = isHandTracking 75 | 76 | this.loading = true 77 | this.loaded = false 78 | const loader = new GLTFLoader() 79 | // loader.setPath(this.modelPath ?? DEFAULT_HAND_PROFILE_PATH) 80 | const fileHandedness = isHandTracking ? this.inputSource.handedness : 'right' 81 | loader.load((this.modelPaths && this.modelPaths[fileHandedness]) ?? `${DEFAULT_HAND_PROFILE_PATH}${fileHandedness}.glb`, (gltf) => { 82 | this.model = gltf.scene.children[0] 83 | 84 | // clearing everything first 85 | super.clear() 86 | super.add(this.model) 87 | 88 | const mesh = this.model.getObjectByProperty('type', 'SkinnedMesh')! as Mesh 89 | mesh.frustumCulled = false 90 | mesh.castShadow = true 91 | mesh.receiveShadow = true 92 | ;(mesh.material as any).side = 0 // Workaround: force FrontSide = 0 93 | 94 | this.bones = [] 95 | XRHandJoints.forEach((jointName: string) => { 96 | const bone = this.model.getObjectByName(jointName) 97 | if (bone !== undefined) { 98 | ;(bone as any).jointName = jointName 99 | } else { 100 | console.log(`Couldn't find ${jointName} in ${this.inputSource.handedness} hand mesh`) 101 | } 102 | this.bones.push(bone!) 103 | }) 104 | 105 | if (!isHandTracking) { 106 | this.setPose('idle') 107 | this.model.setRotationFromEuler(new Euler(Math.PI / 2, -Math.PI / 2, 0)) 108 | 109 | // hand position offset 110 | this.model.position.sub(new Vector3(-0.02, 0.05, -0.12)) 111 | 112 | // only mirror the left one (this is also the right model here) 113 | if (this.inputSource.handedness === 'left') { 114 | this.model.applyMatrix4(new Matrix4().makeScale(-1, 1, 1)) 115 | } 116 | } 117 | 118 | this.loading = false 119 | this.loaded = true 120 | this.controller.add(this) 121 | }) 122 | } 123 | 124 | updateMatrixWorld(force: boolean) { 125 | super.updateMatrixWorld(force) 126 | 127 | for (let i = 0; i < this.bones.length; i++) { 128 | const bone = this.bones[i] 129 | if (bone) { 130 | const XRJoint = ((this.controller as any)?.joints || [])[(bone as any).jointName] 131 | if (XRJoint?.visible) { 132 | const position = XRJoint.position 133 | bone.position.copy(position) 134 | bone.quaternion.copy(XRJoint.quaternion) 135 | } 136 | } 137 | } 138 | } 139 | 140 | setPose(poseType: XRPose = 'idle') { 141 | const pose = poses[poseType] 142 | for (let i = 0; i < this.bones.length; i++) { 143 | const bone = this.bones[i] 144 | if (bone) { 145 | const joint = pose[(bone as any).jointName] 146 | const position = joint.position 147 | bone.position.copy(new Vector3().fromArray(position)) 148 | bone.quaternion.copy(new Quaternion().fromArray(joint.quaternion)) 149 | } 150 | } 151 | } 152 | 153 | // should not be used anymore 154 | getThumbIndexDistance() { 155 | const indexTip = this!.bones.find((bone) => (bone as any).jointName === 'index-finger-tip')! as Object3D 156 | const thumbTip = this!.bones.find((bone) => (bone as any).jointName === 'thumb-tip')! as Object3D 157 | 158 | return indexTip.getWorldPosition(new Vector3()).distanceTo(thumbTip.getWorldPosition(new Vector3())) 159 | } 160 | 161 | getHandTransform() { 162 | const quaternion = new Quaternion() 163 | this.getHandRotationMatrix().decompose(new Vector3(), quaternion, new Vector3()) 164 | const position = this.getHandPosition() 165 | 166 | return new Matrix4().compose(position, quaternion, new Vector3(1, 1, 1)) 167 | } 168 | 169 | getHandRotationMatrix() { 170 | const indexTip = this!.bones.find((bone) => (bone as any).jointName === 'index-finger-phalanx-proximal')! as Object3D 171 | const thumbTip = this!.bones.find((bone) => (bone as any).jointName === 'thumb-phalanx-proximal')! as Object3D 172 | const indexKnuckle = this!.bones.find((bone) => (bone as any).jointName === 'index-finger-metacarpal')! as Object3D 173 | const pinkyKnuckle = this!.bones.find((bone) => (bone as any).jointName === 'pinky-finger-metacarpal')! as Object3D 174 | 175 | const z = thumbTip.getWorldPosition(new Vector3()).sub(indexTip.getWorldPosition(new Vector3())).normalize() 176 | 177 | const y = indexKnuckle.getWorldPosition(new Vector3()).sub(pinkyKnuckle.getWorldPosition(new Vector3())).normalize() 178 | 179 | const x = new Vector3().crossVectors(z, y).negate() 180 | 181 | const y2 = new Vector3().crossVectors(x, z).negate() 182 | 183 | return new Matrix4().makeBasis(x, y2, z) 184 | } 185 | 186 | getHandPosition() { 187 | const indexTip = this!.bones.find((bone) => (bone as any).jointName === 'index-finger-tip')! as Object3D 188 | const thumbTip = this!.bones.find((bone) => (bone as any).jointName === 'thumb-tip')! as Object3D 189 | 190 | const position: Vector3 = indexTip.getWorldPosition(new Vector3()).add(thumbTip.getWorldPosition(new Vector3())).multiplyScalar(0.5) 191 | 192 | return position 193 | } 194 | } 195 | 196 | export { HandModel } 197 | -------------------------------------------------------------------------------- /src/Level.tsx: -------------------------------------------------------------------------------- 1 | import { XRInteractionEvent } from '@react-three/xr' 2 | import React, { ReactElement, Suspense, useMemo, useState } from 'react' 3 | import { Vector3 } from 'three' 4 | import { Button } from './Button' 5 | import Crate from './Crate' 6 | import { useStore } from './store' 7 | 8 | interface Props {} 9 | 10 | export default function Level({}: Props): ReactElement { 11 | const [shouldReload, setReload] = useState(false) 12 | const interacting = useStore((store) => ({ 13 | left: store.hands.interacting?.current.left, 14 | right: store.hands.interacting?.current.right 15 | })) 16 | 17 | const blocks = useMemo(() => { 18 | return [...Array(10)].map((_) => { 19 | const angle = Math.random() * Math.PI * 2 20 | return ( 21 | 22 | 23 | 24 | ) 25 | }) 26 | }, [shouldReload]) 27 | 28 | return ( 29 | <> 30 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {blocks} 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/Text.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useLayoutEffect, useRef, useMemo } from 'react' 2 | import { useLoader } from '@react-three/fiber' 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | import robotoFont from 'url:/src/Roboto.json' 7 | import { DoubleSide, Font, FontLoader, Mesh, MeshBasicMaterial, ShapeGeometry, Vector3 } from 'three' 8 | 9 | const Text = forwardRef(({ children, vAlign = 'center', hAlign = 'center', size = 1, color = '#000000', ...props }, ref) => { 10 | const font = useLoader(FontLoader, robotoFont) as Font 11 | 12 | const matLite = new MeshBasicMaterial({ 13 | color: color, 14 | side: DoubleSide 15 | }) 16 | 17 | const geometry = useMemo(() => { 18 | if (font) { 19 | const shapes = font.generateShapes(children, 1) 20 | const geometry = new ShapeGeometry(shapes) 21 | geometry.computeBoundingBox() 22 | return geometry 23 | } 24 | }, [font]) 25 | 26 | const mesh = useRef() 27 | 28 | useLayoutEffect(() => { 29 | const size = new Vector3() 30 | mesh.current.geometry.computeBoundingBox() 31 | mesh.current.geometry.boundingBox.getSize(size) 32 | mesh.current.position.x = hAlign === 'center' ? -size.x / 2 : hAlign === 'right' ? 0 : -size.x 33 | mesh.current.position.y = vAlign === 'center' ? -size.y / 2 : vAlign === 'top' ? 0 : -size.y 34 | }, [children]) 35 | 36 | return ( 37 | 38 | 39 | 40 | ) 41 | }) 42 | 43 | export default Text 44 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | 3 | import { OrbitControls } from '@react-three/drei' 4 | import { VRCanvas } from '@react-three/xr' 5 | import React from 'react' 6 | import ReactDOM from 'react-dom' 7 | import { VRButton } from 'three/examples/jsm/webxr/VRButton' 8 | 9 | import { DefaultHandControllers } from './DefaultHandControllers' 10 | import Level from './Level' 11 | 12 | // Oculus Browser with #webxr-hands flag enabled 13 | function HandControllersExample() { 14 | return ( 15 | { 18 | args.gl.setClearColor('grey') 19 | void document.body.appendChild(VRButton.createButton(args.gl)) 20 | }}> 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | ;(ReactDOM as any).createRoot(document.getElementById('root')).render() 31 | -------------------------------------------------------------------------------- /src/poses/default.ts: -------------------------------------------------------------------------------- 1 | export const defaultPose = { 2 | wrist: { 3 | position: [1.8626447606528984e-10, -8.940696516468449e-10, 5.96046212386625e-10], 4 | quaternion: [-0.7071068286895752, 5.760118781950041e-8, 5.760119137221409e-8, 0.7071068286895752] 5 | }, 6 | 'thumb-metacarpal': { 7 | position: [-0.028028501197695732, -0.0359584279358387, 0.019157717004418373], 8 | quaternion: [-0.4763745069503784, 0.7100346684455872, 0.18028733134269714, 0.48622485995292664] 9 | }, 10 | 'thumb-phalanx-proximal': { 11 | position: [-0.044955722987651825, -0.05921865254640579, 0.03430764749646187], 12 | quaternion: [-0.47673991322517395, 0.6406403183937073, 0.11477915197610855, 0.5908678770065308] 13 | }, 14 | 'thumb-phalanx-distal': { 15 | position: [-0.06691253185272217, -0.08312328159809113, 0.0437118336558342], 16 | quaternion: [-0.39142173528671265, 0.7194157242774963, 0.09644205868244171, 0.5656226277351379] 17 | }, 18 | 'thumb-tip': { 19 | position: [-0.07789862900972366, -0.09167803823947906, 0.04891208931803703], 20 | quaternion: [-0.3947904706001282, 0.7221444249153137, 0.09097205102443695, 0.5606889724731445] 21 | }, 22 | 'index-finger-metacarpal': { 23 | position: [-0.015884488821029663, -0.029212743043899536, 0.009419834241271019], 24 | quaternion: [-0.77494215965271, 0.05332411825656891, -0.03195803984999657, 0.6289674043655396] 25 | }, 26 | 'index-finger-phalanx-proximal': { 27 | position: [-0.023550676181912422, -0.08854873478412628, 0.007316457573324442], 28 | quaternion: [-0.7392873764038086, 0.01683184690773487, 0.028036130592226982, 0.6725956797599792] 29 | }, 30 | 'index-finger-phalanx-intermediate': { 31 | position: [-0.022021925076842308, -0.13375452160835266, 0.01055047009140253], 32 | quaternion: [-0.7388502955436707, -0.01528934109956026, 0.020967017859220505, 0.673369824886322] 33 | }, 34 | 'index-finger-phalanx-distal': { 35 | position: [-0.02069162018597126, -0.15792004764080048, 0.01276903785765171], 36 | quaternion: [-0.686407744884491, -0.04156109690666199, 0.031442154198884964, 0.7253471612930298] 37 | }, 38 | 'index-finger-tip': { 39 | position: [-0.019425027072429657, -0.16941747069358826, 0.012159150093793869], 40 | quaternion: [-0.6862807869911194, -0.049977876245975494, 0.03852125257253647, 0.7245944738388062] 41 | }, 42 | 'middle-finger-metacarpal': { 43 | position: [-0.0032591435592621565, -0.029106352478265762, 0.009419835172593594], 44 | quaternion: [-0.7821785807609558, -0.023043932393193245, -0.06304748356342316, 0.6194276809692383] 45 | }, 46 | 'middle-finger-phalanx-proximal': { 47 | position: [-0.001725903362967074, -0.09164661914110184, 0.002543156733736396], 48 | quaternion: [-0.7427287697792053, -0.04151846468448639, 0.028317611664533615, 0.6677038669586182] 49 | }, 50 | 'middle-finger-phalanx-intermediate': { 51 | position: [0.002640301128849387, -0.13811546564102173, 0.007021433673799038], 52 | quaternion: [-0.7380437850952148, -0.06258413195610046, 0.036574337631464005, 0.6708479523658752] 53 | }, 54 | 'middle-finger-phalanx-distal': { 55 | position: [0.005685935262590647, -0.1653556078672409, 0.009792773053050041], 56 | quaternion: [-0.6704923510551453, -0.0809938833117485, 0.01419301237910986, 0.7373456954956055] 57 | }, 58 | 'middle-finger-tip': { 59 | position: [0.007302379701286554, -0.17740768194198608, 0.008763404563069344], 60 | quaternion: [-0.6713148355484009, -0.07821036130189896, 0.008088618516921997, 0.7369899153709412] 61 | }, 62 | 'ring-finger-metacarpal': { 63 | position: [0.008265813812613487, -0.026990527287125587, 0.009419836103916168], 64 | quaternion: [-0.779512882232666, -0.12313608825206757, -0.04890383780002594, 0.6122137308120728] 65 | }, 66 | 'ring-finger-phalanx-proximal': { 67 | position: [0.017465244978666306, -0.0846937820315361, 0.006529302801936865], 68 | quaternion: [-0.7372061014175415, -0.11990050226449966, 0.046976324170827866, 0.6632829904556274] 69 | }, 70 | 'ring-finger-phalanx-intermediate': { 71 | position: [0.02676110528409481, -0.1263144463300705, 0.010884810239076614], 72 | quaternion: [-0.7334646582603455, -0.1584361046552658, 0.03868652135133743, 0.6598719954490662] 73 | }, 74 | 'ring-finger-phalanx-distal': { 75 | position: [0.03319219499826431, -0.15188787877559662, 0.01416861079633236], 76 | quaternion: [-0.7179667949676514, -0.13197831809520721, 0.006449223030358553, 0.683420717716217] 77 | }, 78 | 'ring-finger-tip': { 79 | position: [0.03552267327904701, -0.16357211768627167, 0.014942227862775326], 80 | quaternion: [-0.7187318205833435, -0.13417485356330872, 0.003463511122390628, 0.6822094321250916] 81 | }, 82 | 'pinky-finger-metacarpal': { 83 | position: [0.022998575121164322, -0.034073565155267715, 0.009419831447303295], 84 | quaternion: [-0.6950896382331848, -0.24571159482002258, -0.052773747593164444, 0.6735659837722778] 85 | }, 86 | 'pinky-finger-phalanx-proximal': { 87 | position: [0.03505408763885498, -0.07389584183692932, 0.013691178523004055], 88 | quaternion: [-0.7371914982795715, -0.16642968356609344, -0.004589088726788759, 0.6548503041267395] 89 | }, 90 | 'pinky-finger-phalanx-intermediate': { 91 | position: [0.042367469519376755, -0.10740291327238083, 0.01811724156141281], 92 | quaternion: [-0.7181382179260254, -0.22876721620559692, 0.011543449014425278, 0.6571223735809326] 93 | }, 94 | 'pinky-finger-phalanx-distal': { 95 | position: [0.04895488917827606, -0.12640321254730225, 0.0209718719124794], 96 | quaternion: [-0.7028482556343079, -0.1945386528968811, -0.017829783260822296, 0.6839891672134399] 97 | }, 98 | 'pinky-finger-tip': { 99 | position: [0.05193105340003967, -0.13790695369243622, 0.02176165021955967], 100 | quaternion: [-0.7031674981117249, -0.2156880795955658, 0.0012595904991030693, 0.6775193810462952] 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/poses/idle.ts: -------------------------------------------------------------------------------- 1 | export const idlePose = { 2 | wrist: { position: [0, 0, 0], quaternion: [-0.6661913377195962, -0.01640709815701443, -0.11120849576590103, 0.7372601841595182] }, 3 | 'thumb-metacarpal': { 4 | position: [-0.03472973685711622, -0.03378564119338989, 0.010930486023426056], 5 | quaternion: [-0.4370977050405571, 0.801618309803501, 0.07092227508523968, 0.40165123245521117] 6 | }, 7 | 'thumb-phalanx-proximal': { 8 | position: [-0.053650377318263054, -0.048898518085479736, 0.03262612223625183], 9 | quaternion: [-0.5233785648562362, 0.7282073040614634, 0.1202576486209243, 0.4258251966578869] 10 | }, 11 | 'thumb-phalanx-distal': { 12 | position: [-0.07035422325134277, -0.06988000869750977, 0.05318659543991089], 13 | quaternion: [-0.4581473272467597, 0.7965817964345437, 0.10612693530136055, 0.37986253004120674] 14 | }, 15 | 'thumb-tip': { 16 | position: [-0.0838727355003357, -0.08245950937271118, 0.0694720670580864], 17 | quaternion: [-0.4581473272467597, 0.7965817964345437, 0.10612693530136055, 0.37986253004120674] 18 | }, 19 | 'index-finger-metacarpal': { 20 | position: [-0.022320279851555824, -0.03902029991149902, 0.001892339438199997], 21 | quaternion: [-0.7246411098667119, 0.07379692097282581, -0.16971426364871023, 0.6638119802024115] 22 | }, 23 | 'index-finger-phalanx-proximal': { 24 | position: [-0.03621675749309361, -0.09194290637969971, -0.0076351650059223175], 25 | quaternion: [-0.7178758851519482, 0.014103382112460663, -0.09023996284710636, 0.6901536475565782] 26 | }, 27 | 'index-finger-phalanx-intermediate': { 28 | position: [-0.04186903452500701, -0.12942814826965332, -0.0064560286700725555], 29 | quaternion: [-0.7210158724051519, -0.009055942274381036, -0.10289564726845996, 0.6851763184922297] 30 | }, 31 | 'index-finger-phalanx-distal': { 32 | position: [-0.04517357889562845, -0.15348654985427856, -0.0054865144193172455], 33 | quaternion: [-0.7015695377374556, -0.042174841107665895, -0.09454715252033762, 0.7050406388618262] 34 | }, 35 | 'index-finger-tip': { 36 | position: [-0.04632382467389107, -0.17581260204315186, -0.006704933941364288], 37 | quaternion: [-0.7015695377374556, -0.042174841107665895, -0.09454715252033762, 0.7050406388618262] 38 | }, 39 | 'middle-finger-metacarpal': { 40 | position: [-0.009995488449931145, -0.04066729545593262, 0.004085216671228409], 41 | quaternion: [-0.7433134312652817, -0.03421360938765304, -0.16345737120332623, 0.6477625024903804] 42 | }, 43 | 'middle-finger-phalanx-proximal': { 44 | position: [-0.014013143256306648, -0.09428155422210693, -0.008505694568157196], 45 | quaternion: [-0.9808402755032382, -0.015176837171140472, -0.09547985644838834, 0.1691319442825862] 46 | }, 47 | 'middle-finger-phalanx-intermediate': { 48 | position: [-0.021833037957549095, -0.10864841938018799, 0.031182728707790375], 49 | quaternion: [0.7920805673660862, -0.04819466602845819, 0.08346967527489871, 0.6027590416384704] 50 | }, 51 | 'middle-finger-phalanx-distal': { 52 | position: [-0.0238752830773592, -0.08212053775787354, 0.03832988440990448], 53 | quaternion: [0.2946729689061379, 0.0006527793449358052, 0.0037696326864468167, 0.955590500760958] 54 | }, 55 | 'middle-finger-tip': { 56 | position: [-0.02366100437939167, -0.06711900234222412, 0.018341291695833206], 57 | quaternion: [0.2946729689061379, 0.0006527793449358052, 0.0037696326864468167, 0.955590500760958] 58 | }, 59 | 'ring-finger-metacarpal': { 60 | position: [0.001606617122888565, -0.03937530517578125, 0.006404057145118713], 61 | quaternion: [-0.7528343283461107, -0.1418028902789975, -0.15518802882148325, 0.6237379979468028] 62 | }, 63 | 'ring-finger-phalanx-proximal': { 64 | position: [0.004814211279153824, -0.09050309658050537, -0.0005191080272197723], 65 | quaternion: [-0.9866094475388417, -0.05959877380566362, -0.13640739820695835, 0.06665437646825109] 66 | }, 67 | 'ring-finger-phalanx-intermediate': { 68 | position: [-0.0053722187876701355, -0.09626609086990356, 0.03667929023504257], 69 | quaternion: [0.6924812721369185, -0.02458229946205448, 0.16012588303971034, 0.7030114507414129] 70 | }, 71 | 'ring-finger-phalanx-distal': { 72 | position: [-0.010346893221139908, -0.07018381357192993, 0.03562350571155548], 73 | quaternion: [0.35032013919195115, -0.05025873404845777, 0.14068244212261186, 0.9246395569122036] 74 | }, 75 | 'ring-finger-tip': { 76 | position: [-0.010712355375289917, -0.05287259817123413, 0.018459565937519073], 77 | quaternion: [0.35032013919195115, -0.05025873404845777, 0.14068244212261186, 0.9246395569122036] 78 | }, 79 | 'pinky-finger-metacarpal': { 80 | position: [0.016442324966192245, -0.03768932819366455, 0.00937221571803093], 81 | quaternion: [-0.6706119869110772, -0.2552411067241566, -0.1670991033321349, 0.6761726333673979] 82 | }, 83 | 'pinky-finger-phalanx-proximal': { 84 | position: [0.02196628227829933, -0.08298414945602417, 0.010729294270277023], 85 | quaternion: [0.9705382983826922, 0.055846077967221575, 0.23396139656455833, 0.014095810216820616] 86 | }, 87 | 'pinky-finger-phalanx-intermediate': { 88 | position: [0.007966633886098862, -0.08294636011123657, 0.03807436674833298], 89 | quaternion: [0.6245149964487291, -0.04919814445092069, 0.19564827203562896, 0.7545079956121755] 90 | }, 91 | 'pinky-finger-phalanx-distal': { 92 | position: [0.004511065781116486, -0.06341385841369629, 0.03370495140552521], 93 | quaternion: [0.4009713821417304, -0.06275414592364197, 0.20712684434689518, 0.8901586028476283] 94 | }, 95 | 'pinky-finger-tip': { 96 | position: [0.002585865557193756, -0.046552300453186035, 0.01977252960205078], 97 | quaternion: [0.4009713821417304, -0.06275414592364197, 0.20712684434689518, 0.8901586028476283] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/poses/pinch.ts: -------------------------------------------------------------------------------- 1 | export const pinchPose = { 2 | wrist: { position: [0, 0, 0], quaternion: [-0.6527377146928867, -0.04197306512862318, -0.14750412810399346, 0.7418990967870879] }, 3 | 'thumb-metacarpal': { 4 | position: [-0.028708115220069885, -0.03802686929702759, 0.01521763950586319], 5 | quaternion: [-0.5018827760314641, 0.7401433178094579, 0.26408695476159333, 0.361330359297011] 6 | }, 7 | 'thumb-phalanx-proximal': { 8 | position: [-0.03747981786727905, -0.06252908706665039, 0.03470578417181969], 9 | quaternion: [-0.5550449762706562, 0.6602166569844041, 0.29175448580945107, 0.41342273784482036] 10 | }, 11 | 'thumb-phalanx-distal': { 12 | position: [-0.0449826717376709, -0.09105652570724487, 0.05119417980313301], 13 | quaternion: [-0.47353951037964503, 0.7562219659595647, 0.22196308825783814, 0.39321884207232527] 14 | }, 15 | 'thumb-tip': { 16 | position: [-0.05551515519618988, -0.10836756229400635, 0.06518009305000305], 17 | quaternion: [-0.47353951037964503, 0.7562219659595647, 0.22196308825783814, 0.39321884207232527] 18 | }, 19 | 'index-finger-metacarpal': { 20 | position: [-0.02306044101715088, -0.03862184286117554, -0.0009634285233914852], 21 | quaternion: [-0.7085028717326327, 0.05255272987000617, -0.20691323120441896, 0.6726431491380622] 22 | }, 23 | 'index-finger-phalanx-proximal': { 24 | position: [-0.036951854825019836, -0.0910634994506836, -0.01286252960562706], 25 | quaternion: [-0.9520349028684798, -0.01887202901320798, -0.0894724011539764, 0.2920069856576565] 26 | }, 27 | 'index-finger-phalanx-intermediate': { 28 | position: [-0.04299519956111908, -0.11227923631668091, 0.017989562824368477], 29 | quaternion: [-0.9948080696577799, -0.018279256564374204, -0.09906207423043162, 0.014473381511597695] 30 | }, 31 | 'index-finger-phalanx-distal': { 32 | position: [-0.04777246713638306, -0.11306709051132202, 0.04180603101849556], 33 | quaternion: [-0.9966383793141129, -0.0348255636574372, -0.07342604494118556, 0.010379639607186953] 34 | }, 35 | 'index-finger-tip': { 36 | position: [-0.05066494643688202, -0.11464649438858032, 0.06395097076892853], 37 | quaternion: [-0.9966383793141129, -0.0348255636574372, -0.07342604494118556, 0.010379639607186953] 38 | }, 39 | 'middle-finger-metacarpal': { 40 | position: [-0.011009499430656433, -0.04054677486419678, 0.0022756149992346764], 41 | quaternion: [-0.728576836221424, -0.05452649690398996, -0.20251115292559713, 0.6520673951346632] 42 | }, 43 | 'middle-finger-phalanx-proximal': { 44 | position: [-0.014801859855651855, -0.0938032865524292, -0.011814166558906436], 45 | quaternion: [-0.9643559144702265, 0.002296662407013733, -0.14345090442071629, 0.22233810647066332] 46 | }, 47 | 'middle-finger-phalanx-intermediate': { 48 | position: [-0.02672255039215088, -0.11218321323394775, 0.025101996958255768], 49 | quaternion: [0.829592712909983, -0.09380243778711712, 0.1057267691567219, 0.5401841201252863] 50 | }, 51 | 'middle-finger-phalanx-distal': { 52 | position: [-0.02876339852809906, -0.08694499731063843, 0.03595779836177826], 53 | quaternion: [0.36512814687348283, -0.047265183334492235, 0.0021122995466034376, 0.9297542562396359] 54 | }, 55 | 'middle-finger-tip': { 56 | position: [-0.026344195008277893, -0.06916528940200806, 0.018560543656349182], 57 | quaternion: [0.36512814687348283, -0.047265183334492235, 0.0021122995466034376, 0.9297542562396359] 58 | }, 59 | 'ring-finger-metacarpal': { 60 | position: [0.0003604888916015625, -0.03952282667160034, 0.0056415945291519165], 61 | quaternion: [-0.7396808097017777, -0.16093438809609775, -0.1956158263891162, 0.6234636083631186] 62 | }, 63 | 'ring-finger-phalanx-proximal': { 64 | position: [0.0033015459775924683, -0.09054785966873169, -0.0021084961481392384], 65 | quaternion: [-0.9716843359574324, -0.05038339502943255, -0.17894891706806382, 0.14583672322305824] 66 | }, 67 | 'ring-finger-phalanx-intermediate': { 68 | position: [-0.00968681275844574, -0.10230308771133423, 0.03273133561015129], 69 | quaternion: [0.7759395377553959, -0.05574459995025362, 0.18814084493382177, 0.5995109638629774] 70 | }, 71 | 'ring-finger-phalanx-distal': { 72 | position: [-0.015669360756874084, -0.07702267169952393, 0.03832182288169861], 73 | quaternion: [0.32587486330368326, -0.10939771910707881, 0.1317591102661254, 0.9297726869417349] 74 | }, 75 | 'ring-finger-tip': { 76 | position: [-0.013075575232505798, -0.060324668884277344, 0.020747322589159012], 77 | quaternion: [0.32587486330368326, -0.10939771910707881, 0.1317591102661254, 0.9297726869417349] 78 | }, 79 | 'pinky-finger-metacarpal': { 80 | position: [0.01489970088005066, -0.038179636001586914, 0.00994948297739029], 81 | quaternion: [-0.6574740246753815, -0.27719103523257715, -0.2055514915428402, 0.6698071522373896] 82 | }, 83 | 'pinky-finger-phalanx-proximal': { 84 | position: [0.01950991153717041, -0.08358919620513916, 0.010780317708849907], 85 | quaternion: [-0.954898987936288, -0.09855212426684874, -0.26847438050731093, 0.07985554866106723] 86 | }, 87 | 'pinky-finger-phalanx-intermediate': { 88 | position: [0.0042420923709869385, -0.0898999571800232, 0.036680374294519424], 89 | quaternion: [0.7371802888124096, -0.028210442936276114, 0.26473157891790616, 0.6210367008635028] 90 | }, 91 | 'pinky-finger-phalanx-distal': { 92 | position: [-0.0029739439487457275, -0.07099878787994385, 0.03847714141011238], 93 | quaternion: [0.38578432275097324, -0.11353125177035334, 0.2603789668506576, 0.8777721257893837] 94 | }, 95 | 'pinky-finger-tip': { 96 | position: [-0.0038779377937316895, -0.05425739288330078, 0.024298351258039474], 97 | quaternion: [0.38578432275097324, -0.11353125177035334, 0.2603789668506576, 0.8777721257893837] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/store.tsx: -------------------------------------------------------------------------------- 1 | import produce, { setAutoFreeze } from 'immer' 2 | import { MutableRefObject } from 'react' 3 | import { Object3D, XRHandedness } from 'three' 4 | import createStore, { State as ZustandState, StateCreator } from 'zustand' 5 | 6 | import { HandModel } from './HandModel' 7 | 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore 10 | const immer = 11 | (config: StateCreator void) => void>): StateCreator => 12 | (set, get, api) => 13 | config((fn) => set(produce(fn) as (state: T) => T), get, api) 14 | 15 | setAutoFreeze(false) 16 | 17 | export type State = { 18 | hands: { 19 | models?: MutableRefObject<{ [key in XRHandedness]?: HandModel }> 20 | interacting?: MutableRefObject<{ [key in XRHandedness]?: Object3D }> 21 | } 22 | set: (fn: (state: State) => void | State) => void 23 | } 24 | 25 | export const useStore = createStore( 26 | immer((set, get, api) => { 27 | return { 28 | hands: { 29 | models: undefined, 30 | interacting: undefined 31 | }, 32 | set: (fn: (state: State) => State) => { 33 | set(fn) 34 | } 35 | } 36 | }) 37 | ) 38 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "ESNext"], 4 | "typeRoots": [ 5 | "node_modules/@types", 6 | "src/utils/types" 7 | ] , 8 | "target": "esnext", 9 | "module": "commonjs", 10 | "noImplicitAny": false, 11 | "jsx": "react-jsx", 12 | "strict": false, 13 | "esModuleInterop": true, 14 | "outDir": "dist", 15 | "removeComments": true 16 | } 17 | } 18 | 19 | --------------------------------------------------------------------------------