Spaces:
Running
Running
Download src/video/model.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 47.7 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/model.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/model.ts
-
curl -L -o model.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/model.ts
47.7 kB
| import * as THREE from 'three' | |
| import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js' | |
| import type { V3 } from './math' | |
| /* ------------------------------------------------------------------ */ | |
| /* Pose */ | |
| /* ------------------------------------------------------------------ */ | |
| // [wristFlex, wristDev(+ulnar), pron, spread, | |
| // idx mcp,pip,dip, mid.., ring.., little.., | |
| // tAbd, tFlex, tOpp, tMcp, tIp] | |
| export type Pose = number[] | |
| export const POSE = { | |
| rest: [0.05, 0, 0, 0.05, 0.18, 0.22, 0.1, 0.22, 0.28, 0.12, 0.28, 0.32, 0.15, 0.34, 0.36, 0.18, 0.05, 0.1, 0, 0.12, 0.12], | |
| open: [-0.1, 0, 0, 0.18, 0.02, 0.04, 0.02, 0.02, 0.04, 0.02, 0.04, 0.05, 0.03, 0.06, 0.06, 0.04, 0.35, -0.05, 0, 0.0, 0.0], | |
| fist: [0.05, 0, 0, 0, 1.45, 1.6, 0.9, 1.5, 1.65, 0.9, 1.5, 1.65, 0.9, 1.45, 1.6, 0.9, -0.1, 0.55, 0.3, 0.6, 0.6], | |
| // Finkelstein-like: thumb tucked + ulnar deviation (provokes pain) | |
| tuck: [0.0, 0.42, 0, 0, 1.25, 1.5, 0.9, 1.35, 1.55, 0.9, 1.35, 1.55, 0.9, 1.3, 1.5, 0.9, -0.1, 0.6, -0.35, 1.1, 0.8], | |
| grip: [0.0, 0.05, 0, 0.02, 1.05, 1.15, 0.55, 1.12, 1.2, 0.55, 1.15, 1.2, 0.55, 1.1, 1.15, 0.55, -0.05, 0.5, 0.35, 0.35, 0.35], | |
| gripBall: [-0.05, 0.05, 0, 0.12, 0.75, 0.85, 0.45, 0.78, 0.9, 0.45, 0.78, 0.9, 0.45, 0.72, 0.85, 0.45, 0.05, 0.45, 0.35, 0.2, 0.3], | |
| pinch: [0.0, 0.12, 0, 0.04, 0.85, 0.95, 0.45, 0.9, 1.0, 0.5, 0.95, 1.05, 0.55, 1.0, 1.1, 0.6, 0.1, 0.4, 0.3, 0.25, 0.15], | |
| mouse: [-0.25, 0.1, 0, 0.08, 0.3, 0.35, 0.18, 0.32, 0.38, 0.2, 0.36, 0.42, 0.22, 0.42, 0.45, 0.25, 0.25, 0.2, 0.15, 0.12, 0.1], | |
| baby: [-0.2, 0.0, 0, 0.12, 0.35, 0.3, 0.12, 0.38, 0.32, 0.12, 0.42, 0.34, 0.14, 0.45, 0.36, 0.15, 0.62, -0.25, 0.05, -0.05, 0.0], | |
| phone: [-0.1, 0.05, 0, 0.0, 0.9, 0.7, 0.3, 0.95, 0.75, 0.3, 1.0, 0.8, 0.35, 1.05, 0.85, 0.4, 0.3, 0.15, 0.2, 0.15, 0.1], | |
| cup: [0.0, 0.0, 0, 0.0, 1.3, 0.75, 0.35, 1.35, 0.8, 0.35, 1.38, 0.85, 0.4, 1.4, 0.9, 0.45, 0.05, 0.4, 0.35, 0.25, 0.25], | |
| } satisfies Record<string, Pose> | |
| /* ------------------------------------------------------------------ */ | |
| /* Scene state */ | |
| /* ------------------------------------------------------------------ */ | |
| export interface PropState { vis: number; [k: string]: number } | |
| export interface SceneState { | |
| cam: { pos: V3; target: V3; fov: number; roll?: number; follow?: number; shift?: [number, number] } | |
| arm: { pos: V3; rot: V3; twist?: { pivot: V3; axis: V3; angle: number } } | |
| pose: Pose | |
| skin: number // overall skin visibility 0..1 | |
| xray: number // 0 opaque skin → 1 transparent x-ray skin | |
| bone: number // reveal 0..1 | |
| tendon: number | |
| sheath: number | |
| thick: number // sheath thickening 0..1 | |
| friction: number // tendon hot-spot 0..1 | |
| pain: number // red glow 0..1 | |
| slide?: number // extra tendon slide offset | |
| focus?: number // highlight the 1st compartment (dims others) | |
| props: Record<string, PropState | undefined> | |
| shake?: V3 | |
| time: number | |
| } | |
| export const PAIN_LOCAL: V3 = [-0.3, -0.17, 0.06] // in forearm space (1st dorsal compartment) | |
| /* ------------------------------------------------------------------ */ | |
| /* Shaders */ | |
| /* ------------------------------------------------------------------ */ | |
| const VS = /* glsl */ ` | |
| varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv; | |
| void main(){ | |
| vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz; | |
| vec4 mv = viewMatrix * w; vV = mv.xyz; | |
| vN = normalize(normalMatrix * normal); vUv = uv; | |
| gl_Position = projectionMatrix * mv; | |
| }` | |
| const LIGHT = /* glsl */ ` | |
| vec3 shade(vec3 base, vec3 N, vec3 V, float shin, float specAmt, float wrapAmt){ | |
| vec3 L1 = normalize(vec3(0.45,0.75,0.55)); | |
| vec3 L2 = normalize(vec3(-0.75,0.15,0.35)); | |
| vec3 L3 = normalize(vec3(0.15,-0.5,-0.85)); | |
| float d1 = max((dot(N,L1)+wrapAmt)/(1.0+wrapAmt),0.0); | |
| float d2 = max(dot(N,L2),0.0); | |
| float d3 = max(dot(N,L3),0.0); | |
| vec3 c = base*(0.2 + 0.82*d1) + base*d2*0.28*vec3(0.75,0.88,1.0); | |
| float s = pow(max(dot(N,normalize(L1+V)),0.0),shin)*specAmt; | |
| float fr = pow(1.0-max(dot(N,V),0.0),3.0); | |
| c += vec3(s) + fr*vec3(0.35,0.62,1.0)*0.32 + d3*0.12*vec3(0.4,0.65,1.0); | |
| return c; | |
| } | |
| uniform vec3 uRC; uniform float uRR; | |
| float reveal(){ float d = distance(vW,uRC); if(d>uRR) discard; return 1.0-smoothstep(0.0,0.07,uRR-d); } | |
| ` | |
| function revealUniforms() { | |
| return { uRC: { value: new THREE.Vector3() }, uRR: { value: 100 } } | |
| } | |
| function makeOrganic(color: string, shin = 30, spec = 0.25, wrap = 0.3) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { uColor: { value: new THREE.Color(color) }, uAlpha: { value: 1 }, uDim: { value: 0 }, ...revealUniforms() }, | |
| vertexShader: VS, | |
| fragmentShader: /* glsl */ ` | |
| varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv; | |
| uniform vec3 uColor; uniform float uAlpha; uniform float uDim; | |
| ${LIGHT} | |
| void main(){ | |
| float e = reveal(); | |
| vec3 N = normalize(vN); if(!gl_FrontFacing) N = -N; vec3 V = normalize(-vV); | |
| vec3 c = shade(uColor, N, V, ${shin.toFixed(1)}, ${spec.toFixed(3)}, ${wrap.toFixed(2)}); | |
| c = mix(c, c*0.35 + vec3(0.02,0.05,0.1), uDim); | |
| c += vec3(0.3,0.9,1.0)*e*1.6; | |
| gl_FragColor = vec4(c, uAlpha); | |
| }`, | |
| transparent: false, | |
| }) | |
| } | |
| /* ------------------------------------------------------------------ */ | |
| /* Tube builder (dynamic) */ | |
| /* ------------------------------------------------------------------ */ | |
| class DynTube { | |
| geo = new THREE.BufferGeometry() | |
| pos: Float32Array; nor: Float32Array; uv: Float32Array | |
| curve = new THREE.CatmullRomCurve3([new THREE.Vector3(), new THREE.Vector3(0, 1, 0)], false, 'centripetal') | |
| constructor(public segs: number, public radial: number) { | |
| const n = (segs + 1) * (radial + 1) | |
| this.pos = new Float32Array(n * 3); this.nor = new Float32Array(n * 3); this.uv = new Float32Array(n * 2) | |
| const idx: number[] = [] | |
| for (let i = 0; i < segs; i++) for (let j = 0; j < radial; j++) { | |
| const a = i * (radial + 1) + j, b = (i + 1) * (radial + 1) + j | |
| idx.push(a, b, a + 1, b, b + 1, a + 1) | |
| } | |
| this.geo.setIndex(idx) | |
| this.geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3)) | |
| this.geo.setAttribute('normal', new THREE.BufferAttribute(this.nor, 3)) | |
| this.geo.setAttribute('uv', new THREE.BufferAttribute(this.uv, 2)) | |
| } | |
| update(points: THREE.Vector3[], radius: (u: number) => number) { | |
| this.curve.points = points | |
| const { segs, radial } = this | |
| const frames = this.curve.computeFrenetFrames(segs, false) | |
| const len = this.curve.getLength() | |
| const p = new THREE.Vector3(), n = new THREE.Vector3() | |
| for (let i = 0; i <= segs; i++) { | |
| const u = i / segs | |
| this.curve.getPointAt(u, p) | |
| const N = frames.normals[i], B = frames.binormals[i] | |
| const r = radius(u) | |
| for (let j = 0; j <= radial; j++) { | |
| const v = (j / radial) * Math.PI * 2 | |
| const s = Math.sin(v), c = -Math.cos(v) | |
| n.set(c * N.x + s * B.x, c * N.y + s * B.y, c * N.z + s * B.z).normalize() | |
| const k = i * (radial + 1) + j | |
| this.pos[k * 3] = p.x + r * n.x; this.pos[k * 3 + 1] = p.y + r * n.y; this.pos[k * 3 + 2] = p.z + r * n.z | |
| this.nor[k * 3] = n.x; this.nor[k * 3 + 1] = n.y; this.nor[k * 3 + 2] = n.z | |
| this.uv[k * 2] = u * len; this.uv[k * 2 + 1] = j / radial | |
| } | |
| } | |
| this.geo.attributes.position.needsUpdate = true | |
| this.geo.attributes.normal.needsUpdate = true | |
| this.geo.attributes.uv.needsUpdate = true | |
| this.geo.computeBoundingSphere() | |
| } | |
| } | |
| /* ------------------------------------------------------------------ */ | |
| /* Hand model */ | |
| /* ------------------------------------------------------------------ */ | |
| const FINGERS = [ | |
| { base: [-0.165, 0.27, 0.02], head: [-0.27, 0.97, 0.035], len: [0.42, 0.25, 0.2], r: 0.086, splay: -0.09 }, | |
| { base: [-0.055, 0.27, 0.025], head: [-0.09, 1.0, 0.04], len: [0.47, 0.29, 0.21], r: 0.09, splay: -0.02 }, | |
| { base: [0.06, 0.27, 0.02], head: [0.09, 0.96, 0.035], len: [0.44, 0.27, 0.2], r: 0.085, splay: 0.05 }, | |
| { base: [0.165, 0.26, 0.01], head: [0.255, 0.88, 0.02], len: [0.34, 0.21, 0.18], r: 0.075, splay: 0.13 }, | |
| ] as const | |
| export class HandModel { | |
| scene = new THREE.Scene() | |
| armRoot = new THREE.Group() | |
| forearm = new THREE.Group() | |
| wrist = new THREE.Group() | |
| fingerJoints: THREE.Group[][] = [] | |
| thumbJoints: THREE.Group[] = [] | |
| thumbBase = new THREE.Quaternion() | |
| skinMat: THREE.ShaderMaterial | |
| skinDepth: THREE.MeshBasicMaterial | |
| boneMat = makeOrganic('#efe5cf', 24, 0.22, 0.35) | |
| cartMat = makeOrganic('#cfe3f0', 40, 0.35, 0.3) | |
| muscleMat = makeOrganic('#b8463f', 20, 0.18, 0.4) | |
| tendonMat: THREE.ShaderMaterial | |
| extTendonMat: THREE.ShaderMaterial | |
| sheathMat: THREE.ShaderMaterial | |
| tendons: { tube: DynTube; mesh: THREE.Mesh; kind: 'apl' | 'epb' | 'ext' | 'epl'; idx?: number }[] = [] | |
| sheathTube = new DynTube(60, 28) | |
| sheathMesh!: THREE.Mesh | |
| painCore: THREE.Sprite | |
| painHalo: THREE.Sprite | |
| locator: THREE.Sprite | |
| props: Record<string, THREE.Object3D> = {} | |
| anchors: Record<string, THREE.Vector3> = {} | |
| envTex: THREE.Texture | null = null | |
| private boneMats: THREE.ShaderMaterial[] = [] | |
| constructor(renderer: THREE.WebGLRenderer) { | |
| const pm = new THREE.PMREMGenerator(renderer) | |
| this.envTex = pm.fromScene(new RoomEnvironment(), 0.04).texture | |
| this.scene.environment = this.envTex | |
| const dl = new THREE.DirectionalLight('#ffffff', 1.6); dl.position.set(2, 4, 3); this.scene.add(dl) | |
| const fl = new THREE.DirectionalLight('#8fc6ff', 0.6); fl.position.set(-3, 1, 2); this.scene.add(fl) | |
| this.scene.add(new THREE.AmbientLight('#6688aa', 0.35)) | |
| /* --- skin material (custom subsurface-ish + x-ray) --- */ | |
| this.skinMat = new THREE.ShaderMaterial({ | |
| uniforms: { uColor: { value: new THREE.Color('#e2a284') }, uOpacity: { value: 1 }, uXray: { value: 0 }, uPainPos: { value: new THREE.Vector3() }, uPain: { value: 0 }, uTime: { value: 0 } }, | |
| vertexShader: VS, | |
| fragmentShader: /* glsl */ ` | |
| varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv; | |
| uniform vec3 uColor; uniform float uOpacity; uniform float uXray; uniform vec3 uPainPos; uniform float uPain; uniform float uTime; | |
| uniform vec3 uRC; uniform float uRR; | |
| void main(){ | |
| vec3 N = normalize(vN); vec3 V = normalize(-vV); | |
| vec3 L1 = normalize(vec3(0.45,0.75,0.55)); vec3 L2 = normalize(vec3(-0.75,0.15,0.35)); | |
| float ndl = dot(N,L1); | |
| float wrap = clamp((ndl+0.45)/1.45,0.0,1.0); | |
| float term = smoothstep(0.0,0.45,wrap) - smoothstep(0.35,0.9,wrap); | |
| vec3 c = uColor*(0.24 + 0.78*wrap) + uColor*max(dot(N,L2),0.0)*0.22*vec3(0.8,0.9,1.0); | |
| c += vec3(0.55,0.08,0.04)*term*0.35; // subsurface red terminator | |
| float fr = pow(1.0-max(dot(N,V),0.0),2.6); | |
| c += vec3(1.0,0.82,0.72)*fr*0.18; | |
| c += vec3(pow(max(dot(N,normalize(L1+V)),0.0),36.0)*0.16); | |
| // pain glow on skin | |
| float pd = distance(vW,uPainPos); | |
| float pg = uPain*exp(-pd*pd/0.022)*(0.75+0.25*sin(uTime*7.0)); | |
| c = mix(c, vec3(1.0,0.12,0.08), clamp(pg*0.75,0.0,0.8)); c += vec3(0.6,0.05,0.02)*pg*0.6; | |
| // x-ray | |
| vec3 xc = vec3(0.42,0.82,1.0)*(0.1+fr*1.25) + vec3(1.0,0.15,0.1)*pg*0.6; | |
| c = mix(c, xc, uXray); | |
| float a = mix(1.0, 0.05 + fr*0.55 + pg*0.25, uXray) * uOpacity; | |
| gl_FragColor = vec4(c, a); | |
| }`, | |
| transparent: true, depthWrite: false, depthFunc: THREE.LessEqualDepth, | |
| }) | |
| this.skinDepth = new THREE.MeshBasicMaterial({ colorWrite: false, transparent: true }) | |
| /* --- tendon materials --- */ | |
| const tendonFrag = (base: string) => /* glsl */ ` | |
| varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv; | |
| uniform float uSlide; uniform vec3 uHotPos; uniform float uHot; uniform float uDim; uniform float uTime; uniform float uAlpha; | |
| ${LIGHT} | |
| void main(){ | |
| float e = reveal(); | |
| vec3 N = normalize(vN); vec3 V = normalize(-vV); | |
| float x = vUv.x - uSlide; | |
| float fib = 0.9 + 0.1*sin(vUv.y*6.2832*7.0 + sin(vUv.x*40.0)*0.6); | |
| float band = smoothstep(0.35,0.5,abs(fract(x*7.0)-0.5)); | |
| vec3 base = ${base} * fib * (1.0 - band*0.16); | |
| vec3 c = shade(base, N, V, 48.0, 0.45, 0.35); | |
| float d = distance(vW,uHotPos); | |
| float h = uHot*exp(-d*d/0.012); | |
| vec3 hot = mix(vec3(1.0,0.82,0.15), vec3(1.0,0.12,0.06), smoothstep(0.35,0.85,uHot)); | |
| c = mix(c, hot*(0.8+0.25*sin(uTime*9.0)), clamp(h*1.1,0.0,0.92)); | |
| c += hot*h*0.35; | |
| c = mix(c, c*0.3+vec3(0.02,0.05,0.1), uDim); | |
| c += vec3(0.3,0.9,1.0)*e*1.6; | |
| gl_FragColor = vec4(c, uAlpha); | |
| }` | |
| const tUniforms = () => ({ uSlide: { value: 0 }, uHotPos: { value: new THREE.Vector3() }, uHot: { value: 0 }, uDim: { value: 0 }, uTime: { value: 0 }, uAlpha: { value: 1 }, ...revealUniforms() }) | |
| this.tendonMat = new THREE.ShaderMaterial({ uniforms: tUniforms(), vertexShader: VS, fragmentShader: tendonFrag('vec3(0.96,0.93,0.86)') }) | |
| this.extTendonMat = new THREE.ShaderMaterial({ uniforms: tUniforms(), vertexShader: VS, fragmentShader: tendonFrag('vec3(0.9,0.88,0.84)') }) | |
| this.sheathMat = new THREE.ShaderMaterial({ | |
| uniforms: { uThick: { value: 0 }, uAlpha: { value: 1 }, uTime: { value: 0 }, ...revealUniforms() }, | |
| vertexShader: VS, | |
| fragmentShader: /* glsl */ ` | |
| varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv; | |
| uniform float uThick; uniform float uAlpha; uniform float uTime; | |
| ${LIGHT} | |
| void main(){ | |
| float e = reveal(); | |
| vec3 N = normalize(vN); vec3 V = normalize(-vV); | |
| vec3 base = mix(vec3(0.55,0.78,1.0), vec3(1.0,0.36,0.3), uThick); | |
| vec3 c = shade(base, N, V, 30.0, 0.35, 0.4); | |
| float fr = pow(1.0-max(dot(N,V),0.0),2.0); | |
| float ring = 0.5+0.5*sin(vUv.x*95.0); | |
| c += base*fr*0.9 + ring*0.06; | |
| c += vec3(1.0,0.2,0.1)*uThick*(0.25+0.2*sin(uTime*6.0))*fr; | |
| c += vec3(0.3,0.9,1.0)*e*1.6; | |
| gl_FragColor = vec4(c, (0.28 + fr*0.5 + ring*0.05 + uThick*0.15)*uAlpha); | |
| }`, | |
| transparent: true, depthWrite: false, side: THREE.DoubleSide, | |
| }) | |
| this.boneMats = [this.boneMat, this.cartMat] | |
| this.build() | |
| /* --- pain sprites --- */ | |
| const glowTex = (inner: string, outer: string) => { | |
| const c = document.createElement('canvas'); c.width = c.height = 128 | |
| const g = c.getContext('2d')!; const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64) | |
| gr.addColorStop(0, inner); gr.addColorStop(0.35, outer); gr.addColorStop(1, 'rgba(0,0,0,0)') | |
| g.fillStyle = gr; g.fillRect(0, 0, 128, 128) | |
| const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t | |
| } | |
| const mk = (tex: THREE.Texture) => { | |
| const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, blending: THREE.AdditiveBlending, depthTest: false, depthWrite: false, transparent: true })) | |
| s.renderOrder = 40; return s | |
| } | |
| this.painCore = mk(glowTex('rgba(255,235,220,1)', 'rgba(255,40,30,0.85)')) | |
| this.painHalo = mk(glowTex('rgba(255,60,40,0.9)', 'rgba(255,20,20,0.35)')) | |
| this.locator = mk(glowTex('rgba(255,255,255,1)', 'rgba(200,240,255,0.6)')) | |
| this.forearm.add(this.painCore, this.painHalo, this.locator) | |
| this.painCore.position.set(...PAIN_LOCAL); this.painHalo.position.set(...PAIN_LOCAL); this.locator.position.set(...PAIN_LOCAL) | |
| } | |
| /* ---------------- construction ---------------- */ | |
| private skinPart(parent: THREE.Object3D, geo: THREE.BufferGeometry, pos: V3, scale: V3 = [1, 1, 1], rot: V3 = [0, 0, 0]) { | |
| const a = new THREE.Mesh(geo, this.skinDepth); const b = new THREE.Mesh(geo, this.skinMat) | |
| for (const m of [a, b]) { m.position.set(...pos); m.scale.set(...scale); m.rotation.set(...rot); parent.add(m) } | |
| a.renderOrder = 10; b.renderOrder = 11 | |
| return b | |
| } | |
| private bonePart(parent: THREE.Object3D, geo: THREE.BufferGeometry, pos: V3, scale: V3 = [1, 1, 1], rot: V3 = [0, 0, 0], mat = this.boneMat) { | |
| const m = new THREE.Mesh(geo, mat); m.position.set(...pos); m.scale.set(...scale); m.rotation.set(...rot); parent.add(m); return m | |
| } | |
| private boneBetween(parent: THREE.Object3D, a: THREE.Vector3, b: THREE.Vector3, r: number) { | |
| const L = a.distanceTo(b) | |
| const g = new THREE.CapsuleGeometry(r, Math.max(0.001, L - r * 2), 6, 14) | |
| const m = new THREE.Mesh(g, this.boneMat) | |
| m.position.copy(a).add(b).multiplyScalar(0.5) | |
| m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()) | |
| parent.add(m) | |
| // condyles | |
| const s = new THREE.SphereGeometry(r * 1.45, 16, 12) | |
| const h = new THREE.Mesh(s, this.boneMat); h.position.copy(b); h.scale.set(1, 0.8, 0.9); parent.add(h) | |
| const t = new THREE.Mesh(s, this.boneMat); t.position.copy(a); t.scale.set(1.05, 0.75, 0.9); parent.add(t) | |
| return m | |
| } | |
| private build() { | |
| const S = this.scene | |
| S.add(this.armRoot); this.armRoot.add(this.forearm); this.forearm.add(this.wrist) | |
| const sph = new THREE.SphereGeometry(1, 48, 32) | |
| /* forearm skin */ | |
| const fa = new THREE.CylinderGeometry(0.315, 0.42, 3.2, 48, 12, true) | |
| this.skinPart(this.forearm, fa, [0, -1.6, 0], [1.24, 1, 0.74]) | |
| this.skinPart(this.forearm, sph, [0, 0.0, 0], [0.39, 0.24, 0.233]) | |
| /* forearm bones */ | |
| const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) | |
| this.boneBetween(this.forearm, v(-0.13, -3.1, 0), v(-0.16, -0.22, 0.0), 0.055) | |
| this.bonePart(this.forearm, sph, [-0.14, -0.13, 0.0], [0.15, 0.1, 0.105]) | |
| this.bonePart(this.forearm, sph, [-0.255, -0.075, 0.0], [0.055, 0.075, 0.06]) // radial styloid | |
| this.bonePart(this.forearm, sph, [-0.1, -0.15, 0.09], [0.035, 0.05, 0.03]) // Lister tubercle | |
| this.boneBetween(this.forearm, v(0.17, -3.1, -0.02), v(0.19, -0.2, -0.01), 0.048) | |
| this.bonePart(this.forearm, sph, [0.19, -0.12, -0.01], [0.075, 0.075, 0.075]) | |
| this.bonePart(this.forearm, sph, [0.25, -0.07, -0.03], [0.035, 0.05, 0.035]) // ulnar styloid | |
| /* forearm muscles (deep, dim) */ | |
| const mus = new THREE.Mesh(sph, this.muscleMat) | |
| mus.position.set(-0.12, -1.6, 0.13); mus.scale.set(0.11, 0.75, 0.07); this.forearm.add(mus) | |
| const mus2 = mus.clone(); mus2.position.set(0.05, -1.75, 0.15); mus2.scale.set(0.12, 0.8, 0.07); this.forearm.add(mus2) | |
| this.props.muscles = new THREE.Group(); this.props.muscles.add(mus, mus2); this.forearm.add(this.props.muscles) | |
| /* wrist / palm */ | |
| const W = this.wrist | |
| // carpals | |
| const carp: V3[] = [[-0.2, 0.07, 0.0], [-0.07, 0.075, 0.01], [0.06, 0.07, 0.0], [0.17, 0.065, -0.01], [-0.2, 0.2, 0.02], [-0.075, 0.205, 0.02], [0.05, 0.205, 0.02], [0.165, 0.195, 0.01]] | |
| carp.forEach((p, i) => this.bonePart(W, sph, p, [0.065 + (i % 3) * 0.006, 0.062, 0.07])) | |
| // palm skin | |
| this.skinPart(W, sph, [0.0, 0.57, 0.0], [0.455, 0.52, 0.165]) | |
| this.skinPart(W, sph, [0.0, 0.17, -0.005], [0.37, 0.28, 0.19]) | |
| this.skinPart(W, sph, [0.19, 0.5, -0.03], [0.2, 0.42, 0.16]) // hypothenar | |
| // metacarpals + fingers | |
| FINGERS.forEach((f, i) => { | |
| const a = v(...(f.base as unknown as V3)), b = v(...(f.head as unknown as V3)) | |
| this.boneBetween(W, a, b, 0.032) | |
| const mcp = new THREE.Group(); mcp.position.copy(b) | |
| const dir = b.clone().sub(a).normalize() | |
| mcp.userData.base = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir) | |
| W.add(mcp) | |
| const joints = [mcp] | |
| let parent: THREE.Group = mcp | |
| f.len.forEach((L, k) => { | |
| const r = f.r * (1 - k * 0.09) | |
| const seg = new THREE.Group(); parent.add(seg) | |
| const start = k === 0 ? -0.06 : 0 | |
| // skin capsule | |
| this.skinPart(seg, new THREE.CapsuleGeometry(r, L - start, 10, 24), [0, (L + start) / 2, 0], [1, 1, 0.88]) | |
| // bone | |
| this.boneBetween(seg, v(0, 0.035, 0), v(0, L - 0.03, 0), r * 0.36) | |
| if (k === 2) { | |
| // nail | |
| const nail = new THREE.Mesh(sph, new THREE.MeshPhysicalMaterial({ color: '#f2c1b0', roughness: 0.25, clearcoat: 1, transparent: true })) | |
| nail.position.set(0, L * 0.62, r * 0.66); nail.scale.set(r * 0.72, L * 0.36, r * 0.26) | |
| nail.renderOrder = 12; seg.add(nail); nail.userData.nail = true | |
| } | |
| if (k < 2) { const j = new THREE.Group(); j.position.set(0, L, 0); seg.add(j); joints.push(j); parent = j } | |
| }) | |
| this.fingerJoints.push(joints) | |
| }) | |
| /* thumb */ | |
| const cmc = new THREE.Group(); cmc.position.set(-0.235, 0.2, 0.04); W.add(cmc) | |
| const y = new THREE.Vector3(-0.44, 0.84, -0.32).normalize() | |
| let z = new THREE.Vector3(-0.9, 0.0, 0.42); z.sub(y.clone().multiplyScalar(z.dot(y))).normalize() | |
| const x = new THREE.Vector3().crossVectors(y, z) | |
| this.thumbBase.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z)) | |
| const tl = [0.46, 0.33, 0.27], tr = [0.122, 0.104, 0.095] | |
| this.thumbJoints.push(cmc) | |
| let tp: THREE.Group = cmc | |
| tl.forEach((L, k) => { | |
| const seg = new THREE.Group(); tp.add(seg) | |
| if (k === 0) { | |
| this.skinPart(seg, new THREE.CapsuleGeometry(tr[0], L * 0.75, 10, 24), [0.02, L * 0.5, -0.02], [1.15, 1, 0.95]) | |
| this.skinPart(seg, sph, [0.11, 0.22, -0.06], [0.15, 0.27, 0.12]) // thenar | |
| } else { | |
| this.skinPart(seg, new THREE.CapsuleGeometry(tr[k], L, 10, 24), [0, L / 2, 0], [1, 1, 0.9]) | |
| } | |
| this.boneBetween(seg, v(0, 0.04, 0), v(0, L - 0.03, 0), k === 0 ? 0.036 : tr[k] * 0.36) | |
| if (k === 2) { | |
| const nail = new THREE.Mesh(sph, new THREE.MeshPhysicalMaterial({ color: '#f2c1b0', roughness: 0.25, clearcoat: 1, transparent: true })) | |
| nail.position.set(0, L * 0.6, tr[k] * 0.7); nail.scale.set(tr[k] * 0.75, L * 0.38, tr[k] * 0.26); nail.renderOrder = 12; nail.userData.nail = true; seg.add(nail) | |
| } | |
| if (k < 2) { const j = new THREE.Group(); j.position.set(0, L, 0); seg.add(j); this.thumbJoints.push(j); tp = j } | |
| }) | |
| /* tendons */ | |
| const addT = (kind: 'apl' | 'epb' | 'ext' | 'epl', idx?: number) => { | |
| const tube = new DynTube(kind === 'ext' ? 70 : 110, 12) | |
| const mesh = new THREE.Mesh(tube.geo, kind === 'apl' || kind === 'epb' ? this.tendonMat : this.extTendonMat) | |
| mesh.frustumCulled = false | |
| S.add(mesh); this.tendons.push({ tube, mesh, kind, idx }) | |
| } | |
| addT('apl'); addT('epb'); addT('epl'); for (let i = 0; i < 4; i++) addT('ext', i) | |
| this.sheathMesh = new THREE.Mesh(this.sheathTube.geo, this.sheathMat) | |
| this.sheathMesh.renderOrder = 5; this.sheathMesh.frustumCulled = false | |
| S.add(this.sheathMesh) | |
| this.buildProps(sph) | |
| } | |
| /* ---------------- props ---------------- */ | |
| private buildProps(sph: THREE.SphereGeometry) { | |
| const std = (color: string, o: Partial<THREE.MeshPhysicalMaterialParameters> = {}) => new THREE.MeshPhysicalMaterial({ color, roughness: 0.45, metalness: 0, ...o }) | |
| const add = (name: string, obj: THREE.Object3D, parent: THREE.Object3D) => { obj.visible = false; parent.add(obj); this.props[name] = obj; return obj } | |
| // ball (soft grip ball) – palm | |
| const ball = new THREE.Mesh(sph, std('#3fb6a8', { roughness: 0.6, sheen: 1, sheenColor: new THREE.Color('#9ff') })) | |
| ball.scale.setScalar(0.33); ball.position.set(-0.02, 0.7, -0.5) | |
| add('ball', ball, this.wrist) | |
| // coin / bead for pinch | |
| const bead = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.025, 32), std('#d9b44a', { metalness: 0.9, roughness: 0.25 })) | |
| add('bead', bead, this.wrist) | |
| // bottle + cap (cap in hand space) | |
| const bottle = new THREE.Group() | |
| const body = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.36, 1.6, 48), std('#7fc8ff', { transmission: 0.6, roughness: 0.15, thickness: 0.3, transparent: true, opacity: 0.85 })) | |
| body.position.y = -0.95 | |
| const label = new THREE.Mesh(new THREE.CylinderGeometry(0.352, 0.362, 0.5, 48, 1, true), std('#1f6fd1')) | |
| label.position.y = -1.0 | |
| const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.17, 0.33, 0.25, 40), body.material); neck.position.y = -0.05 | |
| const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.18, 40), std('#f4f4f4', { roughness: 0.35 })) | |
| cap.position.y = 0.12 | |
| const ribs = new THREE.Group(); for (let i = 0; i < 24; i++) { const r = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.16, 0.02), std('#ddd')); const a = i / 24 * Math.PI * 2; r.position.set(Math.cos(a) * 0.205, 0.12, Math.sin(a) * 0.205); r.rotation.y = -a; ribs.add(r) } | |
| bottle.add(body, label, neck, cap, ribs) | |
| const bottleWrap = new THREE.Group(); bottleWrap.add(bottle) | |
| bottleWrap.position.set(-0.02, 0.6, -0.3); bottleWrap.rotation.x = Math.PI / 2 // axis along -Z (palmar) | |
| bottle.rotation.y = 0 | |
| add('bottle', bottleWrap, this.wrist); bottleWrap.userData.inner = bottle | |
| // keyboard & mouse (hand space; palm-down usage) | |
| const kb = new THREE.Group() | |
| const deck = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.06, 1.0), std('#2a3240', { roughness: 0.5 })) | |
| kb.add(deck) | |
| for (let r = 0; r < 4; r++) for (let c = 0; c < 13; c++) { | |
| const k = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.05, 0.16), std('#d9dee6', { roughness: 0.6 })) | |
| k.position.set(-1.15 + c * 0.19 + (r % 2) * 0.05, 0.05, -0.33 + r * 0.21); kb.add(k) | |
| } | |
| const mouse = new THREE.Mesh(sph, std('#e8ecf2', { roughness: 0.3, clearcoat: 0.6 })) | |
| mouse.scale.set(0.28, 0.13, 0.42); mouse.position.set(1.75, 0.08, 0.15) | |
| const desk = new THREE.Mesh(new THREE.BoxGeometry(6, 0.05, 3), std('#5b4a3c', { roughness: 0.8 })); desk.position.y = -0.06 | |
| kb.add(mouse, desk) | |
| add('keyboard', kb, this.armRoot) | |
| // hammer (tool) – handle across palm (hand-space X axis) | |
| const ham = new THREE.Group() | |
| const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.13, 1.7, 32), std('#d0763a', { roughness: 0.5 })) | |
| handle.rotation.z = Math.PI / 2 | |
| const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.135, 0.14, 0.75, 32), std('#22262c', { roughness: 0.9 })) | |
| grip.rotation.z = Math.PI / 2; grip.position.x = 0.05 | |
| const head = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.9, 0.28), std('#9aa4b0', { metalness: 0.9, roughness: 0.3 })) | |
| head.position.x = -0.9 | |
| ham.add(handle, grip, head) | |
| ham.position.set(0.02, 0.85, -0.37) | |
| add('hammer', ham, this.wrist) | |
| // towel (wringing) – twisted cloth across grip | |
| const towelGeo = new THREE.CylinderGeometry(0.16, 0.16, 3.2, 24, 60, true) | |
| const pos = towelGeo.attributes.position as THREE.BufferAttribute | |
| for (let i = 0; i < pos.count; i++) { | |
| const px = pos.getX(i), py = pos.getY(i), pz = pos.getZ(i) | |
| const a = py * 4.0, bulge = 1 + 0.25 * Math.sin(Math.atan2(pz, px) * 3 + a) | |
| pos.setXYZ(i, (px * Math.cos(a) - pz * Math.sin(a)) * bulge, py, (px * Math.sin(a) + pz * Math.cos(a)) * bulge * 0.8) | |
| } | |
| towelGeo.computeVertexNormals() | |
| const towel = new THREE.Mesh(towelGeo, std('#f0c6d6', { roughness: 0.95, sheen: 1, sheenColor: new THREE.Color('#fff'), side: THREE.DoubleSide })) | |
| towel.rotation.z = Math.PI / 2; towel.position.set(0.3, 0.85, -0.37) | |
| add('towel', towel, this.wrist) | |
| // baby (swaddled bundle) – supported on palm with thumb abducted | |
| const baby = new THREE.Group() | |
| const wrap = new THREE.Mesh(new THREE.CapsuleGeometry(0.62, 1.3, 12, 32), std('#bfe0ff', { roughness: 0.9, sheen: 1, sheenColor: new THREE.Color('#fff') })) | |
| wrap.rotation.z = Math.PI / 2.2 | |
| const bhead = new THREE.Mesh(sph, std('#f1c4a6', { roughness: 0.6, sheen: 0.5 })); bhead.scale.setScalar(0.5); bhead.position.set(-1.25, 0.55, 0.05) | |
| const hat = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), std('#ffd27f', { roughness: 0.9 })); hat.scale.setScalar(0.53); hat.position.set(-1.3, 0.62, 0.05); hat.rotation.z = 0.7 | |
| const fold = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.08, 12, 40), std('#9cc9f5', { roughness: 0.9 })); fold.position.set(-0.75, 0.32, 0.0); fold.rotation.y = Math.PI / 2; fold.rotation.x = 0.4 | |
| baby.add(wrap, bhead, hat, fold) | |
| baby.position.set(0.5, 0.75, -0.95); baby.rotation.set(0.1, 0.3, 0.2) | |
| add('baby', baby, this.wrist) | |
| // cup | |
| const cup = new THREE.Group() | |
| const cb = new THREE.Mesh(new THREE.CylinderGeometry(0.33, 0.28, 0.85, 40, 1, false), std('#ffffff', { roughness: 0.2, clearcoat: 1 })) | |
| const coffee = new THREE.Mesh(new THREE.CircleGeometry(0.31, 40), std('#6b3f22')); coffee.rotation.x = -Math.PI / 2; coffee.position.y = 0.36 | |
| const hd = new THREE.Mesh(new THREE.TorusGeometry(0.17, 0.045, 12, 30, Math.PI * 1.2), std('#ffffff', { clearcoat: 1 })); hd.position.set(0.38, 0, 0); hd.rotation.z = -Math.PI * 0.6 | |
| const band = new THREE.Mesh(new THREE.CylinderGeometry(0.335, 0.32, 0.12, 40, 1, true), std('#2bb3a3')); band.position.y = 0.1 | |
| cup.add(cb, coffee, hd, band) | |
| cup.position.set(0.0, 0.74, -0.44); cup.rotation.set(0, 0, Math.PI / 2); cup.scale.setScalar(0.8) | |
| add('cup', cup, this.wrist) | |
| // phone | |
| const phone = new THREE.Group() | |
| const pb = new THREE.Mesh(new THREE.BoxGeometry(0.72, 1.48, 0.07), std('#1b1f27', { metalness: 0.6, roughness: 0.3 })) | |
| const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.66, 1.4), new THREE.MeshBasicMaterial({ color: '#3a8bff' })) | |
| scr.position.z = 0.037 | |
| const sc2 = new THREE.Mesh(new THREE.PlaneGeometry(0.56, 0.3), new THREE.MeshBasicMaterial({ color: '#bfe3ff' })); sc2.position.set(0, 0.35, 0.038) | |
| const sc3 = new THREE.Mesh(new THREE.PlaneGeometry(0.56, 0.5), new THREE.MeshBasicMaterial({ color: '#7fb8ff' })); sc3.position.set(0, -0.15, 0.038) | |
| phone.add(pb, scr, sc2, sc3) | |
| phone.position.set(0.05, 0.85, -0.42); phone.rotation.set(-0.35, 0.0, 0.15) | |
| add('phone', phone, this.wrist) | |
| // thumb spica splint | |
| const spl = new THREE.Group() | |
| const splMat = std('#5aa9e6', { roughness: 0.7, transparent: true, opacity: 0.88, sheen: 1, sheenColor: new THREE.Color('#cde') }) | |
| const sleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.43, 0.47, 1.25, 40, 1, true), splMat); sleeve.position.set(0, -0.55, 0); sleeve.scale.set(1.1, 1, 0.72) | |
| const strap1 = new THREE.Mesh(new THREE.CylinderGeometry(0.445, 0.46, 0.12, 40, 1, true), std('#e9eef5')); strap1.position.set(0, -0.85, 0); strap1.scale.set(1.12, 1, 0.74) | |
| const strap2 = strap1.clone(); strap2.position.y = -0.35 | |
| spl.add(sleeve, strap1, strap2) | |
| add('splint', spl, this.forearm) | |
| const splThumb = new THREE.Mesh(new THREE.CylinderGeometry(0.145, 0.175, 0.62, 32, 1, true), splMat); splThumb.position.set(0.02, 0.28, -0.01) | |
| add('splintThumb', splThumb, this.thumbJoints[0]) | |
| const splPalm = new THREE.Mesh(sph, splMat); splPalm.scale.set(0.48, 0.3, 0.2); splPalm.position.set(0, 0.25, 0) | |
| add('splintPalm', splPalm, this.wrist) | |
| // needle-knife (original design): handle + guard + shaft + flat micro blade. Tip at origin, body along +Y | |
| const knife = new THREE.Group() | |
| const steel = std('#d8dee6', { metalness: 1, roughness: 0.18 }) | |
| const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 0.62, 12), steel); shaft.position.y = 0.33 | |
| const blade = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.04, 0.004), steel); blade.position.y = 0.02 | |
| const neck2 = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.008, 0.06, 16), steel); neck2.position.y = 0.66 | |
| const hand = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.045, 0.5, 6), std('#e8f3f7', { roughness: 0.35 })); hand.position.y = 0.94 | |
| const grip2 = new THREE.Group(); for (let i = 0; i < 6; i++) { const r = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.007, 8, 24), std('#21b3a5')); r.rotation.x = Math.PI / 2; r.position.y = 0.8 + i * 0.045; grip2.add(r) } | |
| const capk = new THREE.Mesh(new THREE.SphereGeometry(0.05, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2), std('#21b3a5')); capk.position.y = 1.19 | |
| const fin = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.08, 0.012), std('#21b3a5')); fin.position.y = 0.72 // orientation marker parallel to blade | |
| knife.add(shaft, blade, neck2, hand, grip2, capk, fin) | |
| add('knife', knife, this.forearm) | |
| // syringe (generic, unbranded) | |
| const syr = new THREE.Group() | |
| const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.55, 24, 1, true), std('#ffffff', { transmission: 0.9, roughness: 0.05, transparent: true, opacity: 0.5, side: THREE.DoubleSide })) | |
| barrel.position.y = 0.6 | |
| const liquid = new THREE.Mesh(new THREE.CylinderGeometry(0.052, 0.052, 0.3, 24), std('#8fd8ff', { transparent: true, opacity: 0.6 })); liquid.position.y = 0.48 | |
| const needle = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, 0.32, 8), steel); needle.position.y = 0.16 | |
| const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.035, 0.05, 16), std('#2bb3a3')); hub.position.y = 0.33 | |
| const plunger = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.5, 8), std('#eeeeee')); plunger.position.y = 0.95 | |
| const pcap = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.02, 24), std('#eeeeee')); pcap.position.y = 1.2 | |
| syr.add(barrel, liquid, needle, hub, plunger, pcap) | |
| add('syringe', syr, this.forearm) | |
| // swab | |
| const swab = new THREE.Group() | |
| const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.8, 8), std('#f3f3f3')); stick.position.y = 0.45 | |
| const cot = new THREE.Mesh(sph, std('#c47a2c', { roughness: 1 })); cot.scale.set(0.04, 0.07, 0.04); cot.position.y = 0.04 | |
| swab.add(stick, cot) | |
| add('swab', swab, this.forearm) | |
| // iodine disc (decal on skin) | |
| const iod = new THREE.Mesh(new THREE.CircleGeometry(1, 48), new THREE.MeshBasicMaterial({ color: '#c7731f', transparent: true, opacity: 0.55, depthWrite: false })) | |
| iod.renderOrder = 13 | |
| add('iodine', iod, this.forearm) | |
| // anesthesia cloud | |
| const cloud = new THREE.Mesh(sph, new THREE.MeshBasicMaterial({ color: '#59c8ff', transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending })) | |
| cloud.renderOrder = 14 | |
| add('cloud', cloud, this.forearm) | |
| // release sparks ring | |
| const ring = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.006, 8, 48), new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, blending: THREE.AdditiveBlending, depthTest: false })) | |
| ring.renderOrder = 41 | |
| add('ring', ring, this.forearm) | |
| // probe (ultrasound transducer, original) | |
| const probe = new THREE.Group() | |
| const ph = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.12, 0.16), std('#eef3f8', { roughness: 0.3 })); ph.position.y = 0.06 | |
| const pg = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.07, 0.55, 20), std('#eef3f8', { roughness: 0.3 })); pg.position.y = 0.38 | |
| const lens = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.02, 0.13), std('#2bb3a3')); lens.position.y = 0.0 | |
| const cable = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.6, 8), std('#3b4250')); cable.position.y = 0.9 | |
| probe.add(ph, pg, lens, cable) | |
| add('probe', probe, this.forearm) | |
| } | |
| /* ---------------- per-frame ---------------- */ | |
| private v = new THREE.Vector3() | |
| private pt(obj: THREE.Object3D, x: number, y: number, z: number) { return obj.localToWorld(new THREE.Vector3(x, y, z)) } | |
| apply(s: SceneState) { | |
| const t = s.time | |
| const p = s.pose | |
| /* arm placement */ | |
| this.armRoot.position.set(...s.arm.pos) | |
| this.armRoot.rotation.set(s.arm.rot[0], s.arm.rot[1], s.arm.rot[2], 'YXZ') | |
| this.armRoot.quaternion.setFromEuler(this.armRoot.rotation) | |
| if (s.shake) this.armRoot.position.add(new THREE.Vector3(...s.shake)) | |
| this.forearm.rotation.set(0, p[2], 0) | |
| this.wrist.rotation.set(-p[0], 0, -p[1], 'XZY') | |
| if (s.arm.twist) { | |
| this.armRoot.updateMatrixWorld(true) | |
| const piv = this.wrist.localToWorld(new THREE.Vector3(...s.arm.twist.pivot)) | |
| const ax = new THREE.Vector3(...s.arm.twist.axis).transformDirection(this.wrist.matrixWorld) | |
| const q = new THREE.Quaternion().setFromAxisAngle(ax, s.arm.twist.angle) | |
| this.armRoot.position.sub(piv).applyQuaternion(q).add(piv) | |
| this.armRoot.quaternion.premultiply(q) | |
| } | |
| /* fingers */ | |
| this.fingerJoints.forEach((js, i) => { | |
| const f = FINGERS[i] | |
| const base = js[0].userData.base as THREE.Quaternion | |
| const spread = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -f.splay * (1 + p[3] * 4) + f.splay) | |
| const flex = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -p[4 + i * 3]) | |
| js[0].quaternion.copy(base).multiply(spread).multiply(flex) | |
| js[1].rotation.set(-p[5 + i * 3], 0, 0) | |
| js[2].rotation.set(-p[6 + i * 3], 0, 0) | |
| }) | |
| /* thumb */ | |
| const [ab, fl, op, mcp, ip] = [p[16], p[17], p[18], p[19], p[20]] | |
| const tq = this.thumbBase.clone() | |
| .multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(-fl * 0.8, op, ab, 'ZXY'))) | |
| this.thumbJoints[0].quaternion.copy(tq) | |
| this.thumbJoints[1].rotation.set(-mcp, 0, 0) | |
| this.thumbJoints[2].rotation.set(-ip, 0, 0) | |
| this.scene.updateMatrixWorld(true) | |
| /* tendon paths */ | |
| const F = this.forearm, Wr = this.wrist, T = this.thumbJoints | |
| const excursion = fl * 0.12 + mcp * 0.05 + ip * 0.03 + p[1] * 0.16 - ab * 0.05 + (s.slide ?? 0) | |
| const sheathPts = [this.pt(F, -0.26, -0.62, 0.1), this.pt(F, -0.29, -0.42, 0.075), this.pt(F, -0.3, -0.2, 0.06), this.pt(F, -0.303, 0.0, 0.055), this.pt(F, -0.295, 0.14, 0.055)] | |
| for (const td of this.tendons) { | |
| let pts: THREE.Vector3[] | |
| let rad = (u: number) => 0.02 | |
| if (td.kind === 'apl' || td.kind === 'epb') { | |
| const o = td.kind === 'apl' ? -0.022 : 0.022 | |
| pts = [ | |
| this.pt(F, -0.08 + o, -1.75, 0.2), this.pt(F, -0.2 + o, -1.05, 0.17), this.pt(F, -0.26 + o * 0.6, -0.62, 0.1 + o * 0.6), | |
| this.pt(F, -0.29 + o * 0.5, -0.42, 0.075 + o * 0.5), this.pt(F, -0.3 + o * 0.5, -0.2, 0.06 + o * 0.6), this.pt(F, -0.303 + o * 0.5, 0.0, 0.055 + o * 0.6), | |
| this.pt(Wr, -0.29 + o * 0.4, 0.13, 0.06 + o * 0.5), | |
| this.pt(T[0], 0.0 + o * 0.5, -0.02, 0.07), | |
| ] | |
| if (td.kind === 'apl') pts.push(this.pt(T[0], -0.005, 0.07, 0.075)) | |
| else pts.push(this.pt(T[0], 0.01, 0.2, 0.09), this.pt(T[0], 0.01, 0.42, 0.085), this.pt(T[1], 0.0, 0.06, 0.08)) | |
| const r0 = 0.021 | |
| rad = (u: number) => r0 * (u < 0.15 ? 1.6 - u * 4 : 1) * (u > 0.94 ? (1 - (u - 0.94) * 8) : 1) | |
| } else if (td.kind === 'epl') { | |
| pts = [this.pt(F, 0.0, -1.7, 0.2), this.pt(F, -0.06, -0.8, 0.2), this.pt(F, -0.08, -0.2, 0.17), this.pt(F, -0.13, -0.05, 0.16), | |
| this.pt(Wr, -0.22, 0.12, 0.14), this.pt(T[0], -0.045, 0.1, 0.1), this.pt(T[0], -0.04, 0.4, 0.1), this.pt(T[1], -0.02, 0.15, 0.09), this.pt(T[2], 0, 0.05, 0.08)] | |
| rad = (u) => 0.016 * (u > 0.95 ? 0.6 : 1) | |
| } else { | |
| const i = td.idx!, f = FINGERS[i], J = this.fingerJoints[i] | |
| const hx = f.head[0], bx = f.base[0] | |
| pts = [this.pt(F, -0.02 + i * 0.07, -1.7, 0.21), this.pt(F, -0.05 + i * 0.075, -0.6, 0.2), this.pt(F, -0.07 + i * 0.09, -0.1, 0.18), | |
| this.pt(Wr, bx * 0.9, 0.2, 0.15), this.pt(Wr, (bx + hx) / 2, 0.6, 0.12), this.pt(J[0], 0, 0.0, f.r * 0.85), | |
| this.pt(J[0], 0, f.len[0] * 0.55, f.r * 0.7), this.pt(J[1], 0, 0, f.r * 0.72), this.pt(J[1], 0, f.len[1] * 0.7, f.r * 0.6), this.pt(J[2], 0, 0.04, f.r * 0.55)] | |
| rad = (u) => 0.016 * (u > 0.9 ? 0.75 : 1) | |
| } | |
| td.tube.update(pts, rad) | |
| ;(td.mesh.material as THREE.ShaderMaterial).uniforms.uSlide.value = td.kind === 'ext' ? 0 : excursion * (td.kind === 'epb' ? 1.15 : 1) | |
| } | |
| // sheath | |
| const th = s.thick | |
| this.sheathTube.update(sheathPts, (u) => { | |
| const bell = Math.exp(-Math.pow((u - 0.5) / 0.2, 2)) | |
| const ends = Math.min(1, u * 9, (1 - u) * 9) | |
| return (0.05 + 0.012 * ends + th * 0.026 * bell) * (0.75 + 0.25 * ends) | |
| }) | |
| /* materials */ | |
| const painW = this.pt(F, ...PAIN_LOCAL) | |
| this.anchors.pain = painW | |
| this.anchors.sheath = this.pt(F, -0.33, -0.3, 0.1) | |
| this.anchors.tendon = this.pt(F, -0.22, -0.95, 0.17) | |
| this.anchors.bone = this.pt(F, -0.14, -1.2, 0) | |
| this.anchors.thumb = this.pt(T[2], 0, 0.2, 0) | |
| this.anchors.thumbBase = this.pt(T[0], 0, 0.1, 0) | |
| this.anchors.wrist = this.pt(Wr, 0, 0.1, 0) | |
| this.anchors.palm = this.pt(Wr, 0, 0.6, 0) | |
| this.anchors.narrow = this.pt(F, -0.31, -0.17, 0.08) | |
| const su = this.skinMat.uniforms | |
| su.uOpacity.value = s.skin; su.uXray.value = s.xray; su.uPainPos.value.copy(painW); su.uPain.value = s.pain * (1 - s.xray * 0.6); su.uTime.value = t | |
| const skinVisible = s.skin > 0.003 | |
| this.scene.traverse((o) => { | |
| const m = o as THREE.Mesh | |
| if (m.material === this.skinMat || m.material === this.skinDepth) m.visible = skinVisible | |
| if (o.userData.nail) { const mm = (o as THREE.Mesh).material as THREE.MeshPhysicalMaterial; mm.opacity = s.skin * (1 - s.xray * 0.85); o.visible = skinVisible } | |
| }) | |
| const setReveal = (mat: THREE.ShaderMaterial, r: number, maxR: number) => { | |
| mat.uniforms.uRC.value.copy(painW) | |
| mat.uniforms.uRR.value = r >= 0.999 ? 100 : r * maxR | |
| } | |
| for (const m of this.boneMats) setReveal(m, s.bone, 3.4) | |
| setReveal(this.muscleMat, s.bone, 3.4) | |
| setReveal(this.tendonMat, s.tendon, 3.4) | |
| setReveal(this.extTendonMat, s.tendon, 3.4) | |
| setReveal(this.sheathMat, s.sheath, 1.2) | |
| const showIn = s.xray > 0.01 || s.skin < 0.99 | |
| this.boneMats.forEach((m) => { m.visible = showIn && s.bone > 0.001 }) | |
| this.muscleMat.visible = showIn && s.bone > 0.001 | |
| this.tendonMat.visible = this.extTendonMat.visible = showIn && s.tendon > 0.001 | |
| this.sheathMat.visible = showIn && s.sheath > 0.001 | |
| const focus = s.focus ?? 0 | |
| this.boneMat.uniforms.uDim.value = focus * 0.75; this.cartMat.uniforms.uDim.value = focus * 0.75 | |
| this.muscleMat.uniforms.uDim.value = 0.55 + focus * 0.3 | |
| this.extTendonMat.uniforms.uDim.value = focus * 0.8 | |
| this.tendonMat.uniforms.uDim.value = 0 | |
| for (const m of [this.tendonMat, this.extTendonMat]) { m.uniforms.uTime.value = t } | |
| const tu = this.tendonMat.uniforms | |
| tu.uHot.value = s.friction; tu.uHotPos.value.copy(this.pt(F, -0.303, -0.17, 0.06)) | |
| this.sheathMat.uniforms.uThick.value = th; this.sheathMat.uniforms.uTime.value = t | |
| /* pain sprites */ | |
| const pulse = 0.78 + 0.22 * Math.sin(t * 7.0) | |
| const pv = s.pain | |
| this.painCore.visible = this.painHalo.visible = pv > 0.01 | |
| ;(this.painCore.material as THREE.SpriteMaterial).opacity = pv * pulse | |
| ;(this.painHalo.material as THREE.SpriteMaterial).opacity = pv * 0.55 * (0.8 + 0.2 * Math.sin(t * 7 + 1)) | |
| this.painCore.scale.setScalar(0.22 + 0.06 * pv * pulse) | |
| this.painHalo.scale.setScalar(0.55 + 0.25 * pv * pulse) | |
| /* props */ | |
| for (const [name, obj] of Object.entries(this.props)) { | |
| if (name === 'muscles') continue | |
| const ps = s.props[name] | |
| obj.visible = !!ps && ps.vis > 0.01 | |
| if (!ps || !obj.visible) continue | |
| obj.traverse((o) => { | |
| const mm = (o as THREE.Mesh).material as THREE.Material & { opacity: number; userData: { baseOp?: number } } | |
| if (!mm || !('opacity' in mm)) return | |
| if (mm.userData.baseOp === undefined) mm.userData.baseOp = mm.opacity | |
| mm.transparent = true | |
| mm.opacity = mm.userData.baseOp * ps.vis | |
| }) | |
| this.applyProp(name, obj, ps, t) | |
| } | |
| this.scene.updateMatrixWorld(true) | |
| const kn = this.props.knife | |
| this.anchors.knife = kn.localToWorld(new THREE.Vector3(0, 0.95, 0)) | |
| this.anchors.knifeTip = kn.localToWorld(new THREE.Vector3(0, 0.0, 0)) | |
| this.anchors.syringe = this.props.syringe.localToWorld(new THREE.Vector3(0, 0.6, 0)) | |
| this.anchors.probe = this.props.probe.localToWorld(new THREE.Vector3(0, 0.3, 0)) | |
| this.anchors.splint = this.props.splint.localToWorld(new THREE.Vector3(0, -0.5, 0.3)) | |
| const loc = s.props.locator | |
| this.locator.visible = !!loc && loc.vis > 0.01 | |
| if (loc) { (this.locator.material as THREE.SpriteMaterial).opacity = loc.vis; this.locator.scale.setScalar(0.12 + 0.03 * Math.sin(t * 8)) } | |
| } | |
| private applyProp(name: string, obj: THREE.Object3D, ps: PropState, t: number) { | |
| const P = new THREE.Vector3(...PAIN_LOCAL) | |
| const out = new THREE.Vector3(-0.72, 0.22, 0.66).normalize() | |
| if (name === 'knife') { | |
| const d = ps.depth ?? 0.8 // distance of tip from target along out dir | |
| const wig = (ps.release ?? 0) * Math.sin(t * 22) * 0.025 | |
| const along = new THREE.Vector3(0, 1, 0) | |
| obj.position.copy(P).addScaledVector(out, d).addScaledVector(along, wig).add(new THREE.Vector3(-0.005, 0.0, 0.02)) | |
| obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), out) | |
| obj.rotateY(Math.PI / 2) | |
| } else if (name === 'syringe') { | |
| const d = ps.depth ?? 0.8 | |
| const dir = new THREE.Vector3(-0.6, 0.35, 0.72).normalize() | |
| obj.position.copy(P).addScaledVector(dir, d + 0.06) | |
| obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir) | |
| const pl = obj.children[4], pc = obj.children[5], lq = obj.children[1] | |
| const push = ps.push ?? 0 | |
| pl.position.y = 0.95 - push * 0.22; pc.position.y = 1.2 - push * 0.22 | |
| lq.scale.y = 1 - push * 0.8; lq.position.y = 0.48 - push * 0.12 | |
| } else if (name === 'swab') { | |
| const a = t * 5 | |
| const r = (ps.r ?? 0.1) | |
| const n = out | |
| const tx = new THREE.Vector3(0, 1, 0).cross(n).normalize(), ty = n.clone().cross(tx) | |
| obj.position.copy(P).addScaledVector(n, 0.11).addScaledVector(tx, Math.cos(a) * r).addScaledVector(ty, Math.sin(a) * r) | |
| obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), n.clone().add(new THREE.Vector3(0, 0.3, 0)).normalize()) | |
| } else if (name === 'iodine') { | |
| obj.position.copy(P).addScaledVector(out, 0.105) | |
| obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), out) | |
| obj.scale.setScalar(Math.max(0.001, ps.r ?? 0.2)) | |
| } else if (name === 'cloud') { | |
| obj.position.copy(P).addScaledVector(out, 0.04) | |
| obj.scale.setScalar(Math.max(0.001, ps.r ?? 0.1)) | |
| } else if (name === 'ring') { | |
| obj.position.copy(P) | |
| obj.rotation.set(Math.PI / 2, 0, 0) | |
| obj.scale.setScalar(1 + (ps.r ?? 0) * 0.8) | |
| } else if (name === 'probe') { | |
| obj.position.copy(P).addScaledVector(out, 0.12 + (ps.lift ?? 0)).add(new THREE.Vector3(0, ps.slideY ?? 0, 0)) | |
| obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), out) | |
| } else if (name === 'bottle') { | |
| const inner = obj.userData.inner as THREE.Object3D | |
| inner.rotation.y = -(ps.twist ?? 0) | |
| } else if (name === 'bead') { | |
| // between thumb tip & index tip | |
| const a = this.thumbJoints[2].children[0]?.parent ? this.pt(this.thumbJoints[2], 0, 0.3, 0) : new THREE.Vector3() | |
| const b = this.pt(this.fingerJoints[0][2], 0, 0.22, 0) | |
| const mid = a.add(b).multiplyScalar(0.5) | |
| obj.position.copy(this.wrist.worldToLocal(mid)) | |
| obj.rotation.set(0.3, 0, 1.2) | |
| } else if (name === 'keyboard') { | |
| obj.position.set(ps.x ?? 0, ps.y ?? 0, ps.z ?? 0) | |
| obj.rotation.set(ps.rx ?? 0, ps.ry ?? 0, ps.rz ?? 0) | |
| } | |
| } | |
| /** project world point → normalized screen (0..1) */ | |
| project(p: THREE.Vector3, cam: THREE.Camera) { | |
| const v = p.clone().project(cam) | |
| return { x: (v.x + 1) / 2, y: (1 - v.y) / 2, z: v.z } | |
| } | |
| } | |