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 /* ------------------------------------------------------------------ */ /* 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 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 = {} anchors: Record = {} 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 = {}) => 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 } } }