Ethscriptions commited on
Commit
213d393
·
verified ·
1 Parent(s): f8c63df

Step 3-4: 3D 解剖模型与分镜时间轴

Browse files
README.md CHANGED
@@ -58,8 +58,8 @@ pinned: false
58
 
59
  1. ✅ 编写本计划 README,初始化 Space(static)。
60
  2. ✅ 生成中文旁白逐句音频 + 时间轴清单(`timeline.json`)。
61
- 3. ⏳ 程序化 3D 手部解剖模型:皮肤 / 骨骼 / 肌腱 / 腱鞘 / 关节骨架绑定 / 疼痛发光 / 器械与道具。
62
- 4. ⏳ 分镜时间轴:11 镜头 + 结尾的相机运动、姿态、图层、字幕、HUD。
63
  5. ⏳ 程序化音乐与音效、旁白混音、ducking。
64
  6. ⏳ 播放器 UI(手机竖屏适配、全屏、进度条)+ MP4 本地导出(24/30/60fps)。
65
  7. ⏳ 四平台文案(标题 / 话题 / 正文,严格字数校验)+ 封面生成与下载。
@@ -78,3 +78,4 @@ README.md ← 本文件(计划 + 进度)
78
 
79
  - [x] Step 1:计划与大纲完成
80
  - [x] Step 2:旁白 46 句逐句生成(zh-CN 神经网络语音,语速 +38%),自动裁剪静音并对齐时间轴,全片总长 **88.2 秒**(12 段)
 
 
58
 
59
  1. ✅ 编写本计划 README,初始化 Space(static)。
60
  2. ✅ 生成中文旁白逐句音频 + 时间轴清单(`timeline.json`)。
61
+ 3. ✅ 程序化 3D 手部解剖模型:皮肤 / 骨骼 / 肌腱 / 腱鞘 / 关节骨架绑定 / 疼痛发光 / 器械与道具。
62
+ 4. ✅ 分镜时间轴:11 镜头 + 结尾的相机运动、姿态、图层、字幕、HUD。
63
  5. ⏳ 程序化音乐与音效、旁白混音、ducking。
64
  6. ⏳ 播放器 UI(手机竖屏适配、全屏、进度条)+ MP4 本地导出(24/30/60fps)。
65
  7. ⏳ 四平台文案(标题 / 话题 / 正文,严格字数校验)+ 封面生成与下载。
 
78
 
79
  - [x] Step 1:计划与大纲完成
80
  - [x] Step 2:旁白 46 句逐句生成(zh-CN 神经网络语音,语速 +38%),自动裁剪静音并对齐时间轴,全片总长 **88.2 秒**(12 段)
81
+ - [x] Step 3-4:程序化 3D 手部解剖模型(皮肤透视 / 骨骼 / 肌腱 / 腱鞘、疼痛红光、原创器械与生活道具)+ 12 段分镜时间轴(镜头运动、分屏、字幕、HUD、超声示意、截面图),全时间轴逐帧验证无运行错误
scripts/gen_tts.py CHANGED
@@ -60,3 +60,8 @@ async def main():
60
  print("TOTAL", round(t, 2))
61
 
62
  asyncio.run(main())
 
 
 
 
 
 
60
  print("TOTAL", round(t, 2))
61
 
62
  asyncio.run(main())
63
+
64
+ # also emit TS module for the frontend bundle
65
+ ts = os.path.join(os.path.dirname(OUT), "..", "src", "video", "timelineData.ts")
66
+ with open(ts, "w", encoding="utf-8") as f:
67
+ f.write("// generated from public/audio/timeline.json by scripts/gen_tts.py\nexport const TL = " + json.dumps(data, ensure_ascii=False, indent=1) + " as const\n")
scripts/shot.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Screenshot helper: python3 scripts/shot.py out.png '<query string>' [w h]"""
2
+ import sys, asyncio
3
+ from playwright.async_api import async_playwright
4
+
5
+ async def main():
6
+ out, qs = sys.argv[1], sys.argv[2]
7
+ w = int(sys.argv[3]) if len(sys.argv) > 3 else 540
8
+ h = int(sys.argv[4]) if len(sys.argv) > 4 else 960
9
+ async with async_playwright() as p:
10
+ b = await p.chromium.launch(args=["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--autoplay-policy=no-user-gesture-required"])
11
+ pg = await b.new_page(viewport={"width": w, "height": h})
12
+ logs = []
13
+ pg.on("console", lambda m: logs.append(m.type + ": " + m.text))
14
+ pg.on("pageerror", lambda e: logs.append("PAGEERROR: " + str(e)))
15
+ await pg.goto("http://localhost:5173/preview/production/ed5ba841-ed53-4eec-a8c5-93237d26b7ab/?" + qs)
16
+ try:
17
+ await pg.wait_for_function("window.__done === true", timeout=90000)
18
+ except Exception as e:
19
+ logs.append("TIMEOUT " + str(e)[:100])
20
+ await pg.screenshot(path=out)
21
+ for l in logs:
22
+ if "error" in l.lower() or "TIMEOUT" in l or "INFO" in l: print(l[:900])
23
+ await b.close()
24
+
25
+ asyncio.run(main())
scripts/stat.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from PIL import Image; import numpy as np
3
+ for f in sys.argv[1:]:
4
+ a=np.array(Image.open(f).convert('RGB')).astype(int)
5
+ skin=(a[:,:,0]>150)&(a[:,:,0]>a[:,:,2]+40)
6
+ bone=(a[:,:,0]>190)&(a[:,:,1]>180)&(a[:,:,2]>150)
7
+ red=(a[:,:,0]>200)&(a[:,:,1]<90)
8
+ def bb(m):
9
+ ys,xs=np.where(m); return (int(m.sum()), (xs.min(),xs.max(),ys.min(),ys.max())) if len(xs) else (0,None)
10
+ print(f, 'mean',a.mean(axis=(0,1)).round(1),'skin',bb(skin),'bright',bb(bone),'red',bb(red))
src/App.tsx CHANGED
@@ -1,24 +1,11 @@
1
- const SURF_LOGO_PATH = "M14.6875 13.333C15.0977 13.333 15.4859 13.5196 15.7422 13.8398L17.8721 16.502C17.9009 16.538 17.9308 16.5721 17.9619 16.6035C18.3091 16.9542 18.7529 17.3405 18.7529 17.834C18.7528 18.3399 18.3428 18.75 17.8369 18.75H17.5713C17.2123 18.75 16.8727 18.5869 16.6484 18.3066L15.4678 16.8311C14.761 15.9479 13.3369 16.4478 13.3369 17.5791V18.6494C13.3369 18.7062 13.2882 18.75 13.2314 18.75H11.3584C10.7127 18.75 10.1016 18.4564 9.69824 17.9521L8.80176 16.8311C8.09497 15.9477 6.66993 16.4478 6.66992 17.5791V18.6494C6.66991 18.7062 6.62124 18.75 6.56445 18.75H4.69141C4.04589 18.75 3.43548 18.4562 3.03223 17.9521L2.13477 16.8311C1.77885 16.3862 1.25293 15.931 1.25293 15.3613V13.4395C1.253 13.3822 1.29729 13.3331 1.35449 13.333C1.7646 13.3332 2.15295 13.5196 2.40918 13.8398L4.53809 16.502C5.24478 17.3853 6.66962 16.886 6.66992 15.7549V14.6836C6.67018 13.9379 7.27483 13.3332 8.02051 13.333C8.43065 13.333 8.81884 13.5197 9.0752 13.8398L11.2051 16.502C11.9097 17.3827 13.3282 16.8887 13.3369 15.7646V14.6836C13.3372 13.9378 13.9417 13.3331 14.6875 13.333ZM14.6875 6.66699C15.0978 6.66702 15.4859 6.85347 15.7422 7.17383L17.8721 9.83594C18.2276 10.2801 18.7526 10.7339 18.7529 11.3027V13.2266C18.7528 13.2833 18.7091 13.333 18.6523 13.333C18.2421 13.3329 17.8539 13.1465 17.5977 12.8262L15.4678 10.1641C14.7609 9.28113 13.3369 9.78089 13.3369 10.9121V11.9824C13.3369 12.7282 12.7321 13.3328 11.9863 13.333C11.5761 13.3329 11.187 13.1465 10.9307 12.8262L8.80176 10.1641C8.09497 9.28072 6.66993 9.78076 6.66992 10.9121V11.9824C6.66992 12.7284 6.06525 13.3329 5.31934 13.333C4.90908 13.3329 4.52094 13.1465 4.26465 12.8262L2.13477 10.1641C1.7788 9.71925 1.25293 9.26403 1.25293 8.69434V6.77344C1.253 6.71621 1.29728 6.6671 1.35449 6.66699C1.76461 6.66714 2.15295 6.85359 2.40918 7.17383L4.53809 9.83594C5.24487 10.7193 6.66989 10.2192 6.66992 9.08789V8.01758C6.66992 7.27168 7.27467 6.66722 8.02051 6.66699C8.43082 6.66699 8.81885 6.85345 9.0752 7.17383L11.2051 9.83594C11.9097 10.7163 13.3279 10.2223 13.3369 9.09863V8.01758C13.3369 7.27161 13.9416 6.6671 14.6875 6.66699ZM2.43555 1.25C2.79488 1.25 3.13499 1.41367 3.35938 1.69434L4.53809 3.16895C5.24485 4.05241 6.66991 3.55228 6.66992 2.4209V1.35059C6.66993 1.29391 6.71874 1.25017 6.77539 1.25H8.64844C9.29394 1.25 9.90438 1.5438 10.3076 2.04785L11.2051 3.16895C11.9097 4.04967 13.3282 3.55564 13.3369 2.43164V1.35059C13.3369 1.29391 13.3857 1.25017 13.4424 1.25H15.3154C15.9609 1.25 16.5714 1.5438 16.9746 2.04785L17.8721 3.16895C18.2275 3.61314 18.7526 4.06689 18.7529 4.63574V6.56055C18.7528 6.61728 18.7091 6.66699 18.6523 6.66699C18.2421 6.66688 17.8539 6.4795 17.5977 6.15918L15.4678 3.49805C14.761 2.61474 13.3371 3.11388 13.3369 4.24512V5.31543C13.3369 6.06126 12.7321 6.66673 11.9863 6.66699C11.5761 6.66693 11.187 6.47955 10.9307 6.15918L8.80176 3.49805C8.095 2.61474 6.67006 3.11388 6.66992 4.24512V5.31543C6.66992 6.06137 6.06525 6.66691 5.31934 6.66699C4.90908 6.66693 4.52094 6.47955 4.26465 6.15918L2.13477 3.49805C2.10558 3.46158 2.07543 3.42725 2.04395 3.39551C1.6966 3.04528 1.25293 2.6583 1.25293 2.16504C1.25317 1.65963 1.66348 1.25 2.16895 1.25H2.43555Z"
2
 
3
- function SurfLogo() {
4
- return (
5
- <svg width="40" height="40" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
6
- <path d={SURF_LOGO_PATH} fill="var(--fg-base, #212121)" />
7
- </svg>
8
- )
9
- }
10
 
11
- // Frontend API calls should use `src/lib/api.ts`, not absolute `/api/...` URLs.
12
  export default function App() {
13
- return (
14
- <div data-surf-placeholder className="min-h-screen flex flex-col items-center justify-center gap-4 rounded-[14px]" style={{ background: 'var(--bg-chat-nav, #f4f4f4)', border: '1px solid var(--border-strong, rgba(42,42,42,0.08))' }}>
15
- <SurfLogo />
16
- <h1 className="font-black text-[36px] leading-[44px] text-[var(--fg-base,#212121)]">
17
- GM, Builder<span style={{ color: 'var(--brand-100, #ff2882)' }}>.</span>
18
- </h1>
19
- <p className="text-xl leading-7 text-[var(--fg-subtle,#7a7a7a)]">
20
- Start your crypto project here!
21
- </p>
22
- </div>
23
- )
24
  }
 
1
+ import { lazy, Suspense } from 'react'
2
 
3
+ const Debug = lazy(() => import('./video/Debug'))
4
+ const TDebug = lazy(() => import('./video/TDebug'))
 
 
 
 
 
5
 
 
6
  export default function App() {
7
+ const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
8
+ if (q?.has('debug')) return <Suspense fallback={null}><Debug /></Suspense>
9
+ if (q?.has('tdebug')) return <Suspense fallback={null}><TDebug /></Suspense>
10
+ return <div style={{ color: '#fff' }}>loading</div>
 
 
 
 
 
 
 
11
  }
src/video/Debug.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import { Engine } from './engine'
3
+ import { baseState, orbit } from './state'
4
+ import { POSE } from './model'
5
+ import type { V3 } from './math'
6
+
7
+ /** Debug still renderer: ?debug=<json> */
8
+ export default function Debug() {
9
+ const ref = useRef<HTMLCanvasElement>(null)
10
+ useEffect(() => {
11
+ const q = JSON.parse(decodeURIComponent(new URLSearchParams(location.search).get('debug') || '{}'))
12
+ const eng = new Engine(); eng.setSize(540, 960)
13
+ const c = ref.current!; c.width = 540; c.height = 960
14
+ const target: V3 = q.target ?? [0, 0.2, 0]
15
+ const s = baseState({
16
+ cam: { pos: orbit(target, q.dist ?? 5, q.yaw ?? 0, q.pitch ?? 0.1), target, fov: 35 },
17
+ arm: { pos: [0, 0, 0], rot: q.rot ?? [0, 0.6, 0] },
18
+ pose: q.poseArr ?? [...(POSE as Record<string, number[]>)[q.pose ?? 'rest']],
19
+ xray: q.xray ?? 0, bone: q.bone ?? 1, tendon: q.tendon ?? 1, sheath: q.sheath ?? 1, thick: q.thick ?? 0, pain: q.pain ?? 0, friction: q.friction ?? 0,
20
+ props: Object.fromEntries((q.props ?? []).map((p: string) => [p, { vis: 1, depth: 0.3 }])),
21
+ time: 1,
22
+ })
23
+ eng.render({ views: [{ rect: [0, 0, 1, 1], state: s }] }, c.getContext('2d')!, 1, { w: 540, h: 960 })
24
+ const m = eng.model, cam = eng.cams[0]
25
+ const pr = (o: import('three').Object3D, y: number) => { const v = o.localToWorld(new (m.anchors.pain.constructor as any)(0, y, 0)); const q = m.project(v, cam); return [Math.round(q.x * 540), Math.round(q.y * 960), +v.x.toFixed(2), +v.y.toFixed(2), +v.z.toFixed(2)] }
26
+ const info: Record<string, unknown> = {
27
+ thumbTip: pr(m.thumbJoints[2], 0.27), idxTip: pr(m.fingerJoints[0][2], 0.2), midTip: pr(m.fingerJoints[1][2], 0.21), litTip: pr(m.fingerJoints[3][2], 0.18),
28
+ wrist: pr(m.wrist, 0), elbow: pr(m.forearm, -2.6), pain: (() => { const q = m.project(m.anchors.pain, cam); return [Math.round(q.x * 540), Math.round(q.y * 960)] })(),
29
+ }
30
+ for (const [k, o] of Object.entries(m.props)) if (o.visible) { const v = o.getWorldPosition(new (m.anchors.pain.constructor as any)()); const q = m.project(v, cam); info['prop_' + k] = [Math.round(q.x * 540), Math.round(q.y * 960), +v.x.toFixed(2), +v.y.toFixed(2), +v.z.toFixed(2)] }
31
+ console.log('INFO ' + JSON.stringify(info))
32
+ ;(window as unknown as { __done: boolean }).__done = true
33
+ }, [])
34
+ return <canvas ref={ref} style={{ width: 540, height: 960 }} />
35
+ }
src/video/TDebug.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import { Engine } from './engine'
3
+ import { frameAt } from './scenes'
4
+
5
+ /** ?tdebug=3,10,17 renders frames, logs pixel stats; last one stays on screen */
6
+ export default function TDebug() {
7
+ const ref = useRef<HTMLCanvasElement>(null)
8
+ useEffect(() => {
9
+ const ts = (new URLSearchParams(location.search).get('tdebug') || '3').split(',').map(Number)
10
+ const eng = new Engine(); eng.setSize(540, 960)
11
+ const c = ref.current!; c.width = 540; c.height = 960
12
+ const ctx = c.getContext('2d')!
13
+ const out: string[] = []
14
+ for (const t of ts) {
15
+ try {
16
+ const t0 = performance.now()
17
+ eng.render(frameAt(t), ctx, t, { w: 540, h: 960 })
18
+ const ms = performance.now() - t0
19
+ const d = ctx.getImageData(0, 0, 540, 960).data
20
+ let sum = 0, red = 0, skin = 0
21
+ for (let i = 0; i < d.length; i += 16) {
22
+ const r = d[i], g = d[i + 1], b = d[i + 2]
23
+ sum += (r + g + b) / 3
24
+ if (r > 170 && g < 90 && b < 90) red++
25
+ if (r > 150 && g > 100 && b > 70 && r > g && g > b && r - b > 40) skin++
26
+ }
27
+ const n = d.length / 16
28
+ out.push(`t=${t} ms=${ms.toFixed(0)} lum=${(sum / n).toFixed(1)} red=${(red / n * 100).toFixed(1)}% skin=${(skin / n * 100).toFixed(1)}%`)
29
+ } catch (e) {
30
+ out.push(`t=${t} ERROR ${(e as Error).message} ${(e as Error).stack?.split('\n').slice(0, 3).join(' | ')}`)
31
+ }
32
+ }
33
+ console.log('INFO ' + out.join(' ;; '))
34
+ ;(window as unknown as { __done: boolean }).__done = true
35
+ }, [])
36
+ return <canvas ref={ref} style={{ width: 540, height: 960 }} />
37
+ }
src/video/engine.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three'
2
+ import { HandModel, type SceneState } from './model'
3
+
4
+ export interface View { rect: [number, number, number, number]; state: SceneState; bg?: string }
5
+ export interface OverlayApi {
6
+ W: number; H: number; t: number
7
+ /** project anchor/world point of view i → logical coords (1080 x 1920 space scaled by view rect) */
8
+ anchor: (name: string, view?: number) => { x: number; y: number; z: number }
9
+ world: (p: THREE.Vector3, view?: number) => { x: number; y: number; z: number }
10
+ model: HandModel
11
+ }
12
+ export interface Frame {
13
+ views: View[]
14
+ overlay?: (ctx: CanvasRenderingContext2D, api: OverlayApi) => void
15
+ fade?: number // 0..1 black
16
+ }
17
+
18
+ export const LW = 1080, LH = 1920
19
+
20
+ const BG_CACHE: Record<string, THREE.Texture> = {}
21
+ function bgTexture(kind: string) {
22
+ if (BG_CACHE[kind]) return BG_CACHE[kind]
23
+ const c = document.createElement('canvas'); c.width = 540; c.height = 960
24
+ const g = c.getContext('2d')!
25
+ const pal: Record<string, [string, string, string]> = {
26
+ lab: ['#123257', '#081a33', '#020711'],
27
+ warm: ['#3a2a3f', '#1b1426', '#07050c'],
28
+ red: ['#4a1626', '#1e0b19', '#06030a'],
29
+ clean: ['#1d4a6e', '#0b2441', '#030b18'],
30
+ }
31
+ const [a, b, d] = pal[kind] ?? pal.lab
32
+ const gr = g.createRadialGradient(270, 400, 20, 270, 480, 620)
33
+ gr.addColorStop(0, a); gr.addColorStop(0.5, b); gr.addColorStop(1, d)
34
+ g.fillStyle = gr; g.fillRect(0, 0, 540, 960)
35
+ // subtle grid
36
+ g.strokeStyle = 'rgba(120,190,255,0.05)'; g.lineWidth = 1
37
+ for (let x = 0; x <= 540; x += 30) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, 960); g.stroke() }
38
+ for (let y = 0; y <= 960; y += 30) { g.beginPath(); g.moveTo(0, y); g.lineTo(540, y); g.stroke() }
39
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace
40
+ BG_CACHE[kind] = t
41
+ return t
42
+ }
43
+
44
+ export class Engine {
45
+ gl: HTMLCanvasElement
46
+ renderer: THREE.WebGLRenderer
47
+ model: HandModel
48
+ camera = new THREE.PerspectiveCamera(35, 9 / 16, 0.05, 100)
49
+ cams: THREE.PerspectiveCamera[] = []
50
+ W = 1080; H = 1920
51
+
52
+ constructor() {
53
+ this.gl = document.createElement('canvas')
54
+ this.renderer = new THREE.WebGLRenderer({ canvas: this.gl, antialias: true, preserveDrawingBuffer: true, alpha: false, powerPreference: 'high-performance' })
55
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace
56
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping
57
+ this.renderer.toneMappingExposure = 1.05
58
+ this.renderer.setPixelRatio(1)
59
+ this.model = new HandModel(this.renderer)
60
+ this.setSize(1080, 1920)
61
+ }
62
+
63
+ setSize(w: number, h: number) {
64
+ this.W = w; this.H = h
65
+ this.renderer.setSize(w, h, false)
66
+ }
67
+
68
+ private setupCam(cam: THREE.PerspectiveCamera, s: SceneState, aspect: number) {
69
+ cam.aspect = aspect
70
+ cam.fov = s.cam.fov
71
+ cam.position.set(...s.cam.pos)
72
+ const target = new THREE.Vector3(...s.cam.target)
73
+ const follow = (s.cam as { follow?: number }).follow ?? 0
74
+ if (follow > 0) {
75
+ const d = this.model.anchors.pain.clone().sub(target).multiplyScalar(follow)
76
+ cam.position.add(d); target.add(d)
77
+ }
78
+ cam.up.set(0, 1, 0)
79
+ if (s.cam.roll) cam.up.applyAxisAngle(new THREE.Vector3(0, 0, 1), s.cam.roll)
80
+ cam.lookAt(target)
81
+ cam.updateProjectionMatrix()
82
+ cam.updateMatrixWorld()
83
+ }
84
+
85
+ /** Render a frame onto a 2D context sized W x H (logical layout uses LW x LH scaled). */
86
+ render(frame: Frame, ctx: CanvasRenderingContext2D, t: number, logical: { w: number; h: number } = { w: LW, h: LH }) {
87
+ const { W, H, renderer } = this
88
+ renderer.setScissorTest(true)
89
+ renderer.autoClear = false
90
+ renderer.setClearColor('#02060d', 1)
91
+ renderer.setViewport(0, 0, W, H); renderer.setScissor(0, 0, W, H); renderer.clear()
92
+ while (this.cams.length < frame.views.length) this.cams.push(new THREE.PerspectiveCamera())
93
+ const anchorsPerView: Record<string, THREE.Vector3>[] = []
94
+ frame.views.forEach((v, i) => {
95
+ const [rx, ry, rw, rh] = v.rect
96
+ const x = Math.round(rx * W), y = Math.round(ry * H), w = Math.round(rw * W), h = Math.round(rh * H)
97
+ this.model.apply(v.state)
98
+ this.model.scene.background = bgTexture(v.bg ?? 'lab')
99
+ const cam = this.cams[i]
100
+ this.setupCam(cam, v.state, w / h)
101
+ renderer.setViewport(x, H - y - h, w, h); renderer.setScissor(x, H - y - h, w, h)
102
+ renderer.render(this.model.scene, cam)
103
+ const snap: Record<string, THREE.Vector3> = {}
104
+ for (const [k, p] of Object.entries(this.model.anchors)) snap[k] = p.clone()
105
+ anchorsPerView.push(snap)
106
+ })
107
+ renderer.setScissorTest(false)
108
+
109
+ ctx.save()
110
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
111
+ ctx.clearRect(0, 0, W, H)
112
+ ctx.drawImage(this.gl, 0, 0, W, H)
113
+ // logical transform
114
+ const sx = W / logical.w, sy = H / logical.h
115
+ ctx.setTransform(sx, 0, 0, sy, 0, 0)
116
+ // particles + vignette
117
+ drawAtmosphere(ctx, t, logical.w, logical.h)
118
+ if (frame.overlay) {
119
+ const api: OverlayApi = {
120
+ W: logical.w, H: logical.h, t, model: this.model,
121
+ world: (p, vi = 0) => {
122
+ const v = frame.views[vi]; const cam = this.cams[vi]
123
+ const q = p.clone().project(cam)
124
+ const [rx, ry, rw, rh] = v.rect
125
+ return { x: (rx + ((q.x + 1) / 2) * rw) * logical.w, y: (ry + ((1 - q.y) / 2) * rh) * logical.h, z: q.z }
126
+ },
127
+ anchor: (name, vi = 0) => api.world(anchorsPerView[vi]?.[name] ?? new THREE.Vector3(), vi),
128
+ }
129
+ frame.overlay(ctx, api)
130
+ }
131
+ if (frame.fade && frame.fade > 0) {
132
+ ctx.fillStyle = `rgba(0,0,0,${Math.min(1, frame.fade)})`; ctx.fillRect(0, 0, logical.w, logical.h)
133
+ }
134
+ ctx.restore()
135
+ }
136
+ }
137
+
138
+ function drawAtmosphere(ctx: CanvasRenderingContext2D, t: number, w: number, h: number) {
139
+ // floating micro particles (deterministic)
140
+ ctx.save()
141
+ ctx.globalCompositeOperation = 'lighter'
142
+ for (let i = 0; i < 40; i++) {
143
+ const s1 = Math.sin(i * 91.7) * 0.5 + 0.5, s2 = Math.sin(i * 37.3 + 1) * 0.5 + 0.5, s3 = Math.sin(i * 13.1 + 2) * 0.5 + 0.5
144
+ const x = (s1 * w + Math.sin(t * 0.2 + i) * 30) % w
145
+ const y = ((s2 * h - t * (8 + s3 * 18)) % h + h) % h
146
+ const r = 1.2 + s3 * 2.4
147
+ ctx.fillStyle = `rgba(120,200,255,${0.05 + s3 * 0.12})`
148
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
149
+ }
150
+ ctx.restore()
151
+ const g = ctx.createRadialGradient(w / 2, h * 0.45, h * 0.3, w / 2, h * 0.5, h * 0.78)
152
+ g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.55)')
153
+ ctx.fillStyle = g; ctx.fillRect(0, 0, w, h)
154
+ }
src/video/math.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type V3 = [number, number, number]
2
+
3
+ export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x))
4
+ export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
5
+ export const smooth = (t: number) => { t = clamp(t); return t * t * (3 - 2 * t) }
6
+ export const smoother = (t: number) => { t = clamp(t); return t * t * t * (t * (t * 6 - 15) + 10) }
7
+ export const easeOut = (t: number) => 1 - Math.pow(1 - clamp(t), 3)
8
+ export const easeIn = (t: number) => Math.pow(clamp(t), 3)
9
+ export const easeInOut = (t: number) => smoother(t)
10
+ export const backOut = (t: number) => { t = clamp(t); const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) }
11
+
12
+ /** 0→1 ramp between a and b */
13
+ export const ramp = (x: number, a: number, b: number) => clamp((x - a) / (b - a))
14
+ /** fade in over [a,a+fi], hold, fade out over [b-fo,b] */
15
+ export const win = (x: number, a: number, b: number, fi = 0.3, fo = 0.3) =>
16
+ Math.min(smooth(ramp(x, a, a + fi)), 1 - smooth(ramp(x, b - fo, b)))
17
+
18
+ export const lerpV = (a: V3, b: V3, t: number): V3 => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
19
+ export const lerpArr = (a: number[], b: number[], t: number) => a.map((v, i) => lerp(v, b[i], t))
20
+
21
+ /** Keyframe interpolation for numbers / arrays. keys: [time, value] sorted. */
22
+ export function kf<T extends number | number[]>(x: number, keys: [number, T][], ease: (t: number) => number = smoother): T {
23
+ if (x <= keys[0][0]) return keys[0][1]
24
+ for (let i = 0; i < keys.length - 1; i++) {
25
+ const [t0, v0] = keys[i], [t1, v1] = keys[i + 1]
26
+ if (x <= t1) {
27
+ const k = ease((x - t0) / Math.max(1e-6, t1 - t0))
28
+ if (typeof v0 === 'number') return lerp(v0, v1 as number, k) as T
29
+ return lerpArr(v0 as number[], v1 as number[], k) as T
30
+ }
31
+ }
32
+ return keys[keys.length - 1][1]
33
+ }
34
+
35
+ /** deterministic hash noise */
36
+ export const hash = (n: number) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s) }
37
+ export const noise1 = (x: number) => { const i = Math.floor(x), f = x - i; const u = f * f * (3 - 2 * f); return lerp(hash(i), hash(i + 1), u) * 2 - 1 }
src/video/model.ts ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three'
2
+ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
3
+ import type { V3 } from './math'
4
+
5
+ /* ------------------------------------------------------------------ */
6
+ /* Pose */
7
+ /* ------------------------------------------------------------------ */
8
+ // [wristFlex, wristDev(+ulnar), pron, spread,
9
+ // idx mcp,pip,dip, mid.., ring.., little..,
10
+ // tAbd, tFlex, tOpp, tMcp, tIp]
11
+ export type Pose = number[]
12
+ export const POSE = {
13
+ 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],
14
+ 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],
15
+ 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],
16
+ // Finkelstein-like: thumb tucked + ulnar deviation (provokes pain)
17
+ 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],
18
+ 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],
19
+ 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],
20
+ 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],
21
+ 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],
22
+ 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],
23
+ 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],
24
+ 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],
25
+ } satisfies Record<string, Pose>
26
+
27
+ /* ------------------------------------------------------------------ */
28
+ /* Scene state */
29
+ /* ------------------------------------------------------------------ */
30
+ export interface PropState { vis: number; [k: string]: number }
31
+ export interface SceneState {
32
+ cam: { pos: V3; target: V3; fov: number; roll?: number; follow?: number }
33
+ arm: { pos: V3; rot: V3; twist?: { pivot: V3; axis: V3; angle: number } }
34
+ pose: Pose
35
+ skin: number // overall skin visibility 0..1
36
+ xray: number // 0 opaque skin → 1 transparent x-ray skin
37
+ bone: number // reveal 0..1
38
+ tendon: number
39
+ sheath: number
40
+ thick: number // sheath thickening 0..1
41
+ friction: number // tendon hot-spot 0..1
42
+ pain: number // red glow 0..1
43
+ slide?: number // extra tendon slide offset
44
+ focus?: number // highlight the 1st compartment (dims others)
45
+ props: Record<string, PropState | undefined>
46
+ shake?: V3
47
+ time: number
48
+ }
49
+
50
+ export const PAIN_LOCAL: V3 = [-0.3, -0.17, 0.06] // in forearm space (1st dorsal compartment)
51
+
52
+ /* ------------------------------------------------------------------ */
53
+ /* Shaders */
54
+ /* ------------------------------------------------------------------ */
55
+ const VS = /* glsl */ `
56
+ varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv;
57
+ void main(){
58
+ vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz;
59
+ vec4 mv = viewMatrix * w; vV = mv.xyz;
60
+ vN = normalize(normalMatrix * normal); vUv = uv;
61
+ gl_Position = projectionMatrix * mv;
62
+ }`
63
+ const LIGHT = /* glsl */ `
64
+ vec3 shade(vec3 base, vec3 N, vec3 V, float shin, float specAmt, float wrapAmt){
65
+ vec3 L1 = normalize(vec3(0.45,0.75,0.55));
66
+ vec3 L2 = normalize(vec3(-0.75,0.15,0.35));
67
+ vec3 L3 = normalize(vec3(0.15,-0.5,-0.85));
68
+ float d1 = max((dot(N,L1)+wrapAmt)/(1.0+wrapAmt),0.0);
69
+ float d2 = max(dot(N,L2),0.0);
70
+ float d3 = max(dot(N,L3),0.0);
71
+ vec3 c = base*(0.2 + 0.82*d1) + base*d2*0.28*vec3(0.75,0.88,1.0);
72
+ float s = pow(max(dot(N,normalize(L1+V)),0.0),shin)*specAmt;
73
+ float fr = pow(1.0-max(dot(N,V),0.0),3.0);
74
+ c += vec3(s) + fr*vec3(0.35,0.62,1.0)*0.32 + d3*0.12*vec3(0.4,0.65,1.0);
75
+ return c;
76
+ }
77
+ uniform vec3 uRC; uniform float uRR;
78
+ float reveal(){ float d = distance(vW,uRC); if(d>uRR) discard; return 1.0-smoothstep(0.0,0.07,uRR-d); }
79
+ `
80
+
81
+ function revealUniforms() {
82
+ return { uRC: { value: new THREE.Vector3() }, uRR: { value: 100 } }
83
+ }
84
+
85
+ function makeOrganic(color: string, shin = 30, spec = 0.25, wrap = 0.3) {
86
+ return new THREE.ShaderMaterial({
87
+ uniforms: { uColor: { value: new THREE.Color(color) }, uAlpha: { value: 1 }, uDim: { value: 0 }, ...revealUniforms() },
88
+ vertexShader: VS,
89
+ fragmentShader: /* glsl */ `
90
+ varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv;
91
+ uniform vec3 uColor; uniform float uAlpha; uniform float uDim;
92
+ ${LIGHT}
93
+ void main(){
94
+ float e = reveal();
95
+ vec3 N = normalize(vN); if(!gl_FrontFacing) N = -N; vec3 V = normalize(-vV);
96
+ vec3 c = shade(uColor, N, V, ${shin.toFixed(1)}, ${spec.toFixed(3)}, ${wrap.toFixed(2)});
97
+ c = mix(c, c*0.35 + vec3(0.02,0.05,0.1), uDim);
98
+ c += vec3(0.3,0.9,1.0)*e*1.6;
99
+ gl_FragColor = vec4(c, uAlpha);
100
+ }`,
101
+ transparent: false,
102
+ })
103
+ }
104
+
105
+ /* ------------------------------------------------------------------ */
106
+ /* Tube builder (dynamic) */
107
+ /* ------------------------------------------------------------------ */
108
+ class DynTube {
109
+ geo = new THREE.BufferGeometry()
110
+ pos: Float32Array; nor: Float32Array; uv: Float32Array
111
+ curve = new THREE.CatmullRomCurve3([new THREE.Vector3(), new THREE.Vector3(0, 1, 0)], false, 'centripetal')
112
+ constructor(public segs: number, public radial: number) {
113
+ const n = (segs + 1) * (radial + 1)
114
+ this.pos = new Float32Array(n * 3); this.nor = new Float32Array(n * 3); this.uv = new Float32Array(n * 2)
115
+ const idx: number[] = []
116
+ for (let i = 0; i < segs; i++) for (let j = 0; j < radial; j++) {
117
+ const a = i * (radial + 1) + j, b = (i + 1) * (radial + 1) + j
118
+ idx.push(a, b, a + 1, b, b + 1, a + 1)
119
+ }
120
+ this.geo.setIndex(idx)
121
+ this.geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3))
122
+ this.geo.setAttribute('normal', new THREE.BufferAttribute(this.nor, 3))
123
+ this.geo.setAttribute('uv', new THREE.BufferAttribute(this.uv, 2))
124
+ }
125
+ update(points: THREE.Vector3[], radius: (u: number) => number) {
126
+ this.curve.points = points
127
+ const { segs, radial } = this
128
+ const frames = this.curve.computeFrenetFrames(segs, false)
129
+ const len = this.curve.getLength()
130
+ const p = new THREE.Vector3(), n = new THREE.Vector3()
131
+ for (let i = 0; i <= segs; i++) {
132
+ const u = i / segs
133
+ this.curve.getPointAt(u, p)
134
+ const N = frames.normals[i], B = frames.binormals[i]
135
+ const r = radius(u)
136
+ for (let j = 0; j <= radial; j++) {
137
+ const v = (j / radial) * Math.PI * 2
138
+ const s = Math.sin(v), c = -Math.cos(v)
139
+ n.set(c * N.x + s * B.x, c * N.y + s * B.y, c * N.z + s * B.z).normalize()
140
+ const k = i * (radial + 1) + j
141
+ 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
142
+ this.nor[k * 3] = n.x; this.nor[k * 3 + 1] = n.y; this.nor[k * 3 + 2] = n.z
143
+ this.uv[k * 2] = u * len; this.uv[k * 2 + 1] = j / radial
144
+ }
145
+ }
146
+ this.geo.attributes.position.needsUpdate = true
147
+ this.geo.attributes.normal.needsUpdate = true
148
+ this.geo.attributes.uv.needsUpdate = true
149
+ this.geo.computeBoundingSphere()
150
+ }
151
+ }
152
+
153
+ /* ------------------------------------------------------------------ */
154
+ /* Hand model */
155
+ /* ------------------------------------------------------------------ */
156
+ const FINGERS = [
157
+ { 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 },
158
+ { 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 },
159
+ { 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 },
160
+ { 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 },
161
+ ] as const
162
+
163
+ export class HandModel {
164
+ scene = new THREE.Scene()
165
+ armRoot = new THREE.Group()
166
+ forearm = new THREE.Group()
167
+ wrist = new THREE.Group()
168
+ fingerJoints: THREE.Group[][] = []
169
+ thumbJoints: THREE.Group[] = []
170
+ thumbBase = new THREE.Quaternion()
171
+ skinMat: THREE.ShaderMaterial
172
+ skinDepth: THREE.MeshBasicMaterial
173
+ boneMat = makeOrganic('#efe5cf', 24, 0.22, 0.35)
174
+ cartMat = makeOrganic('#cfe3f0', 40, 0.35, 0.3)
175
+ muscleMat = makeOrganic('#b8463f', 20, 0.18, 0.4)
176
+ tendonMat: THREE.ShaderMaterial
177
+ extTendonMat: THREE.ShaderMaterial
178
+ sheathMat: THREE.ShaderMaterial
179
+ tendons: { tube: DynTube; mesh: THREE.Mesh; kind: 'apl' | 'epb' | 'ext' | 'epl'; idx?: number }[] = []
180
+ sheathTube = new DynTube(60, 28)
181
+ sheathMesh!: THREE.Mesh
182
+ painCore: THREE.Sprite
183
+ painHalo: THREE.Sprite
184
+ locator: THREE.Sprite
185
+ props: Record<string, THREE.Object3D> = {}
186
+ anchors: Record<string, THREE.Vector3> = {}
187
+ envTex: THREE.Texture | null = null
188
+ private boneMats: THREE.ShaderMaterial[] = []
189
+
190
+ constructor(renderer: THREE.WebGLRenderer) {
191
+ const pm = new THREE.PMREMGenerator(renderer)
192
+ this.envTex = pm.fromScene(new RoomEnvironment(), 0.04).texture
193
+ this.scene.environment = this.envTex
194
+ const dl = new THREE.DirectionalLight('#ffffff', 1.6); dl.position.set(2, 4, 3); this.scene.add(dl)
195
+ const fl = new THREE.DirectionalLight('#8fc6ff', 0.6); fl.position.set(-3, 1, 2); this.scene.add(fl)
196
+ this.scene.add(new THREE.AmbientLight('#6688aa', 0.35))
197
+
198
+ /* --- skin material (custom subsurface-ish + x-ray) --- */
199
+ this.skinMat = new THREE.ShaderMaterial({
200
+ uniforms: { uColor: { value: new THREE.Color('#e2a284') }, uOpacity: { value: 1 }, uXray: { value: 0 }, uPainPos: { value: new THREE.Vector3() }, uPain: { value: 0 }, uTime: { value: 0 } },
201
+ vertexShader: VS,
202
+ fragmentShader: /* glsl */ `
203
+ varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv;
204
+ uniform vec3 uColor; uniform float uOpacity; uniform float uXray; uniform vec3 uPainPos; uniform float uPain; uniform float uTime;
205
+ uniform vec3 uRC; uniform float uRR;
206
+ void main(){
207
+ vec3 N = normalize(vN); vec3 V = normalize(-vV);
208
+ vec3 L1 = normalize(vec3(0.45,0.75,0.55)); vec3 L2 = normalize(vec3(-0.75,0.15,0.35));
209
+ float ndl = dot(N,L1);
210
+ float wrap = clamp((ndl+0.45)/1.45,0.0,1.0);
211
+ float term = smoothstep(0.0,0.45,wrap) - smoothstep(0.35,0.9,wrap);
212
+ vec3 c = uColor*(0.24 + 0.78*wrap) + uColor*max(dot(N,L2),0.0)*0.22*vec3(0.8,0.9,1.0);
213
+ c += vec3(0.55,0.08,0.04)*term*0.35; // subsurface red terminator
214
+ float fr = pow(1.0-max(dot(N,V),0.0),2.6);
215
+ c += vec3(1.0,0.82,0.72)*fr*0.18;
216
+ c += vec3(pow(max(dot(N,normalize(L1+V)),0.0),36.0)*0.16);
217
+ // pain glow on skin
218
+ float pd = distance(vW,uPainPos);
219
+ float pg = uPain*exp(-pd*pd/0.022)*(0.75+0.25*sin(uTime*7.0));
220
+ 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;
221
+ // x-ray
222
+ vec3 xc = vec3(0.42,0.82,1.0)*(0.1+fr*1.25) + vec3(1.0,0.15,0.1)*pg*0.6;
223
+ c = mix(c, xc, uXray);
224
+ float a = mix(1.0, 0.05 + fr*0.55 + pg*0.25, uXray) * uOpacity;
225
+ gl_FragColor = vec4(c, a);
226
+ }`,
227
+ transparent: true, depthWrite: false, depthFunc: THREE.LessEqualDepth,
228
+ })
229
+ this.skinDepth = new THREE.MeshBasicMaterial({ colorWrite: false, transparent: true })
230
+
231
+ /* --- tendon materials --- */
232
+ const tendonFrag = (base: string) => /* glsl */ `
233
+ varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv;
234
+ uniform float uSlide; uniform vec3 uHotPos; uniform float uHot; uniform float uDim; uniform float uTime; uniform float uAlpha;
235
+ ${LIGHT}
236
+ void main(){
237
+ float e = reveal();
238
+ vec3 N = normalize(vN); vec3 V = normalize(-vV);
239
+ float x = vUv.x - uSlide;
240
+ float fib = 0.9 + 0.1*sin(vUv.y*6.2832*7.0 + sin(vUv.x*40.0)*0.6);
241
+ float band = smoothstep(0.35,0.5,abs(fract(x*7.0)-0.5));
242
+ vec3 base = ${base} * fib * (1.0 - band*0.16);
243
+ vec3 c = shade(base, N, V, 48.0, 0.45, 0.35);
244
+ float d = distance(vW,uHotPos);
245
+ float h = uHot*exp(-d*d/0.012);
246
+ vec3 hot = mix(vec3(1.0,0.82,0.15), vec3(1.0,0.12,0.06), smoothstep(0.35,0.85,uHot));
247
+ c = mix(c, hot*(0.8+0.25*sin(uTime*9.0)), clamp(h*1.1,0.0,0.92));
248
+ c += hot*h*0.35;
249
+ c = mix(c, c*0.3+vec3(0.02,0.05,0.1), uDim);
250
+ c += vec3(0.3,0.9,1.0)*e*1.6;
251
+ gl_FragColor = vec4(c, uAlpha);
252
+ }`
253
+ const tUniforms = () => ({ uSlide: { value: 0 }, uHotPos: { value: new THREE.Vector3() }, uHot: { value: 0 }, uDim: { value: 0 }, uTime: { value: 0 }, uAlpha: { value: 1 }, ...revealUniforms() })
254
+ this.tendonMat = new THREE.ShaderMaterial({ uniforms: tUniforms(), vertexShader: VS, fragmentShader: tendonFrag('vec3(0.96,0.93,0.86)') })
255
+ this.extTendonMat = new THREE.ShaderMaterial({ uniforms: tUniforms(), vertexShader: VS, fragmentShader: tendonFrag('vec3(0.9,0.88,0.84)') })
256
+
257
+ this.sheathMat = new THREE.ShaderMaterial({
258
+ uniforms: { uThick: { value: 0 }, uAlpha: { value: 1 }, uTime: { value: 0 }, ...revealUniforms() },
259
+ vertexShader: VS,
260
+ fragmentShader: /* glsl */ `
261
+ varying vec3 vN; varying vec3 vV; varying vec3 vW; varying vec2 vUv;
262
+ uniform float uThick; uniform float uAlpha; uniform float uTime;
263
+ ${LIGHT}
264
+ void main(){
265
+ float e = reveal();
266
+ vec3 N = normalize(vN); vec3 V = normalize(-vV);
267
+ vec3 base = mix(vec3(0.55,0.78,1.0), vec3(1.0,0.36,0.3), uThick);
268
+ vec3 c = shade(base, N, V, 30.0, 0.35, 0.4);
269
+ float fr = pow(1.0-max(dot(N,V),0.0),2.0);
270
+ float ring = 0.5+0.5*sin(vUv.x*95.0);
271
+ c += base*fr*0.9 + ring*0.06;
272
+ c += vec3(1.0,0.2,0.1)*uThick*(0.25+0.2*sin(uTime*6.0))*fr;
273
+ c += vec3(0.3,0.9,1.0)*e*1.6;
274
+ gl_FragColor = vec4(c, (0.28 + fr*0.5 + ring*0.05 + uThick*0.15)*uAlpha);
275
+ }`,
276
+ transparent: true, depthWrite: false, side: THREE.DoubleSide,
277
+ })
278
+
279
+ this.boneMats = [this.boneMat, this.cartMat]
280
+ this.build()
281
+
282
+ /* --- pain sprites --- */
283
+ const glowTex = (inner: string, outer: string) => {
284
+ const c = document.createElement('canvas'); c.width = c.height = 128
285
+ const g = c.getContext('2d')!; const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64)
286
+ gr.addColorStop(0, inner); gr.addColorStop(0.35, outer); gr.addColorStop(1, 'rgba(0,0,0,0)')
287
+ g.fillStyle = gr; g.fillRect(0, 0, 128, 128)
288
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t
289
+ }
290
+ const mk = (tex: THREE.Texture) => {
291
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, blending: THREE.AdditiveBlending, depthTest: false, depthWrite: false, transparent: true }))
292
+ s.renderOrder = 40; return s
293
+ }
294
+ this.painCore = mk(glowTex('rgba(255,235,220,1)', 'rgba(255,40,30,0.85)'))
295
+ this.painHalo = mk(glowTex('rgba(255,60,40,0.9)', 'rgba(255,20,20,0.35)'))
296
+ this.locator = mk(glowTex('rgba(255,255,255,1)', 'rgba(200,240,255,0.6)'))
297
+ this.forearm.add(this.painCore, this.painHalo, this.locator)
298
+ this.painCore.position.set(...PAIN_LOCAL); this.painHalo.position.set(...PAIN_LOCAL); this.locator.position.set(...PAIN_LOCAL)
299
+ }
300
+
301
+ /* ---------------- construction ---------------- */
302
+ private skinPart(parent: THREE.Object3D, geo: THREE.BufferGeometry, pos: V3, scale: V3 = [1, 1, 1], rot: V3 = [0, 0, 0]) {
303
+ const a = new THREE.Mesh(geo, this.skinDepth); const b = new THREE.Mesh(geo, this.skinMat)
304
+ for (const m of [a, b]) { m.position.set(...pos); m.scale.set(...scale); m.rotation.set(...rot); parent.add(m) }
305
+ a.renderOrder = 10; b.renderOrder = 11
306
+ return b
307
+ }
308
+ private bonePart(parent: THREE.Object3D, geo: THREE.BufferGeometry, pos: V3, scale: V3 = [1, 1, 1], rot: V3 = [0, 0, 0], mat = this.boneMat) {
309
+ const m = new THREE.Mesh(geo, mat); m.position.set(...pos); m.scale.set(...scale); m.rotation.set(...rot); parent.add(m); return m
310
+ }
311
+ private boneBetween(parent: THREE.Object3D, a: THREE.Vector3, b: THREE.Vector3, r: number) {
312
+ const L = a.distanceTo(b)
313
+ const g = new THREE.CapsuleGeometry(r, Math.max(0.001, L - r * 2), 6, 14)
314
+ const m = new THREE.Mesh(g, this.boneMat)
315
+ m.position.copy(a).add(b).multiplyScalar(0.5)
316
+ m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize())
317
+ parent.add(m)
318
+ // condyles
319
+ const s = new THREE.SphereGeometry(r * 1.45, 16, 12)
320
+ const h = new THREE.Mesh(s, this.boneMat); h.position.copy(b); h.scale.set(1, 0.8, 0.9); parent.add(h)
321
+ const t = new THREE.Mesh(s, this.boneMat); t.position.copy(a); t.scale.set(1.05, 0.75, 0.9); parent.add(t)
322
+ return m
323
+ }
324
+
325
+ private build() {
326
+ const S = this.scene
327
+ S.add(this.armRoot); this.armRoot.add(this.forearm); this.forearm.add(this.wrist)
328
+ const sph = new THREE.SphereGeometry(1, 48, 32)
329
+
330
+ /* forearm skin */
331
+ const fa = new THREE.CylinderGeometry(0.315, 0.42, 3.2, 48, 12, true)
332
+ this.skinPart(this.forearm, fa, [0, -1.6, 0], [1.24, 1, 0.74])
333
+ this.skinPart(this.forearm, sph, [0, 0.0, 0], [0.39, 0.24, 0.233])
334
+
335
+ /* forearm bones */
336
+ const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z)
337
+ this.boneBetween(this.forearm, v(-0.13, -3.1, 0), v(-0.16, -0.22, 0.0), 0.055)
338
+ this.bonePart(this.forearm, sph, [-0.14, -0.13, 0.0], [0.15, 0.1, 0.105])
339
+ this.bonePart(this.forearm, sph, [-0.255, -0.075, 0.0], [0.055, 0.075, 0.06]) // radial styloid
340
+ this.bonePart(this.forearm, sph, [-0.1, -0.15, 0.09], [0.035, 0.05, 0.03]) // Lister tubercle
341
+ this.boneBetween(this.forearm, v(0.17, -3.1, -0.02), v(0.19, -0.2, -0.01), 0.048)
342
+ this.bonePart(this.forearm, sph, [0.19, -0.12, -0.01], [0.075, 0.075, 0.075])
343
+ this.bonePart(this.forearm, sph, [0.25, -0.07, -0.03], [0.035, 0.05, 0.035]) // ulnar styloid
344
+
345
+ /* forearm muscles (deep, dim) */
346
+ const mus = new THREE.Mesh(sph, this.muscleMat)
347
+ mus.position.set(-0.12, -1.6, 0.13); mus.scale.set(0.11, 0.75, 0.07); this.forearm.add(mus)
348
+ 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)
349
+ this.props.muscles = new THREE.Group(); this.props.muscles.add(mus, mus2); this.forearm.add(this.props.muscles)
350
+
351
+ /* wrist / palm */
352
+ const W = this.wrist
353
+ // carpals
354
+ 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]]
355
+ carp.forEach((p, i) => this.bonePart(W, sph, p, [0.065 + (i % 3) * 0.006, 0.062, 0.07]))
356
+ // palm skin
357
+ this.skinPart(W, sph, [0.0, 0.57, 0.0], [0.455, 0.52, 0.165])
358
+ this.skinPart(W, sph, [0.0, 0.17, -0.005], [0.37, 0.28, 0.19])
359
+ this.skinPart(W, sph, [0.19, 0.5, -0.03], [0.2, 0.42, 0.16]) // hypothenar
360
+ // metacarpals + fingers
361
+ FINGERS.forEach((f, i) => {
362
+ const a = v(...(f.base as unknown as V3)), b = v(...(f.head as unknown as V3))
363
+ this.boneBetween(W, a, b, 0.032)
364
+ const mcp = new THREE.Group(); mcp.position.copy(b)
365
+ const dir = b.clone().sub(a).normalize()
366
+ mcp.userData.base = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir)
367
+ W.add(mcp)
368
+ const joints = [mcp]
369
+ let parent: THREE.Group = mcp
370
+ f.len.forEach((L, k) => {
371
+ const r = f.r * (1 - k * 0.09)
372
+ const seg = new THREE.Group(); parent.add(seg)
373
+ const start = k === 0 ? -0.06 : 0
374
+ // skin capsule
375
+ this.skinPart(seg, new THREE.CapsuleGeometry(r, L - start, 10, 24), [0, (L + start) / 2, 0], [1, 1, 0.88])
376
+ // bone
377
+ this.boneBetween(seg, v(0, 0.035, 0), v(0, L - 0.03, 0), r * 0.36)
378
+ if (k === 2) {
379
+ // nail
380
+ const nail = new THREE.Mesh(sph, new THREE.MeshPhysicalMaterial({ color: '#f2c1b0', roughness: 0.25, clearcoat: 1, transparent: true }))
381
+ nail.position.set(0, L * 0.62, r * 0.66); nail.scale.set(r * 0.72, L * 0.36, r * 0.26)
382
+ nail.renderOrder = 12; seg.add(nail); nail.userData.nail = true
383
+ }
384
+ if (k < 2) { const j = new THREE.Group(); j.position.set(0, L, 0); seg.add(j); joints.push(j); parent = j }
385
+ })
386
+ this.fingerJoints.push(joints)
387
+ })
388
+
389
+ /* thumb */
390
+ const cmc = new THREE.Group(); cmc.position.set(-0.235, 0.2, 0.04); W.add(cmc)
391
+ const y = new THREE.Vector3(-0.44, 0.84, -0.32).normalize()
392
+ let z = new THREE.Vector3(-0.9, 0.0, 0.42); z.sub(y.clone().multiplyScalar(z.dot(y))).normalize()
393
+ const x = new THREE.Vector3().crossVectors(y, z)
394
+ this.thumbBase.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z))
395
+ const tl = [0.46, 0.33, 0.27], tr = [0.122, 0.104, 0.095]
396
+ this.thumbJoints.push(cmc)
397
+ let tp: THREE.Group = cmc
398
+ tl.forEach((L, k) => {
399
+ const seg = new THREE.Group(); tp.add(seg)
400
+ if (k === 0) {
401
+ 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])
402
+ this.skinPart(seg, sph, [0.11, 0.22, -0.06], [0.15, 0.27, 0.12]) // thenar
403
+ } else {
404
+ this.skinPart(seg, new THREE.CapsuleGeometry(tr[k], L, 10, 24), [0, L / 2, 0], [1, 1, 0.9])
405
+ }
406
+ this.boneBetween(seg, v(0, 0.04, 0), v(0, L - 0.03, 0), k === 0 ? 0.036 : tr[k] * 0.36)
407
+ if (k === 2) {
408
+ const nail = new THREE.Mesh(sph, new THREE.MeshPhysicalMaterial({ color: '#f2c1b0', roughness: 0.25, clearcoat: 1, transparent: true }))
409
+ 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)
410
+ }
411
+ if (k < 2) { const j = new THREE.Group(); j.position.set(0, L, 0); seg.add(j); this.thumbJoints.push(j); tp = j }
412
+ })
413
+
414
+ /* tendons */
415
+ const addT = (kind: 'apl' | 'epb' | 'ext' | 'epl', idx?: number) => {
416
+ const tube = new DynTube(kind === 'ext' ? 70 : 110, 12)
417
+ const mesh = new THREE.Mesh(tube.geo, kind === 'apl' || kind === 'epb' ? this.tendonMat : this.extTendonMat)
418
+ mesh.frustumCulled = false
419
+ S.add(mesh); this.tendons.push({ tube, mesh, kind, idx })
420
+ }
421
+ addT('apl'); addT('epb'); addT('epl'); for (let i = 0; i < 4; i++) addT('ext', i)
422
+ this.sheathMesh = new THREE.Mesh(this.sheathTube.geo, this.sheathMat)
423
+ this.sheathMesh.renderOrder = 5; this.sheathMesh.frustumCulled = false
424
+ S.add(this.sheathMesh)
425
+
426
+ this.buildProps(sph)
427
+ }
428
+
429
+ /* ---------------- props ---------------- */
430
+ private buildProps(sph: THREE.SphereGeometry) {
431
+ const std = (color: string, o: Partial<THREE.MeshPhysicalMaterialParameters> = {}) => new THREE.MeshPhysicalMaterial({ color, roughness: 0.45, metalness: 0, ...o })
432
+ const add = (name: string, obj: THREE.Object3D, parent: THREE.Object3D) => { obj.visible = false; parent.add(obj); this.props[name] = obj; return obj }
433
+
434
+ // ball (soft grip ball) – palm
435
+ const ball = new THREE.Mesh(sph, std('#3fb6a8', { roughness: 0.6, sheen: 1, sheenColor: new THREE.Color('#9ff') }))
436
+ ball.scale.setScalar(0.33); ball.position.set(-0.02, 0.7, -0.5)
437
+ add('ball', ball, this.wrist)
438
+
439
+ // coin / bead for pinch
440
+ const bead = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.025, 32), std('#d9b44a', { metalness: 0.9, roughness: 0.25 }))
441
+ add('bead', bead, this.wrist)
442
+
443
+ // bottle + cap (cap in hand space)
444
+ const bottle = new THREE.Group()
445
+ 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 }))
446
+ body.position.y = -0.95
447
+ const label = new THREE.Mesh(new THREE.CylinderGeometry(0.352, 0.362, 0.5, 48, 1, true), std('#1f6fd1'))
448
+ label.position.y = -1.0
449
+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.17, 0.33, 0.25, 40), body.material); neck.position.y = -0.05
450
+ const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.18, 40), std('#f4f4f4', { roughness: 0.35 }))
451
+ cap.position.y = 0.12
452
+ 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) }
453
+ bottle.add(body, label, neck, cap, ribs)
454
+ const bottleWrap = new THREE.Group(); bottleWrap.add(bottle)
455
+ bottleWrap.position.set(-0.02, 0.6, -0.3); bottleWrap.rotation.x = Math.PI / 2 // axis along -Z (palmar)
456
+ bottle.rotation.y = 0
457
+ add('bottle', bottleWrap, this.wrist); bottleWrap.userData.inner = bottle
458
+
459
+ // keyboard & mouse (hand space; palm-down usage)
460
+ const kb = new THREE.Group()
461
+ const deck = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.06, 1.0), std('#2a3240', { roughness: 0.5 }))
462
+ kb.add(deck)
463
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 13; c++) {
464
+ const k = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.05, 0.16), std('#d9dee6', { roughness: 0.6 }))
465
+ k.position.set(-1.15 + c * 0.19 + (r % 2) * 0.05, 0.05, -0.33 + r * 0.21); kb.add(k)
466
+ }
467
+ const mouse = new THREE.Mesh(sph, std('#e8ecf2', { roughness: 0.3, clearcoat: 0.6 }))
468
+ mouse.scale.set(0.28, 0.13, 0.42); mouse.position.set(1.75, 0.08, 0.15)
469
+ const desk = new THREE.Mesh(new THREE.BoxGeometry(6, 0.05, 3), std('#5b4a3c', { roughness: 0.8 })); desk.position.y = -0.06
470
+ kb.add(mouse, desk)
471
+ add('keyboard', kb, this.armRoot)
472
+
473
+ // hammer (tool) – handle across palm (hand-space X axis)
474
+ const ham = new THREE.Group()
475
+ const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.13, 1.7, 32), std('#d0763a', { roughness: 0.5 }))
476
+ handle.rotation.z = Math.PI / 2
477
+ const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.135, 0.14, 0.75, 32), std('#22262c', { roughness: 0.9 }))
478
+ grip.rotation.z = Math.PI / 2; grip.position.x = 0.05
479
+ const head = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.9, 0.28), std('#9aa4b0', { metalness: 0.9, roughness: 0.3 }))
480
+ head.position.x = -0.9
481
+ ham.add(handle, grip, head)
482
+ ham.position.set(0.02, 0.85, -0.37)
483
+ add('hammer', ham, this.wrist)
484
+
485
+ // towel (wringing) – twisted cloth across grip
486
+ const towelGeo = new THREE.CylinderGeometry(0.16, 0.16, 3.2, 24, 60, true)
487
+ const pos = towelGeo.attributes.position as THREE.BufferAttribute
488
+ for (let i = 0; i < pos.count; i++) {
489
+ const px = pos.getX(i), py = pos.getY(i), pz = pos.getZ(i)
490
+ const a = py * 4.0, bulge = 1 + 0.25 * Math.sin(Math.atan2(pz, px) * 3 + a)
491
+ pos.setXYZ(i, (px * Math.cos(a) - pz * Math.sin(a)) * bulge, py, (px * Math.sin(a) + pz * Math.cos(a)) * bulge * 0.8)
492
+ }
493
+ towelGeo.computeVertexNormals()
494
+ const towel = new THREE.Mesh(towelGeo, std('#f0c6d6', { roughness: 0.95, sheen: 1, sheenColor: new THREE.Color('#fff'), side: THREE.DoubleSide }))
495
+ towel.rotation.z = Math.PI / 2; towel.position.set(0.3, 0.85, -0.37)
496
+ add('towel', towel, this.wrist)
497
+
498
+ // baby (swaddled bundle) – supported on palm with thumb abducted
499
+ const baby = new THREE.Group()
500
+ 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') }))
501
+ wrap.rotation.z = Math.PI / 2.2
502
+ 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)
503
+ 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
504
+ 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
505
+ baby.add(wrap, bhead, hat, fold)
506
+ baby.position.set(0.5, 0.75, -0.95); baby.rotation.set(0.1, 0.3, 0.2)
507
+ add('baby', baby, this.wrist)
508
+
509
+ // cup
510
+ const cup = new THREE.Group()
511
+ const cb = new THREE.Mesh(new THREE.CylinderGeometry(0.33, 0.28, 0.85, 40, 1, false), std('#ffffff', { roughness: 0.2, clearcoat: 1 }))
512
+ const coffee = new THREE.Mesh(new THREE.CircleGeometry(0.31, 40), std('#6b3f22')); coffee.rotation.x = -Math.PI / 2; coffee.position.y = 0.36
513
+ 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
514
+ const band = new THREE.Mesh(new THREE.CylinderGeometry(0.335, 0.32, 0.12, 40, 1, true), std('#2bb3a3')); band.position.y = 0.1
515
+ cup.add(cb, coffee, hd, band)
516
+ cup.position.set(0.0, 0.74, -0.44); cup.rotation.set(0, 0, Math.PI / 2); cup.scale.setScalar(0.8)
517
+ add('cup', cup, this.wrist)
518
+
519
+ // phone
520
+ const phone = new THREE.Group()
521
+ const pb = new THREE.Mesh(new THREE.BoxGeometry(0.72, 1.48, 0.07), std('#1b1f27', { metalness: 0.6, roughness: 0.3 }))
522
+ const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.66, 1.4), new THREE.MeshBasicMaterial({ color: '#3a8bff' }))
523
+ scr.position.z = 0.037
524
+ 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)
525
+ 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)
526
+ phone.add(pb, scr, sc2, sc3)
527
+ phone.position.set(0.05, 0.85, -0.42); phone.rotation.set(-0.35, 0.0, 0.15)
528
+ add('phone', phone, this.wrist)
529
+
530
+ // thumb spica splint
531
+ const spl = new THREE.Group()
532
+ const splMat = std('#5aa9e6', { roughness: 0.7, transparent: true, opacity: 0.88, sheen: 1, sheenColor: new THREE.Color('#cde') })
533
+ 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)
534
+ 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)
535
+ const strap2 = strap1.clone(); strap2.position.y = -0.35
536
+ spl.add(sleeve, strap1, strap2)
537
+ add('splint', spl, this.forearm)
538
+ 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)
539
+ add('splintThumb', splThumb, this.thumbJoints[0])
540
+ const splPalm = new THREE.Mesh(sph, splMat); splPalm.scale.set(0.48, 0.3, 0.2); splPalm.position.set(0, 0.25, 0)
541
+ add('splintPalm', splPalm, this.wrist)
542
+
543
+ // needle-knife (original design): handle + guard + shaft + flat micro blade. Tip at origin, body along +Y
544
+ const knife = new THREE.Group()
545
+ const steel = std('#d8dee6', { metalness: 1, roughness: 0.18 })
546
+ const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 0.62, 12), steel); shaft.position.y = 0.33
547
+ const blade = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.04, 0.004), steel); blade.position.y = 0.02
548
+ const neck2 = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.008, 0.06, 16), steel); neck2.position.y = 0.66
549
+ 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
550
+ 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) }
551
+ 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
552
+ 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
553
+ knife.add(shaft, blade, neck2, hand, grip2, capk, fin)
554
+ add('knife', knife, this.forearm)
555
+
556
+ // syringe (generic, unbranded)
557
+ const syr = new THREE.Group()
558
+ 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 }))
559
+ barrel.position.y = 0.6
560
+ 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
561
+ const needle = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, 0.32, 8), steel); needle.position.y = 0.16
562
+ const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.035, 0.05, 16), std('#2bb3a3')); hub.position.y = 0.33
563
+ const plunger = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.5, 8), std('#eeeeee')); plunger.position.y = 0.95
564
+ const pcap = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.02, 24), std('#eeeeee')); pcap.position.y = 1.2
565
+ syr.add(barrel, liquid, needle, hub, plunger, pcap)
566
+ add('syringe', syr, this.forearm)
567
+
568
+ // swab
569
+ const swab = new THREE.Group()
570
+ const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.8, 8), std('#f3f3f3')); stick.position.y = 0.45
571
+ const cot = new THREE.Mesh(sph, std('#c47a2c', { roughness: 1 })); cot.scale.set(0.04, 0.07, 0.04); cot.position.y = 0.04
572
+ swab.add(stick, cot)
573
+ add('swab', swab, this.forearm)
574
+
575
+ // iodine disc (decal on skin)
576
+ const iod = new THREE.Mesh(new THREE.CircleGeometry(1, 48), new THREE.MeshBasicMaterial({ color: '#c7731f', transparent: true, opacity: 0.55, depthWrite: false }))
577
+ iod.renderOrder = 13
578
+ add('iodine', iod, this.forearm)
579
+
580
+ // anesthesia cloud
581
+ const cloud = new THREE.Mesh(sph, new THREE.MeshBasicMaterial({ color: '#59c8ff', transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending }))
582
+ cloud.renderOrder = 14
583
+ add('cloud', cloud, this.forearm)
584
+
585
+ // release sparks ring
586
+ 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 }))
587
+ ring.renderOrder = 41
588
+ add('ring', ring, this.forearm)
589
+
590
+ // probe (ultrasound transducer, original)
591
+ const probe = new THREE.Group()
592
+ const ph = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.12, 0.16), std('#eef3f8', { roughness: 0.3 })); ph.position.y = 0.06
593
+ 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
594
+ const lens = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.02, 0.13), std('#2bb3a3')); lens.position.y = 0.0
595
+ const cable = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.6, 8), std('#3b4250')); cable.position.y = 0.9
596
+ probe.add(ph, pg, lens, cable)
597
+ add('probe', probe, this.forearm)
598
+ }
599
+
600
+ /* ---------------- per-frame ---------------- */
601
+ private v = new THREE.Vector3()
602
+ private pt(obj: THREE.Object3D, x: number, y: number, z: number) { return obj.localToWorld(new THREE.Vector3(x, y, z)) }
603
+
604
+ apply(s: SceneState) {
605
+ const t = s.time
606
+ const p = s.pose
607
+ /* arm placement */
608
+ this.armRoot.position.set(...s.arm.pos)
609
+ this.armRoot.rotation.set(s.arm.rot[0], s.arm.rot[1], s.arm.rot[2], 'YXZ')
610
+ this.armRoot.quaternion.setFromEuler(this.armRoot.rotation)
611
+ if (s.shake) this.armRoot.position.add(new THREE.Vector3(...s.shake))
612
+ this.forearm.rotation.set(0, p[2], 0)
613
+ this.wrist.rotation.set(-p[0], 0, -p[1], 'XZY')
614
+ if (s.arm.twist) {
615
+ this.armRoot.updateMatrixWorld(true)
616
+ const piv = this.wrist.localToWorld(new THREE.Vector3(...s.arm.twist.pivot))
617
+ const ax = new THREE.Vector3(...s.arm.twist.axis).transformDirection(this.wrist.matrixWorld)
618
+ const q = new THREE.Quaternion().setFromAxisAngle(ax, s.arm.twist.angle)
619
+ this.armRoot.position.sub(piv).applyQuaternion(q).add(piv)
620
+ this.armRoot.quaternion.premultiply(q)
621
+ }
622
+ /* fingers */
623
+ this.fingerJoints.forEach((js, i) => {
624
+ const f = FINGERS[i]
625
+ const base = js[0].userData.base as THREE.Quaternion
626
+ const spread = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -f.splay * (1 + p[3] * 4) + f.splay)
627
+ const flex = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -p[4 + i * 3])
628
+ js[0].quaternion.copy(base).multiply(spread).multiply(flex)
629
+ js[1].rotation.set(-p[5 + i * 3], 0, 0)
630
+ js[2].rotation.set(-p[6 + i * 3], 0, 0)
631
+ })
632
+ /* thumb */
633
+ const [ab, fl, op, mcp, ip] = [p[16], p[17], p[18], p[19], p[20]]
634
+ const tq = this.thumbBase.clone()
635
+ .multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(-fl * 0.8, op, ab, 'ZXY')))
636
+ this.thumbJoints[0].quaternion.copy(tq)
637
+ this.thumbJoints[1].rotation.set(-mcp, 0, 0)
638
+ this.thumbJoints[2].rotation.set(-ip, 0, 0)
639
+
640
+ this.scene.updateMatrixWorld(true)
641
+
642
+ /* tendon paths */
643
+ const F = this.forearm, Wr = this.wrist, T = this.thumbJoints
644
+ const excursion = fl * 0.12 + mcp * 0.05 + ip * 0.03 + p[1] * 0.16 - ab * 0.05 + (s.slide ?? 0)
645
+ 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)]
646
+ for (const td of this.tendons) {
647
+ let pts: THREE.Vector3[]
648
+ let rad = (u: number) => 0.02
649
+ if (td.kind === 'apl' || td.kind === 'epb') {
650
+ const o = td.kind === 'apl' ? -0.022 : 0.022
651
+ pts = [
652
+ 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),
653
+ 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),
654
+ this.pt(Wr, -0.29 + o * 0.4, 0.13, 0.06 + o * 0.5),
655
+ this.pt(T[0], 0.0 + o * 0.5, -0.02, 0.07),
656
+ ]
657
+ if (td.kind === 'apl') pts.push(this.pt(T[0], -0.005, 0.07, 0.075))
658
+ 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))
659
+ const r0 = 0.021
660
+ rad = (u: number) => r0 * (u < 0.15 ? 1.6 - u * 4 : 1) * (u > 0.94 ? (1 - (u - 0.94) * 8) : 1)
661
+ } else if (td.kind === 'epl') {
662
+ 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),
663
+ 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)]
664
+ rad = (u) => 0.016 * (u > 0.95 ? 0.6 : 1)
665
+ } else {
666
+ const i = td.idx!, f = FINGERS[i], J = this.fingerJoints[i]
667
+ const hx = f.head[0], bx = f.base[0]
668
+ 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),
669
+ 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),
670
+ 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)]
671
+ rad = (u) => 0.016 * (u > 0.9 ? 0.75 : 1)
672
+ }
673
+ td.tube.update(pts, rad)
674
+ ;(td.mesh.material as THREE.ShaderMaterial).uniforms.uSlide.value = td.kind === 'ext' ? 0 : excursion * (td.kind === 'epb' ? 1.15 : 1)
675
+ }
676
+ // sheath
677
+ const th = s.thick
678
+ this.sheathTube.update(sheathPts, (u) => {
679
+ const bell = Math.exp(-Math.pow((u - 0.5) / 0.2, 2))
680
+ const ends = Math.min(1, u * 9, (1 - u) * 9)
681
+ return (0.05 + 0.012 * ends + th * 0.026 * bell) * (0.75 + 0.25 * ends)
682
+ })
683
+
684
+ /* materials */
685
+ const painW = this.pt(F, ...PAIN_LOCAL)
686
+ this.anchors.pain = painW
687
+ this.anchors.sheath = this.pt(F, -0.33, -0.3, 0.1)
688
+ this.anchors.tendon = this.pt(F, -0.22, -0.95, 0.17)
689
+ this.anchors.bone = this.pt(F, -0.14, -1.2, 0)
690
+ this.anchors.thumb = this.pt(T[2], 0, 0.2, 0)
691
+ this.anchors.thumbBase = this.pt(T[0], 0, 0.1, 0)
692
+ this.anchors.wrist = this.pt(Wr, 0, 0.1, 0)
693
+ this.anchors.palm = this.pt(Wr, 0, 0.6, 0)
694
+ this.anchors.narrow = this.pt(F, -0.31, -0.17, 0.08)
695
+
696
+ const su = this.skinMat.uniforms
697
+ 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
698
+ const skinVisible = s.skin > 0.003
699
+ this.scene.traverse((o) => {
700
+ const m = o as THREE.Mesh
701
+ if (m.material === this.skinMat || m.material === this.skinDepth) m.visible = skinVisible
702
+ 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 }
703
+ })
704
+
705
+ const setReveal = (mat: THREE.ShaderMaterial, r: number, maxR: number) => {
706
+ mat.uniforms.uRC.value.copy(painW)
707
+ mat.uniforms.uRR.value = r >= 0.999 ? 100 : r * maxR
708
+ }
709
+ for (const m of this.boneMats) setReveal(m, s.bone, 3.4)
710
+ setReveal(this.muscleMat, s.bone, 3.4)
711
+ setReveal(this.tendonMat, s.tendon, 3.4)
712
+ setReveal(this.extTendonMat, s.tendon, 3.4)
713
+ setReveal(this.sheathMat, s.sheath, 1.2)
714
+ const showIn = s.xray > 0.01 || s.skin < 0.99
715
+ this.boneMats.forEach((m) => { m.visible = showIn && s.bone > 0.001 })
716
+ this.muscleMat.visible = showIn && s.bone > 0.001
717
+ this.tendonMat.visible = this.extTendonMat.visible = showIn && s.tendon > 0.001
718
+ this.sheathMat.visible = showIn && s.sheath > 0.001
719
+ const focus = s.focus ?? 0
720
+ this.boneMat.uniforms.uDim.value = focus * 0.75; this.cartMat.uniforms.uDim.value = focus * 0.75
721
+ this.muscleMat.uniforms.uDim.value = 0.55 + focus * 0.3
722
+ this.extTendonMat.uniforms.uDim.value = focus * 0.8
723
+ this.tendonMat.uniforms.uDim.value = 0
724
+ for (const m of [this.tendonMat, this.extTendonMat]) { m.uniforms.uTime.value = t }
725
+ const tu = this.tendonMat.uniforms
726
+ tu.uHot.value = s.friction; tu.uHotPos.value.copy(this.pt(F, -0.303, -0.17, 0.06))
727
+ this.sheathMat.uniforms.uThick.value = th; this.sheathMat.uniforms.uTime.value = t
728
+
729
+ /* pain sprites */
730
+ const pulse = 0.78 + 0.22 * Math.sin(t * 7.0)
731
+ const pv = s.pain
732
+ this.painCore.visible = this.painHalo.visible = pv > 0.01
733
+ ;(this.painCore.material as THREE.SpriteMaterial).opacity = pv * pulse
734
+ ;(this.painHalo.material as THREE.SpriteMaterial).opacity = pv * 0.55 * (0.8 + 0.2 * Math.sin(t * 7 + 1))
735
+ this.painCore.scale.setScalar(0.22 + 0.06 * pv * pulse)
736
+ this.painHalo.scale.setScalar(0.55 + 0.25 * pv * pulse)
737
+
738
+ /* props */
739
+ for (const [name, obj] of Object.entries(this.props)) {
740
+ if (name === 'muscles') continue
741
+ const ps = s.props[name]
742
+ obj.visible = !!ps && ps.vis > 0.01
743
+ if (!ps || !obj.visible) continue
744
+ obj.traverse((o) => {
745
+ const mm = (o as THREE.Mesh).material as THREE.Material & { opacity: number; userData: { baseOp?: number } }
746
+ if (!mm || !('opacity' in mm)) return
747
+ if (mm.userData.baseOp === undefined) mm.userData.baseOp = mm.opacity
748
+ mm.transparent = true
749
+ mm.opacity = mm.userData.baseOp * ps.vis
750
+ })
751
+ this.applyProp(name, obj, ps, t)
752
+ }
753
+ this.scene.updateMatrixWorld(true)
754
+ const kn = this.props.knife
755
+ this.anchors.knife = kn.localToWorld(new THREE.Vector3(0, 0.95, 0))
756
+ this.anchors.knifeTip = kn.localToWorld(new THREE.Vector3(0, 0.0, 0))
757
+ this.anchors.syringe = this.props.syringe.localToWorld(new THREE.Vector3(0, 0.6, 0))
758
+ this.anchors.probe = this.props.probe.localToWorld(new THREE.Vector3(0, 0.3, 0))
759
+ this.anchors.splint = this.props.splint.localToWorld(new THREE.Vector3(0, -0.5, 0.3))
760
+ const loc = s.props.locator
761
+ this.locator.visible = !!loc && loc.vis > 0.01
762
+ if (loc) { (this.locator.material as THREE.SpriteMaterial).opacity = loc.vis; this.locator.scale.setScalar(0.12 + 0.03 * Math.sin(t * 8)) }
763
+ }
764
+
765
+ private applyProp(name: string, obj: THREE.Object3D, ps: PropState, t: number) {
766
+ const P = new THREE.Vector3(...PAIN_LOCAL)
767
+ const out = new THREE.Vector3(-0.72, 0.22, 0.66).normalize()
768
+ if (name === 'knife') {
769
+ const d = ps.depth ?? 0.8 // distance of tip from target along out dir
770
+ const wig = (ps.release ?? 0) * Math.sin(t * 22) * 0.025
771
+ const along = new THREE.Vector3(0, 1, 0)
772
+ obj.position.copy(P).addScaledVector(out, d).addScaledVector(along, wig).add(new THREE.Vector3(-0.005, 0.0, 0.02))
773
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), out)
774
+ obj.rotateY(Math.PI / 2)
775
+ } else if (name === 'syringe') {
776
+ const d = ps.depth ?? 0.8
777
+ const dir = new THREE.Vector3(-0.6, 0.35, 0.72).normalize()
778
+ obj.position.copy(P).addScaledVector(dir, d + 0.06)
779
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir)
780
+ const pl = obj.children[4], pc = obj.children[5], lq = obj.children[1]
781
+ const push = ps.push ?? 0
782
+ pl.position.y = 0.95 - push * 0.22; pc.position.y = 1.2 - push * 0.22
783
+ lq.scale.y = 1 - push * 0.8; lq.position.y = 0.48 - push * 0.12
784
+ } else if (name === 'swab') {
785
+ const a = t * 5
786
+ const r = (ps.r ?? 0.1)
787
+ const n = out
788
+ const tx = new THREE.Vector3(0, 1, 0).cross(n).normalize(), ty = n.clone().cross(tx)
789
+ obj.position.copy(P).addScaledVector(n, 0.11).addScaledVector(tx, Math.cos(a) * r).addScaledVector(ty, Math.sin(a) * r)
790
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), n.clone().add(new THREE.Vector3(0, 0.3, 0)).normalize())
791
+ } else if (name === 'iodine') {
792
+ obj.position.copy(P).addScaledVector(out, 0.105)
793
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), out)
794
+ obj.scale.setScalar(Math.max(0.001, ps.r ?? 0.2))
795
+ } else if (name === 'cloud') {
796
+ obj.position.copy(P).addScaledVector(out, 0.04)
797
+ obj.scale.setScalar(Math.max(0.001, ps.r ?? 0.1))
798
+ } else if (name === 'ring') {
799
+ obj.position.copy(P)
800
+ obj.rotation.set(Math.PI / 2, 0, 0)
801
+ obj.scale.setScalar(1 + (ps.r ?? 0) * 0.8)
802
+ } else if (name === 'probe') {
803
+ obj.position.copy(P).addScaledVector(out, 0.12 + (ps.lift ?? 0)).add(new THREE.Vector3(0, ps.slideY ?? 0, 0))
804
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), out)
805
+ } else if (name === 'bottle') {
806
+ const inner = obj.userData.inner as THREE.Object3D
807
+ inner.rotation.y = -(ps.twist ?? 0)
808
+ } else if (name === 'bead') {
809
+ // between thumb tip & index tip
810
+ const a = this.thumbJoints[2].children[0]?.parent ? this.pt(this.thumbJoints[2], 0, 0.3, 0) : new THREE.Vector3()
811
+ const b = this.pt(this.fingerJoints[0][2], 0, 0.22, 0)
812
+ const mid = a.add(b).multiplyScalar(0.5)
813
+ obj.position.copy(this.wrist.worldToLocal(mid))
814
+ obj.rotation.set(0.3, 0, 1.2)
815
+ } else if (name === 'keyboard') {
816
+ obj.position.set(ps.x ?? 0, ps.y ?? 0, ps.z ?? 0)
817
+ obj.rotation.set(ps.rx ?? 0, ps.ry ?? 0, ps.rz ?? 0)
818
+ }
819
+ }
820
+
821
+ /** project world point → normalized screen (0..1) */
822
+ project(p: THREE.Vector3, cam: THREE.Camera) {
823
+ const v = p.clone().project(cam)
824
+ return { x: (v.x + 1) / 2, y: (1 - v.y) / 2, z: v.z }
825
+ }
826
+ }
src/video/overlay.ts ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { clamp, smooth, backOut, hash } from './math'
2
+
3
+ export const FONT = '"PingFang SC","Noto Sans SC","Source Han Sans SC","Microsoft YaHei","Hiragino Sans GB",sans-serif'
4
+ type C = CanvasRenderingContext2D
5
+
6
+ export const COL = {
7
+ cyan: '#5fd4ff', teal: '#2fe0c0', red: '#ff3b3b', yellow: '#ffd23f', white: '#ffffff', green: '#3ee08f',
8
+ }
9
+
10
+ function rr(ctx: C, x: number, y: number, w: number, h: number, r: number) {
11
+ ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r)
12
+ ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath()
13
+ }
14
+
15
+ /* ---------------- subtitle ---------------- */
16
+ export function subtitle(ctx: C, text: string, alpha: number, y = 1555) {
17
+ if (alpha <= 0.01 || !text) return
18
+ ctx.save()
19
+ ctx.globalAlpha = alpha
20
+ ctx.font = `800 58px ${FONT}`
21
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
22
+ const lines = wrap(ctx, text, 940)
23
+ const lh = 80
24
+ lines.forEach((l, i) => {
25
+ const yy = y + (i - (lines.length - 1) / 2) * lh + (1 - alpha) * 10
26
+ ctx.lineJoin = 'round'; ctx.lineWidth = 12; ctx.strokeStyle = 'rgba(0,8,20,0.85)'; ctx.strokeText(l, 540, yy)
27
+ ctx.fillStyle = '#ffffff'; ctx.fillText(l, 540, yy)
28
+ })
29
+ ctx.restore()
30
+ }
31
+ export function wrap(ctx: C, text: string, maxW: number) {
32
+ if (ctx.measureText(text).width <= maxW) return [text]
33
+ // split at punctuation near the middle when possible
34
+ const chars = [...text]; const lines: string[] = []; let cur = ''
35
+ for (const ch of chars) {
36
+ if (ctx.measureText(cur + ch).width > maxW) { lines.push(cur); cur = ch } else cur += ch
37
+ }
38
+ if (cur) lines.push(cur)
39
+ if (lines.length === 2) {
40
+ const mid = Math.floor(chars.length / 2)
41
+ let best = -1
42
+ for (let d = 0; d < 6; d++) for (const k of [mid + d, mid - d]) if (best < 0 && ',、:;'.includes(chars[k] ?? '')) best = k
43
+ if (best > 0) return [chars.slice(0, best + 1).join(''), chars.slice(best + 1).join('')]
44
+ }
45
+ return lines
46
+ }
47
+
48
+ /* ---------------- chips / keywords ---------------- */
49
+ export interface ChipOpt { alpha?: number; scale?: number; color?: string; size?: number; icon?: 'check' | 'cross' | 'warn' | 'dot' | 'num'; num?: number; fill?: boolean; strike?: number }
50
+ export function chip(ctx: C, x: number, y: number, text: string, o: ChipOpt = {}) {
51
+ const a = o.alpha ?? 1; if (a <= 0.01) return
52
+ const size = o.size ?? 46, color = o.color ?? COL.cyan
53
+ ctx.save()
54
+ ctx.globalAlpha = a
55
+ ctx.translate(x, y); const sc = o.scale ?? 1; ctx.scale(sc, sc)
56
+ ctx.font = `800 ${size}px ${FONT}`
57
+ const tw = ctx.measureText(text).width
58
+ const iconW = o.icon ? size * 1.05 : 0
59
+ const w = tw + iconW + size * 1.1, h = size * 1.7
60
+ ctx.shadowColor = color; ctx.shadowBlur = 24
61
+ rr(ctx, -w / 2, -h / 2, w, h, h / 2)
62
+ ctx.fillStyle = o.fill ? color : 'rgba(6,18,36,0.78)'; ctx.fill()
63
+ ctx.shadowBlur = 0
64
+ ctx.lineWidth = 3; ctx.strokeStyle = color; ctx.stroke()
65
+ const tx = -w / 2 + size * 0.55 + iconW
66
+ if (o.icon) icon(ctx, o.icon, -w / 2 + size * 0.55 + size * 0.42, 0, size * 0.8, o.fill ? '#061224' : color, o.num)
67
+ ctx.fillStyle = o.fill ? '#061224' : '#fff'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'
68
+ ctx.fillText(text, tx, 2)
69
+ if (o.strike) {
70
+ ctx.strokeStyle = COL.red; ctx.lineWidth = 7; ctx.lineCap = 'round'
71
+ ctx.beginPath(); ctx.moveTo(-w / 2 + 10, 0); ctx.lineTo(-w / 2 + 10 + (w - 20) * clamp(o.strike), 0); ctx.stroke()
72
+ }
73
+ ctx.restore()
74
+ }
75
+
76
+ export function icon(ctx: C, kind: string, x: number, y: number, s: number, color: string, num?: number) {
77
+ ctx.save(); ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = s * 0.13; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
78
+ if (kind === 'check') { ctx.beginPath(); ctx.moveTo(-s * 0.35, 0); ctx.lineTo(-s * 0.08, s * 0.28); ctx.lineTo(s * 0.4, -s * 0.3); ctx.stroke() }
79
+ else if (kind === 'cross') { ctx.beginPath(); ctx.moveTo(-s * 0.3, -s * 0.3); ctx.lineTo(s * 0.3, s * 0.3); ctx.moveTo(s * 0.3, -s * 0.3); ctx.lineTo(-s * 0.3, s * 0.3); ctx.stroke() }
80
+ else if (kind === 'warn') { ctx.beginPath(); ctx.moveTo(0, -s * 0.42); ctx.lineTo(s * 0.45, s * 0.38); ctx.lineTo(-s * 0.45, s * 0.38); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -s * 0.1); ctx.lineTo(0, s * 0.12); ctx.stroke(); ctx.beginPath(); ctx.arc(0, s * 0.25, s * 0.04, 0, 7); ctx.fill() }
81
+ else if (kind === 'dot') { ctx.beginPath(); ctx.arc(0, 0, s * 0.22, 0, 7); ctx.fill() }
82
+ else if (kind === 'num') { ctx.beginPath(); ctx.arc(0, 0, s * 0.45, 0, 7); ctx.fill(); ctx.fillStyle = '#061224'; ctx.font = `900 ${s * 0.6}px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(num ?? 1), 0, s * 0.03) }
83
+ ctx.restore()
84
+ }
85
+
86
+ /** big centered keyword with pop-in */
87
+ export function bigWord(ctx: C, text: string, x: number, y: number, p: number, color = COL.red, size = 92) {
88
+ if (p <= 0.01) return
89
+ ctx.save()
90
+ const sc = 0.6 + 0.4 * backOut(Math.min(1, p * 1.6))
91
+ ctx.globalAlpha = clamp(p * 2)
92
+ ctx.translate(x, y); ctx.scale(sc, sc)
93
+ ctx.font = `900 ${size}px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
94
+ ctx.shadowColor = color; ctx.shadowBlur = 40
95
+ ctx.lineWidth = 14; ctx.strokeStyle = 'rgba(0,6,16,0.9)'; ctx.lineJoin = 'round'; ctx.strokeText(text, 0, 0)
96
+ ctx.fillStyle = color; ctx.fillText(text, 0, 0)
97
+ ctx.shadowBlur = 0; ctx.fillStyle = '#fff'; ctx.globalAlpha *= 0.25; ctx.fillText(text, 0, -3)
98
+ ctx.restore()
99
+ }
100
+
101
+ /* ---------------- labels with leader lines ---------------- */
102
+ export function label(ctx: C, ax: number, ay: number, tx: number, ty: number, text: string, alpha: number, color = COL.cyan, size = 40) {
103
+ if (alpha <= 0.01) return
104
+ ctx.save(); ctx.globalAlpha = alpha
105
+ const p = smooth(alpha)
106
+ const ex = ax + (tx - ax) * p, ey = ay + (ty - ay) * p
107
+ ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.shadowColor = color; ctx.shadowBlur = 10
108
+ ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(ex, ey); ctx.stroke()
109
+ ctx.fillStyle = color; ctx.beginPath(); ctx.arc(ax, ay, 8, 0, 7); ctx.fill()
110
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(ax, ay, 14 + Math.sin(alpha * 6) * 2, 0, 7); ctx.stroke()
111
+ ctx.shadowBlur = 0
112
+ ctx.font = `800 ${size}px ${FONT}`
113
+ const w = ctx.measureText(text).width + 36, h = size * 1.5
114
+ const left = tx < ax
115
+ const bx = left ? ex - w : ex
116
+ rr(ctx, bx, ey - h / 2, w, h, 12); ctx.fillStyle = 'rgba(4,14,30,0.85)'; ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.stroke()
117
+ ctx.fillStyle = '#fff'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, bx + 18, ey + 2)
118
+ ctx.restore()
119
+ }
120
+
121
+ /* ---------------- reticle ---------------- */
122
+ export function reticle(ctx: C, x: number, y: number, r: number, alpha: number, t: number, color = COL.red, lock = 1) {
123
+ if (alpha <= 0.01) return
124
+ ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y)
125
+ const R = r * (1 + (1 - lock) * 1.4)
126
+ ctx.rotate((1 - lock) * 1.2 + t * 0.3)
127
+ ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.shadowColor = color; ctx.shadowBlur = 16
128
+ for (let i = 0; i < 4; i++) {
129
+ ctx.rotate(Math.PI / 2)
130
+ ctx.beginPath(); ctx.moveTo(R, R * 0.45); ctx.lineTo(R, R); ctx.lineTo(R * 0.45, R); ctx.stroke()
131
+ }
132
+ ctx.rotate(-t * 0.3 - (1 - lock) * 1.2)
133
+ ctx.lineWidth = 2; ctx.setLineDash([8, 10]); ctx.beginPath(); ctx.arc(0, 0, R * 0.72, t, t + Math.PI * 2); ctx.stroke(); ctx.setLineDash([])
134
+ ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(0, -14); ctx.lineTo(0, 14); ctx.stroke()
135
+ ctx.restore()
136
+ }
137
+
138
+ /* ---------------- pain burst ---------------- */
139
+ export function painBurst(ctx: C, x: number, y: number, p: number, t: number) {
140
+ if (p <= 0.01) return
141
+ ctx.save(); ctx.translate(x, y); ctx.globalCompositeOperation = 'lighter'
142
+ for (let k = 0; k < 3; k++) {
143
+ const ph = ((t * 1.3 + k / 3) % 1)
144
+ ctx.strokeStyle = `rgba(255,60,50,${(1 - ph) * 0.6 * p})`; ctx.lineWidth = 6 * (1 - ph) + 1
145
+ ctx.beginPath(); ctx.arc(0, 0, 40 + ph * 170, 0, Math.PI * 2); ctx.stroke()
146
+ }
147
+ ctx.strokeStyle = `rgba(255,90,70,${0.9 * p})`; ctx.lineWidth = 7; ctx.lineCap = 'round'
148
+ for (let i = 0; i < 9; i++) {
149
+ const a = i / 9 * Math.PI * 2 + 0.3, j = 0.6 + 0.4 * Math.sin(t * 20 + i * 3)
150
+ const r1 = 70 + 20 * j, r2 = r1 + 50 * p * j
151
+ ctx.beginPath(); ctx.moveTo(Math.cos(a) * r1, Math.sin(a) * r1); ctx.lineTo(Math.cos(a) * r2, Math.sin(a) * r2); ctx.stroke()
152
+ }
153
+ ctx.restore()
154
+ }
155
+
156
+ /* ---------------- cross-section inset (sheath + tendons) ---------------- */
157
+ export function crossSection(ctx: C, cx: number, cy: number, R: number, thick: number, friction: number, alpha: number, t: number, title = '横截面示意') {
158
+ if (alpha <= 0.01) return
159
+ ctx.save(); ctx.globalAlpha = alpha; ctx.translate(cx, cy)
160
+ const box = R * 1.5
161
+ rr(ctx, -box, -box - 50, box * 2, box * 2 + 70, 26); ctx.fillStyle = 'rgba(4,14,30,0.82)'; ctx.fill()
162
+ ctx.strokeStyle = 'rgba(95,212,255,0.7)'; ctx.lineWidth = 2.5; ctx.stroke()
163
+ ctx.font = `800 30px ${FONT}`; ctx.fillStyle = COL.cyan; ctx.textAlign = 'center'; ctx.fillText(title, 0, -box - 12)
164
+ // sheath ring
165
+ const outer = R * (1 + thick * 0.22), inner = R * (0.82 - thick * 0.3)
166
+ const sc = `rgb(${Math.round(140 + 115 * thick)},${Math.round(200 - 120 * thick)},${Math.round(255 - 170 * thick)})`
167
+ ctx.fillStyle = sc; ctx.globalAlpha = alpha * 0.85
168
+ ctx.beginPath(); ctx.arc(0, 10, outer, 0, Math.PI * 2); ctx.arc(0, 10, inner, 0, Math.PI * 2, true); ctx.fill('evenodd')
169
+ ctx.globalAlpha = alpha
170
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 10, inner, 0, 7); ctx.stroke()
171
+ // space (synovial fluid) – shrinking
172
+ ctx.fillStyle = 'rgba(70,170,255,0.25)'; ctx.beginPath(); ctx.arc(0, 10, inner - 2, 0, 7); ctx.fill()
173
+ // two tendons
174
+ const tr = R * 0.3, wob = Math.sin(t * 5) * 2
175
+ const hot = friction
176
+ for (const dx of [-tr * 1.05, tr * 1.05]) {
177
+ const g = ctx.createRadialGradient(dx - 6, 4 + wob, 2, dx, 10 + wob, tr)
178
+ g.addColorStop(0, '#ffffff'); g.addColorStop(1, hot > 0.1 ? `rgb(255,${Math.round(230 - 170 * hot)},${Math.round(200 - 170 * hot)})` : '#e6dfcf')
179
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(dx, 10 + wob, tr, 0, 7); ctx.fill()
180
+ ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1.5
181
+ for (let k = -2; k <= 2; k++) { ctx.beginPath(); ctx.arc(dx + k * 6, 10 + wob, tr * 0.18, 0, 7); ctx.stroke() }
182
+ }
183
+ if (hot > 0.05) {
184
+ ctx.globalCompositeOperation = 'lighter'
185
+ const g = ctx.createRadialGradient(0, 10, inner * 0.5, 0, 10, outer)
186
+ g.addColorStop(0, 'rgba(255,40,30,0)'); g.addColorStop(0.7, `rgba(255,60,40,${0.5 * hot})`); g.addColorStop(1, 'rgba(255,40,30,0)')
187
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 10, outer * 1.1, 0, 7); ctx.fill()
188
+ ctx.globalCompositeOperation = 'source-over'
189
+ }
190
+ // gap arrows
191
+ const gap = inner - tr * 2.05
192
+ ctx.font = `700 24px ${FONT}`; ctx.fillStyle = '#cfefff'; ctx.textAlign = 'center'
193
+ ctx.fillText(gap > R * 0.12 ? '间隙充足' : '间隙变窄', 0, R * 1.45)
194
+ ctx.restore()
195
+ }
196
+
197
+ /* ---------------- ultrasound ---------------- */
198
+ let speckle: HTMLCanvasElement | null = null
199
+ function getSpeckle() {
200
+ if (speckle) return speckle
201
+ const c = document.createElement('canvas'); c.width = 360; c.height = 300
202
+ const g = c.getContext('2d')!; const im = g.createImageData(360, 300)
203
+ for (let i = 0; i < 360 * 300; i++) {
204
+ const v = Math.pow(hash(i * 1.37 + 0.5), 2.2) * 120
205
+ im.data[i * 4] = im.data[i * 4 + 1] = im.data[i * 4 + 2] = v; im.data[i * 4 + 3] = 255
206
+ }
207
+ g.putImageData(im, 0, 0)
208
+ speckle = c; return c
209
+ }
210
+ export interface USOpt { alpha: number; thick: number; locator?: number; labels?: number; knife?: number; release?: number; narrowMark?: number; title?: string }
211
+ export function ultrasound(ctx: C, x: number, y: number, w: number, h: number, t: number, o: USOpt) {
212
+ if (o.alpha <= 0.01) return
213
+ ctx.save(); ctx.globalAlpha = o.alpha
214
+ rr(ctx, x - 14, y - 64, w + 28, h + 84, 24); ctx.fillStyle = 'rgba(2,8,16,0.94)'; ctx.fill()
215
+ ctx.strokeStyle = 'rgba(95,212,255,0.6)'; ctx.lineWidth = 2.5; ctx.stroke()
216
+ ctx.font = `800 30px ${FONT}`; ctx.fillStyle = COL.cyan; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'
217
+ ctx.fillText(o.title ?? '超声检查 · 示意', x + 6, y - 32)
218
+ ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = `600 22px ${FONT}`; ctx.textAlign = 'right'
219
+ ctx.fillText(`B-MODE 12MHz ${(t % 60).toFixed(1)}s`, x + w - 6, y - 32)
220
+ // field
221
+ ctx.save(); rr(ctx, x, y, w, h, 10); ctx.clip()
222
+ ctx.fillStyle = '#000'; ctx.fillRect(x, y, w, h)
223
+ const sp = getSpeckle()
224
+ ctx.globalAlpha = o.alpha * 0.9
225
+ ctx.drawImage(sp, (t * 7) % 40, 0, 320, 300, x, y, w, h)
226
+ ctx.globalAlpha = o.alpha
227
+ // layers: skin (bright line), subcut, sheath (hypoechoic halo), tendons (fibrillar bright), bone (bright line + shadow)
228
+ const cy = y + h * 0.48, cx = x + w * 0.5
229
+ ctx.fillStyle = 'rgba(230,230,230,0.55)'; ctx.fillRect(x, y + h * 0.06, w, 5)
230
+ // sheath halo (dark) thickness grows with thick
231
+ const sh = h * (0.12 + o.thick * 0.1)
232
+ const narrowW = w * 0.32
233
+ ctx.fillStyle = 'rgba(0,0,0,0.75)'
234
+ ctx.beginPath(); ctx.ellipse(cx, cy, w * 0.46, h * 0.1 + sh * 0.6, 0, 0, 7); ctx.fill()
235
+ // tendon fibrillar pattern (narrowed in the middle)
236
+ for (let i = 0; i < 9; i++) {
237
+ const yy = cy - h * 0.07 + i * h * 0.0175
238
+ ctx.strokeStyle = `rgba(240,240,240,${0.35 + 0.35 * hash(i)})`; ctx.lineWidth = 2.2
239
+ ctx.beginPath()
240
+ for (let k = 0; k <= 40; k++) {
241
+ const xx = x + w * 0.06 + (w * 0.88) * k / 40
242
+ const dn = Math.exp(-Math.pow((xx - cx) / (narrowW * 0.5), 2)) * o.thick * (o.release ? 1 - o.release * 0.7 : 1)
243
+ const yv = cy + (yy - cy) * (1 - dn * 0.35) + Math.sin(k * 1.7 + i) * 1.2
244
+ if (k === 0) ctx.moveTo(xx, yv); else ctx.lineTo(xx, yv)
245
+ }
246
+ ctx.stroke()
247
+ }
248
+ // bone
249
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 8
250
+ ctx.beginPath(); ctx.moveTo(x, y + h * 0.8); ctx.quadraticCurveTo(cx, y + h * 0.7, x + w, y + h * 0.82); ctx.stroke()
251
+ const gsh = ctx.createLinearGradient(0, y + h * 0.8, 0, y + h); gsh.addColorStop(0, 'rgba(0,0,0,0.6)'); gsh.addColorStop(1, 'rgba(0,0,0,0.95)')
252
+ ctx.fillStyle = gsh; ctx.fillRect(x, y + h * 0.8, w, h * 0.2)
253
+ // knife echo line
254
+ if (o.knife && o.knife > 0) {
255
+ const kx0 = x + w * 0.1, ky0 = y + h * 0.04
256
+ const kx1 = kx0 + (cx - kx0) * o.knife, ky1 = ky0 + (cy - h * 0.05 - ky0) * o.knife
257
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 5; ctx.shadowColor = '#fff'; ctx.shadowBlur = 12
258
+ ctx.beginPath(); ctx.moveTo(kx0, ky0); ctx.lineTo(kx1, ky1); ctx.stroke(); ctx.shadowBlur = 0
259
+ }
260
+ if (o.narrowMark && o.narrowMark > 0) {
261
+ ctx.globalAlpha = o.alpha * o.narrowMark
262
+ ctx.strokeStyle = COL.red; ctx.lineWidth = 4; ctx.setLineDash([12, 8])
263
+ ctx.beginPath(); ctx.ellipse(cx, cy, narrowW * 0.62, h * 0.2, 0, 0, 7); ctx.stroke(); ctx.setLineDash([])
264
+ ctx.globalAlpha = o.alpha
265
+ }
266
+ if (o.locator && o.locator > 0) {
267
+ ctx.globalAlpha = o.alpha * o.locator
268
+ const r = 10 + Math.sin(t * 8) * 3
269
+ ctx.fillStyle = '#fff'; ctx.shadowColor = '#fff'; ctx.shadowBlur = 25
270
+ ctx.beginPath(); ctx.arc(cx, cy - h * 0.05, r, 0, 7); ctx.fill(); ctx.shadowBlur = 0
271
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 2
272
+ ctx.beginPath(); ctx.arc(cx, cy - h * 0.05, r + 18, 0, 7); ctx.stroke()
273
+ ctx.globalAlpha = o.alpha
274
+ }
275
+ // depth ruler
276
+ ctx.fillStyle = 'rgba(255,255,255,0.6)'
277
+ for (let i = 0; i < 8; i++) ctx.fillRect(x + w - 16, y + 10 + i * (h - 20) / 7, i % 2 ? 6 : 12, 2)
278
+ ctx.restore()
279
+ // labels
280
+ const L = o.labels ?? 0
281
+ if (L > 0) {
282
+ const ls = [
283
+ { t: '肌腱', ax: x + w * 0.2, ay: cy, tx: x + w * 0.08, ty: y + h + 50, c: COL.white, d: 0 },
284
+ { t: '腱鞘', ax: x + w * 0.78, ay: cy - h * 0.13, tx: x + w * 0.66, ty: y + h + 50, c: COL.cyan, d: 0.25 },
285
+ { t: '狭窄位置', ax: cx, ay: cy + h * 0.08, tx: x + w * 0.36, ty: y + h + 50, c: COL.red, d: 0.5 },
286
+ ]
287
+ for (const l of ls) {
288
+ const a = clamp((L - l.d) * 2.5)
289
+ if (a <= 0) continue
290
+ ctx.save(); ctx.globalAlpha = o.alpha * a
291
+ ctx.strokeStyle = l.c; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(l.ax, l.ay); ctx.lineTo(l.ax, l.ty - 26); ctx.stroke()
292
+ ctx.fillStyle = l.c; ctx.beginPath(); ctx.arc(l.ax, l.ay, 7, 0, 7); ctx.fill()
293
+ ctx.font = `800 34px ${FONT}`; ctx.textAlign = 'center'; ctx.fillStyle = l.c; ctx.fillText(l.t, l.ax, l.ty)
294
+ ctx.restore()
295
+ }
296
+ }
297
+ ctx.restore()
298
+ }
299
+
300
+ /* ---------------- step bar ---------------- */
301
+ export function steps(ctx: C, list: string[], active: number, alpha: number, y = 260) {
302
+ if (alpha <= 0.01) return
303
+ ctx.save(); ctx.globalAlpha = alpha
304
+ const n = list.length, gap = 230, x0 = 540 - (gap * (n - 1)) / 2
305
+ ctx.strokeStyle = 'rgba(95,212,255,0.35)'; ctx.lineWidth = 4
306
+ ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + gap * (n - 1), y); ctx.stroke()
307
+ const prog = clamp(active / (n - 1))
308
+ ctx.strokeStyle = COL.teal; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + gap * (n - 1) * prog, y); ctx.stroke()
309
+ list.forEach((s, i) => {
310
+ const x = x0 + i * gap
311
+ const on = active >= i - 0.01, cur = Math.abs(active - i) < 0.5
312
+ ctx.fillStyle = on ? COL.teal : '#0b2238'; ctx.strokeStyle = on ? COL.teal : 'rgba(95,212,255,0.5)'; ctx.lineWidth = 3
313
+ ctx.beginPath(); ctx.arc(x, y, cur ? 30 : 24, 0, 7); ctx.fill(); ctx.stroke()
314
+ ctx.fillStyle = on ? '#04121f' : '#9fd8ff'; ctx.font = `900 28px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
315
+ ctx.fillText(String(i + 1), x, y + 1)
316
+ ctx.fillStyle = cur ? '#fff' : 'rgba(255,255,255,0.65)'; ctx.font = `800 ${cur ? 36 : 30}px ${FONT}`
317
+ ctx.fillText(s, x, y + 62)
318
+ })
319
+ ctx.restore()
320
+ }
321
+
322
+ /* ---------------- header / footer ---------------- */
323
+ export function header(ctx: C, idx: number, title: string, alpha: number) {
324
+ ctx.save()
325
+ // persistent brandless series tag
326
+ ctx.globalAlpha = 0.9
327
+ ctx.font = `800 30px ${FONT}`; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'
328
+ rr(ctx, 48, 120, 300, 54, 27); ctx.fillStyle = 'rgba(4,14,30,0.6)'; ctx.fill(); ctx.strokeStyle = 'rgba(95,212,255,0.45)'; ctx.lineWidth = 2; ctx.stroke()
329
+ ctx.fillStyle = COL.red; ctx.beginPath(); ctx.arc(76, 147, 8, 0, 7); ctx.fill()
330
+ ctx.fillStyle = '#dff4ff'; ctx.fillText('腱鞘炎 · 3D 解剖', 96, 148)
331
+ if (alpha > 0.01) {
332
+ ctx.globalAlpha = alpha
333
+ ctx.font = `900 40px ${FONT}`
334
+ const tw = ctx.measureText(title).width
335
+ const x = 48 - (1 - alpha) * 40
336
+ rr(ctx, x, 190, tw + 120, 66, 14); ctx.fillStyle = 'rgba(95,212,255,0.14)'; ctx.fill()
337
+ ctx.fillStyle = COL.cyan; ctx.fillRect(x, 190, 8, 66)
338
+ ctx.fillStyle = COL.cyan; ctx.font = `900 34px ${FONT}`; ctx.fillText(String(idx).padStart(2, '0'), x + 26, 224)
339
+ ctx.fillStyle = '#fff'; ctx.font = `900 40px ${FONT}`; ctx.fillText(title, x + 84, 224)
340
+ }
341
+ ctx.restore()
342
+ }
343
+ export function footer(ctx: C) {
344
+ ctx.save(); ctx.globalAlpha = 0.55
345
+ ctx.font = `600 24px ${FONT}`; ctx.fillStyle = '#cfe6ff'; ctx.textAlign = 'center'
346
+ ctx.fillText('原创3D医学示意动画 · 仅供科普,不替代面诊', 540, 1870)
347
+ ctx.restore()
348
+ }
349
+
350
+ /* ---------------- panel frame ---------------- */
351
+ export function panel(ctx: C, x: number, y: number, w: number, h: number, title: string, num: number, active: number, dim: number) {
352
+ ctx.save()
353
+ if (dim > 0.01) { ctx.fillStyle = `rgba(2,6,14,${dim * 0.6})`; ctx.fillRect(x, y, w, h) }
354
+ const c = active > 0.5 ? COL.teal : 'rgba(95,212,255,0.4)'
355
+ ctx.strokeStyle = c; ctx.lineWidth = active > 0.5 ? 6 : 2
356
+ if (active > 0.5) { ctx.shadowColor = COL.teal; ctx.shadowBlur = 20 }
357
+ rr(ctx, x + 6, y + 6, w - 12, h - 12, 22); ctx.stroke(); ctx.shadowBlur = 0
358
+ ctx.font = `900 40px ${FONT}`
359
+ const tw = ctx.measureText(title).width
360
+ rr(ctx, x + 22, y + h - 100, tw + 100, 70, 35); ctx.fillStyle = active > 0.5 ? COL.teal : 'rgba(4,14,30,0.85)'; ctx.fill()
361
+ icon(ctx, 'num', x + 62, y + h - 65, 46, active > 0.5 ? '#04121f' : COL.cyan, num)
362
+ ctx.fillStyle = active > 0.5 ? '#04121f' : '#fff'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'
363
+ ctx.fillText(title, x + 98, y + h - 63)
364
+ ctx.restore()
365
+ }
366
+
367
+ export function flash(ctx: C, a: number, color = '255,255,255') {
368
+ if (a <= 0.01) return
369
+ ctx.save(); ctx.fillStyle = `rgba(${color},${a})`; ctx.fillRect(0, 0, 1080, 1920); ctx.restore()
370
+ }
371
+
372
+ /** simple line icons for actions */
373
+ export function actionIcon(ctx: C, kind: 'grab' | 'pinch' | 'twist' | 'baby' | 'keyboard' | 'tool' | 'home' | 'comment', x: number, y: number, s: number, color: string, alpha = 1) {
374
+ if (alpha <= 0.01) return
375
+ ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = s * 0.07; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
376
+ const S = s / 2
377
+ if (kind === 'grab') { ctx.beginPath(); ctx.arc(0, 0, S * 0.55, 0, 7); ctx.stroke(); for (let i = 0; i < 4; i++) { const a = -2.4 + i * 0.45; ctx.beginPath(); ctx.arc(Math.cos(a) * S * 0.55, Math.sin(a) * S * 0.55, S * 0.16, 0, 7); ctx.fill() } }
378
+ else if (kind === 'pinch') { ctx.beginPath(); ctx.moveTo(-S * 0.7, -S * 0.5); ctx.quadraticCurveTo(0, -S * 0.2, -S * 0.05, -S * 0.05); ctx.moveTo(-S * 0.7, S * 0.5); ctx.quadraticCurveTo(0, S * 0.2, -S * 0.05, S * 0.05); ctx.stroke(); ctx.beginPath(); ctx.arc(S * 0.15, 0, S * 0.15, 0, 7); ctx.fill() }
379
+ else if (kind === 'twist') { ctx.beginPath(); ctx.arc(0, 0, S * 0.6, -2.6, 1.2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(S * 0.21, S * 0.56); ctx.lineTo(S * 0.5, S * 0.75); ctx.lineTo(S * 0.4, S * 0.4); ctx.stroke(); ctx.strokeRect(-S * 0.25, -S * 0.25, S * 0.5, S * 0.5) }
380
+ else if (kind === 'baby') { ctx.beginPath(); ctx.arc(-S * 0.35, -S * 0.15, S * 0.28, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(S * 0.2, S * 0.15, S * 0.55, S * 0.32, -0.3, 0, 7); ctx.stroke() }
381
+ else if (kind === 'keyboard') { ctx.strokeRect(-S * 0.75, -S * 0.35, S * 1.5, S * 0.7); for (let i = 0; i < 5; i++) ctx.fillRect(-S * 0.6 + i * S * 0.27, -S * 0.15, S * 0.12, S * 0.1) }
382
+ else if (kind === 'tool') { ctx.beginPath(); ctx.moveTo(-S * 0.6, S * 0.6); ctx.lineTo(S * 0.25, -S * 0.25); ctx.stroke(); ctx.fillRect(S * 0.05, -S * 0.7, S * 0.6, S * 0.35) }
383
+ else if (kind === 'home') { ctx.beginPath(); ctx.moveTo(-S * 0.6, 0); ctx.lineTo(0, -S * 0.6); ctx.lineTo(S * 0.6, 0); ctx.moveTo(-S * 0.45, -S * 0.1); ctx.lineTo(-S * 0.45, S * 0.55); ctx.lineTo(S * 0.45, S * 0.55); ctx.lineTo(S * 0.45, -S * 0.1); ctx.stroke() }
384
+ else if (kind === 'comment') { rr(ctx, -S * 0.7, -S * 0.55, S * 1.4, S * 0.95, S * 0.25); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-S * 0.3, S * 0.4); ctx.lineTo(-S * 0.45, S * 0.75); ctx.lineTo(0, S * 0.4); ctx.stroke(); for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.arc(i * S * 0.32, -S * 0.08, S * 0.08, 0, 7); ctx.fill() } }
385
+ ctx.restore()
386
+ }
src/video/scenes.ts ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TL } from './timelineData'
2
+ import type { Frame, View, OverlayApi } from './engine'
3
+ import { POSE, type SceneState, type Pose } from './model'
4
+ import { baseState, orbit } from './state'
5
+ import { clamp, kf, lerp, lerpArr, ramp, smooth, win, easeOut, noise1, type V3 } from './math'
6
+ import * as O from './overlay'
7
+
8
+ export const DURATION = TL.total
9
+ export const SHOTS = TL.shots
10
+ export const LINES = TL.lines
11
+ type C = CanvasRenderingContext2D
12
+
13
+ /* ---------------- helpers ---------------- */
14
+ const shotLines = (i: number) => LINES.filter((l) => l.shot === i).map((l) => ({ ...l, s: l.start - SHOTS[i].start, e: l.start - SHOTS[i].start + l.dur }))
15
+ const SL = SHOTS.map((_, i) => shotLines(i))
16
+
17
+ const withThumb = (p: Pose, th: number[]) => { const q = [...p]; for (let i = 0; i < 5; i++) q[16 + i] = th[i]; return q }
18
+ const THUMB_FLEX = [-0.08, 0.75, 0.1, 0.9, 0.65]
19
+ const THUMB_EXT = [0.35, -0.1, 0.0, -0.05, -0.05]
20
+ /** thumb flex/extension cycle on top of a base pose */
21
+ function thumbCycle(base: Pose, u: number, hz: number, amp: number, jerky = 0): Pose {
22
+ let k = 0.5 - 0.5 * Math.cos(u * hz * Math.PI * 2)
23
+ if (jerky > 0) {
24
+ // stick–slip: hold then jump
25
+ const stepK = Math.round(k * 4) / 4
26
+ k = lerp(k, stepK, jerky) + noise1(u * 13) * 0.04 * jerky
27
+ }
28
+ const ext = withThumb(base, THUMB_EXT)
29
+ const flx = withThumb(base, THUMB_FLEX)
30
+ return lerpArr(ext, flx, clamp(k * amp + (1 - amp) * 0.35))
31
+ }
32
+ const mixPose = (a: Pose, b: Pose, k: number) => lerpArr(a, b, clamp(k))
33
+ const setW = (p: Pose, flex: number, dev: number, pron = p[2]) => { const q = [...p]; q[0] = flex; q[1] = dev; q[2] = pron; return q }
34
+ const full = (state: SceneState, bg = 'lab'): View[] => [{ rect: [0, 0, 1, 1], state, bg }]
35
+ const painCam = (dist: number, yaw: number, pitch: number, fov = 35) => ({ pos: orbit([0, 0, 0], dist, yaw, pitch), target: [0, 0, 0] as V3, fov, follow: 1 })
36
+
37
+ /* =========================================================================
38
+ SHOT 1 · 疼痛开始
39
+ ========================================================================= */
40
+ function shot1(u: number, d: number, t: number): Frame {
41
+ const L = SL[0]
42
+ const a = L[0].s, b = L[1].s
43
+ // pose
44
+ let pose: Pose
45
+ if (u < a - 0.2) pose = thumbCycle(POSE.rest, u, 0.85, 0.9)
46
+ else pose = mixPose(mixPose(POSE.rest, POSE.fist, smooth(ramp(u, a - 0.2, a + 0.6))), POSE.tuck, smooth(ramp(u, a + 0.55, a + 1.15)))
47
+ const hit = a + 1.05
48
+ const painK = smooth(ramp(u, hit, hit + 0.35))
49
+ // relax after the hit, back to near rest when going inside
50
+ pose = mixPose(pose, mixPose(POSE.rest, POSE.tuck, 0.25), smooth(ramp(u, b + 0.9, b + 1.6)))
51
+ const shakeA = Math.max(0, 1 - Math.abs(u - hit - 0.15) / 0.35) * 0.035
52
+ const shake: V3 = [noise1(u * 40) * shakeA, noise1(u * 37 + 5) * shakeA, 0]
53
+ const push = smooth(ramp(u, b - 0.25, b + 1.0))
54
+ const xr = smooth(ramp(u, b + 0.7, b + 1.3))
55
+ const camWide = { pos: orbit([0, 0.45, 0], lerp(5.6, 4.8, smooth(u / a)), 0.25 - 0.15 * smooth(u / d), 0.08), target: [0, 0.45, 0] as V3, fov: 35 }
56
+ const state = baseState({
57
+ time: t, pose, shake,
58
+ arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.45, 0.95, push), 0.04] },
59
+ cam: push < 0.001 ? camWide : (() => {
60
+ const pc = painCam(lerp(4.8, 1.75, push), lerp(0.1, 0.05, push), lerp(0.08, 0.12, push))
61
+ // blend from hand-centered to pain-follow
62
+ return { ...pc, follow: push, pos: lerpVec(camWide.pos, pc.pos, push), target: lerpVec(camWide.target, pc.target, push) }
63
+ })(),
64
+ pain: painK * (1 - 0.2 * xr),
65
+ xray: xr, skin: 1,
66
+ bone: smooth(ramp(u, b + 1.0, b + 1.7)),
67
+ tendon: smooth(ramp(u, b + 1.25, b + 1.95)),
68
+ sheath: smooth(ramp(u, b + 1.5, d + 0.4)),
69
+ friction: 0.0,
70
+ })
71
+ return {
72
+ views: full(state),
73
+ fade: 1 - smooth(u / 0.8),
74
+ overlay: (ctx, api) => {
75
+ const p = api.anchor('pain')
76
+ O.painBurst(ctx, p.x, p.y, Math.max(0, 1 - Math.abs(u - hit - 0.4) / 1.2) * painK, t)
77
+ O.reticle(ctx, p.x, p.y, 70, win(u, b, d, 0.3, 0.4) * (1 - xr * 0.5), t, O.COL.red, smooth(ramp(u, b, b + 0.6)))
78
+ O.bigWord(ctx, '?', p.x + 150, p.y - 150, ramp(u, b + 0.2, b + 0.6) * (1 - ramp(u, b + 1.3, b + 1.6)), O.COL.red, 150)
79
+ // layer labels
80
+ const lb = api.anchor('bone'), lt = api.anchor('tendon'), ls = api.anchor('sheath')
81
+ O.label(ctx, lb.x, lb.y, lb.x + 230, lb.y + 120, '骨骼', ramp(u, b + 1.2, b + 1.5), '#f2e7cf')
82
+ O.label(ctx, lt.x, lt.y, lt.x + 260, lt.y - 60, '肌腱', ramp(u, b + 1.45, b + 1.75), O.COL.white)
83
+ O.label(ctx, ls.x, ls.y, ls.x - 230, ls.y - 170, '腱鞘', ramp(u, b + 1.7, b + 2.0), O.COL.cyan)
84
+ },
85
+ }
86
+ }
87
+ const lerpVec = (a: V3 | number[], b: V3 | number[], k: number): V3 => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]
88
+
89
+ /* =========================================================================
90
+ SHOT 2 · 腱鞘炎到底发生了什么
91
+ ========================================================================= */
92
+ function shot2(u: number, d: number, t: number): Frame {
93
+ const L = SL[1]
94
+ const thick = smooth(ramp(u, L[2].s, L[3].e))
95
+ const fr = Math.max(0.45 * smooth(ramp(u, L[2].s, L[3].s)), 0.95 * smooth(ramp(u, L[4].s, L[5].s + 0.6)))
96
+ const jerk = smooth(ramp(u, L[3].s, L[5].s))
97
+ const pose = thumbCycle(POSE.rest, u, lerp(0.9, 1.1, jerk), lerp(1, 0.7, jerk), jerk)
98
+ const state = baseState({
99
+ time: t, pose,
100
+ arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.95, 0.75, smooth(u / d)), 0.04] },
101
+ cam: painCam(lerp(1.75, 1.08, easeOut(u / 3)), lerp(0.05, -0.12, smooth(u / d)), 0.12),
102
+ skin: 0.75, xray: 1, bone: 1, tendon: 1, sheath: 1,
103
+ focus: smooth(ramp(u, 0, 1.0)), thick, friction: fr,
104
+ pain: 0.85 * smooth(ramp(u, L[5].s, L[5].s + 0.6)),
105
+ })
106
+ return {
107
+ views: full(state),
108
+ overlay: (ctx, api) => {
109
+ const s = api.anchor('sheath'), tn = api.anchor('tendon')
110
+ O.label(ctx, s.x, s.y, s.x - 200, s.y - 260, '腱鞘 = 小通道', win(u, L[0].s, L[2].s + 0.3), O.COL.cyan)
111
+ O.label(ctx, tn.x, tn.y, tn.x + 150, tn.y + 200, '肌腱', win(u, L[0].s + 0.4, L[2].s + 0.3), O.COL.white)
112
+ O.chip(ctx, 540, 1300, '正常:顺畅滑动', { icon: 'check', color: O.COL.green, alpha: win(u, L[1].s, L[2].s + 0.2) })
113
+ O.crossSection(ctx, 830, 560, 105, thick, fr, smooth(ramp(u, 0.5, 1.0)), t)
114
+ const k = (i: number) => ramp(u, L[i].s + 0.1, L[i].s + 0.5)
115
+ O.chip(ctx, 540, 1150, '腱鞘增厚', { icon: 'warn', color: O.COL.yellow, alpha: k(3), scale: 0.8 + 0.2 * easeOut(k(3)) })
116
+ O.chip(ctx, 540, 1268, '空间变窄', { icon: 'warn', color: '#ff9a3c', alpha: k(4), scale: 0.8 + 0.2 * easeOut(k(4)) })
117
+ O.chip(ctx, 540, 1386, '活动疼痛', { icon: 'warn', color: O.COL.red, alpha: k(5), scale: 0.8 + 0.2 * easeOut(k(5)) })
118
+ },
119
+ }
120
+ }
121
+
122
+ /* =========================================================================
123
+ SHOT 3 · 为什么一动就疼 (抓 / 捏 / 拧)
124
+ ========================================================================= */
125
+ function shot3(u: number, d: number, t: number): Frame {
126
+ const seg = d / 3
127
+ const i = Math.min(2, Math.floor(u / seg)), v = u - i * seg
128
+ const squeeze = 0.5 - 0.5 * Math.cos(v * Math.PI * 2 * 1.3)
129
+ let pose: Pose, props: SceneState['props'] = {}
130
+ let twist: { pivot: V3; axis: V3; angle: number } | undefined
131
+ if (i === 0) { pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.35)) * (0.8 + 0.2 * squeeze)); props = { ball: { vis: smooth(ramp(v, 0, 0.2)) } } }
132
+ else if (i === 1) { pose = mixPose(POSE.open, POSE.pinch, smooth(ramp(v, 0, 0.35)) * (0.9 + 0.1 * squeeze)); props = { bead: { vis: smooth(ramp(v, 0.25, 0.4)) } } }
133
+ else {
134
+ pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.3)))
135
+ pose = setW(pose, pose[0], pose[1] + 0.25 * squeeze)
136
+ const ang = -0.8 * squeeze
137
+ twist = { pivot: [-0.02, 0.6, -0.3], axis: [0, 0, 1], angle: ang }
138
+ props = { bottle: { vis: smooth(ramp(v, 0, 0.2)), twist: ang } }
139
+ }
140
+ const lvl = [0.4, 0.7, 1.0][i]
141
+ const state = baseState({
142
+ time: t, pose, props,
143
+ arm: { pos: [0, 0, 0], rot: [0.05, 0.85, 0.05], twist },
144
+ cam: { ...painCam(2.5, 0.25, 0.18), pos: orbit([0, 0, 0], 2.5, 0.25, 0.18) },
145
+ skin: 1, xray: 0.62, bone: 1, tendon: 1, sheath: 1, focus: 0.25,
146
+ thick: 1, friction: lvl * (0.7 + 0.3 * squeeze), pain: lvl * (0.75 + 0.25 * squeeze),
147
+ })
148
+ // camera looks slightly above the pain point so the hand & prop are visible
149
+ state.cam.target = [0, 0.35, 0]; state.cam.pos = [state.cam.pos[0], state.cam.pos[1] + 0.35, state.cam.pos[2]]
150
+ return {
151
+ views: full(state),
152
+ overlay: (ctx, api) => {
153
+ const names = ['抓', '捏', '拧'] as const
154
+ const kinds = ['grab', 'pinch', 'twist'] as const
155
+ names.forEach((n, k) => {
156
+ const on = k === i ? 1 : k < i ? 0.55 : 0.3
157
+ const x = 250 + k * 290, y = 1230
158
+ ctx.save(); ctx.globalAlpha = on
159
+ ctx.fillStyle = k === i ? 'rgba(255,59,59,0.22)' : 'rgba(4,14,30,0.7)'
160
+ ctx.strokeStyle = k === i ? O.COL.red : 'rgba(95,212,255,0.5)'; ctx.lineWidth = k === i ? 5 : 2
161
+ ctx.beginPath(); ctx.arc(x, y, 92 * (k === i ? 1 + 0.06 * squeeze : 1), 0, 7); ctx.fill(); ctx.stroke()
162
+ ctx.restore()
163
+ O.actionIcon(ctx, kinds[k], x, y - 22, 90, k === i ? '#fff' : O.COL.cyan, on)
164
+ ctx.save(); ctx.globalAlpha = on; ctx.font = `900 46px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(n, x, y + 48); ctx.restore()
165
+ })
166
+ // pain meter
167
+ const p = api.anchor('pain')
168
+ O.painBurst(ctx, p.x, p.y, lvl * 0.5 * squeeze, t)
169
+ const mx = 975, my0 = 520, mh = 480
170
+ ctx.save()
171
+ ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.fillRect(mx - 20, my0, 40, mh)
172
+ const fillH = mh * lvl * (0.85 + 0.15 * squeeze)
173
+ const g = ctx.createLinearGradient(0, my0 + mh, 0, my0); g.addColorStop(0, O.COL.yellow); g.addColorStop(1, O.COL.red)
174
+ ctx.fillStyle = g; ctx.fillRect(mx - 20, my0 + mh - fillH, 40, fillH)
175
+ ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; ctx.strokeRect(mx - 20, my0, 40, mh)
176
+ ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText('疼痛', mx, my0 + mh + 40)
177
+ ctx.restore()
178
+ },
179
+ }
180
+ }
181
+
182
+ /* =========================================================================
183
+ SHOT 4 · 哪些人更容易出现
184
+ ========================================================================= */
185
+ function shot4(u: number, d: number, t: number): Frame {
186
+ const L = SL[3]
187
+ const s0 = L[1].s - 0.05
188
+ const segD = (d - s0) / 4
189
+ const intro = u < s0
190
+ const i = intro ? -1 : Math.min(3, Math.floor((u - s0) / segD))
191
+ const v = intro ? u : u - s0 - i * segD
192
+ const words = ['重复用手', '长期抓握', '频繁捏拧', '抱孩子']
193
+ const icons = ['keyboard', 'tool', 'home', 'baby'] as const
194
+ let state: SceneState
195
+ const painFlash = intro ? 0 : smooth(ramp(v, 0.35, 0.6)) * (0.75 + 0.25 * Math.sin(t * 9))
196
+ if (intro) {
197
+ state = baseState({
198
+ time: t, pose: mixPose(POSE.rest, POSE.open, 0.5 + 0.5 * Math.sin(u * 3)),
199
+ arm: { pos: [0, 0, 0], rot: [0, -0.4 + u * 0.9, 0] },
200
+ cam: { pos: orbit([0, 0.4, 0], 5.4, 0, 0.1), target: [0, 0.4, 0], fov: 35 },
201
+ })
202
+ } else if (i === 0) {
203
+ const tap = Math.max(0, Math.sin(v * 18))
204
+ const pose = setW(POSE.mouse, -0.25, 0.1 + 0.18 * Math.sin(v * 5))
205
+ pose[4] += tap * 0.18
206
+ state = baseState({
207
+ time: t, pose,
208
+ arm: { pos: [0, 0, 0], rot: [-1.25, 0, 0] },
209
+ props: { keyboard: { vis: 1, x: -1.75, y: 0.7, z: -0.38, rx: Math.PI / 2 } },
210
+ cam: { pos: [-1.0, 2.4, 2.6], target: [-0.35, 0.15, -0.6], fov: 38 },
211
+ pain: painFlash,
212
+ })
213
+ } else if (i === 1) {
214
+ const hamK = Math.pow(0.5 + 0.5 * Math.sin(v * 9), 2)
215
+ state = baseState({
216
+ time: t, pose: setW(POSE.grip, 0.05, -0.15 + 0.5 * hamK),
217
+ arm: { pos: [0, 0, 0], rot: [0, 0.85, 0.15] },
218
+ props: { hammer: { vis: 1 } },
219
+ cam: { pos: orbit([-0.2, 0.55, 0], 4.2, 0.05, 0.12), target: [-0.2, 0.55, 0], fov: 35 },
220
+ pain: painFlash,
221
+ })
222
+ } else if (i === 2) {
223
+ const w = Math.sin(v * 6)
224
+ state = baseState({
225
+ time: t, pose: setW(POSE.grip, 0.1, 0.15 + 0.15 * w, 0.55 * w),
226
+ arm: { pos: [0, 0, 0], rot: [0, 0.75, -0.2] },
227
+ props: { towel: { vis: 1 } },
228
+ cam: { pos: orbit([0.1, 0.5, 0], 4.4, 0.1, 0.15), target: [0.1, 0.5, 0], fov: 35 },
229
+ pain: painFlash,
230
+ })
231
+ } else {
232
+ state = baseState({
233
+ time: t, pose: setW(POSE.baby, -0.25, 0.05 * Math.sin(v * 4)),
234
+ arm: { pos: [0, -0.1 * Math.sin(v * 3), 0], rot: [1.25, 0.7, 0] },
235
+ props: { baby: { vis: 1 } },
236
+ cam: { pos: [1.6, 3.0, 3.6], target: [0.0, 0.35, 0.3], fov: 38 },
237
+ pain: painFlash,
238
+ })
239
+ }
240
+ return {
241
+ views: full(state, intro ? 'lab' : 'warm'),
242
+ overlay: (ctx, api) => {
243
+ if (!intro && v < 0.14) O.flash(ctx, 0.35 * (1 - v / 0.14))
244
+ if (intro) {
245
+ icons.forEach((ic, k) => {
246
+ const a = ramp(u, 0.2 + k * 0.12, 0.45 + k * 0.12)
247
+ const x = 195 + k * 230, y = 1230
248
+ ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
249
+ ctx.beginPath(); ctx.arc(x, y, 80, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore()
250
+ O.actionIcon(ctx, ic, x, y, 80, O.COL.cyan, a)
251
+ })
252
+ O.bigWord(ctx, '哪些人更容易出现?', 540, 1050, ramp(u, 0.1, 0.5), '#ffffff', 74)
253
+ return
254
+ }
255
+ const p = api.anchor('pain')
256
+ O.painBurst(ctx, p.x, p.y, painFlash * 0.6, t)
257
+ // scene counter + keyword
258
+ ctx.save(); ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.textAlign = 'right'
259
+ ctx.fillText(`0${i + 1} / 04`, 1030, 330); ctx.restore()
260
+ O.chip(ctx, 540, 1290, words[i], { color: O.COL.red, size: 60, alpha: ramp(v, 0.05, 0.25), scale: 0.85 + 0.15 * easeOut(ramp(v, 0.05, 0.35)), icon: 'dot' })
261
+ O.actionIcon(ctx, icons[i], 120, 330, 90, O.COL.yellow, 0.9)
262
+ },
263
+ }
264
+ }
265
+
266
+ /* =========================================================================
267
+ SHOT 5 · 不要一上来就直接治疗
268
+ ========================================================================= */
269
+ function shot5(u: number, d: number, t: number): Frame {
270
+ const L = SL[4]
271
+ const lock = smooth(ramp(u, L[1].s - 0.2, L[1].s + 0.6))
272
+ const motion = ramp(u, L[2].s, L[2].s + 0.2)
273
+ const probeK = smooth(ramp(u, L[3].s - 0.1, L[3].s + 0.4))
274
+ const usK = smooth(ramp(u, L[3].s + 0.5, L[3].s + 1.0))
275
+ const pose = motion > 0 ? thumbCycle(POSE.rest, u, 1.1, 0.9) : POSE.rest
276
+ const rotY = lerp(-0.7, 0.9, smooth(ramp(u, 0, L[1].s)))
277
+ const wide = { pos: orbit([0, 0.35, 0], 4.9, 0, 0.1), target: [0, 0.35, 0] as V3, fov: 35 }
278
+ const pc = painCam(lerp(4.9, 1.6, lock), 0.05, 0.12)
279
+ const state = baseState({
280
+ time: t, pose,
281
+ arm: { pos: [0, 0, 0], rot: [0.03, rotY, 0.04] },
282
+ cam: { ...pc, follow: lock, pos: lerpVec(wide.pos, pc.pos, lock), target: lerpVec(wide.target, pc.target, lock) },
283
+ skin: 1, xray: 0.75, bone: 1, tendon: 1, sheath: 1, thick: 0.8,
284
+ focus: smooth(ramp(u, L[2].s, L[2].s + 0.5)) * 0.9,
285
+ friction: 0.6 * motion, pain: 0.55 + 0.3 * lock,
286
+ props: { probe: { vis: probeK, lift: (1 - probeK) * 0.6 } },
287
+ })
288
+ return {
289
+ views: full(state),
290
+ overlay: (ctx, api) => {
291
+ O.chip(ctx, 540, 1180, '直接治疗', { icon: 'cross', color: O.COL.red, alpha: win(u, 0.2, L[1].s + 0.2), strike: ramp(u, 0.9, 1.5) })
292
+ O.chip(ctx, 540, 1300, '先判断,再治疗', { icon: 'check', color: O.COL.teal, alpha: win(u, 1.2, L[2].s + 0.1) })
293
+ const p = api.anchor('pain')
294
+ O.reticle(ctx, p.x, p.y, 90, win(u, L[1].s - 0.1, L[3].s + 0.4), t, O.COL.red, lock)
295
+ O.label(ctx, p.x, p.y, p.x - 240, p.y - 250, '疼痛位置', win(u, L[1].s + 0.4, L[3].s + 0.4), O.COL.red)
296
+ // tendon trajectory (animated dashes along tendon path)
297
+ const tr = win(u, L[2].s, L[3].s + 0.5)
298
+ if (tr > 0.01) {
299
+ const a1 = api.anchor('tendon'), a2 = api.anchor('pain'), a3 = api.anchor('thumbBase')
300
+ ctx.save(); ctx.globalAlpha = tr; ctx.strokeStyle = O.COL.yellow; ctx.lineWidth = 7; ctx.setLineDash([22, 18]); ctx.lineDashOffset = -t * 120
301
+ ctx.shadowColor = O.COL.yellow; ctx.shadowBlur = 14
302
+ ctx.beginPath(); ctx.moveTo(a1.x, a1.y); ctx.quadraticCurveTo(a2.x, a2.y, a3.x, a3.y); ctx.stroke(); ctx.restore()
303
+ O.chip(ctx, 540, 1300, '肌腱活动轨迹', { color: O.COL.yellow, alpha: tr, icon: 'dot' })
304
+ }
305
+ O.ultrasound(ctx, 110, 470, 860, 620, t, { alpha: usK, thick: 0.85, labels: ramp(u, L[4].s, L[4].s + 1.2), narrowMark: ramp(u, L[4].s + 0.6, L[4].s + 1.0) })
306
+ },
307
+ }
308
+ }
309
+
310
+ /* =========================================================================
311
+ SHOT 6 · 常见治疗方法 (四宫格)
312
+ ========================================================================= */
313
+ function shot6(u: number, d: number, t: number): Frame {
314
+ const L = SL[5]
315
+ const act = (k: number) => (u >= L[k].s - 0.1 && (k === 3 || u < L[k + 1].s - 0.1) ? 1 : 0)
316
+ const prog = (k: number) => clamp((u - L[k].s) / (k === 3 ? d - L[k].s : L[k + 1].s - L[k].s))
317
+ const y0 = 0.16, ph = 0.3
318
+ const rects: [number, number, number, number][] = [[0, y0, 0.5, ph], [0.5, y0, 0.5, ph], [0, y0 + ph, 0.5, ph], [0.5, y0 + ph, 0.5, ph]]
319
+ const cam = (dist: number, tgt: V3, yaw = 0, pitch = 0.1) => ({ pos: orbit(tgt, dist, yaw, pitch), target: tgt, fov: 35 })
320
+ // 1 减少抓握
321
+ const p0 = prog(0)
322
+ const v0 = baseState({ time: t, pose: mixPose(POSE.grip, POSE.open, smooth(ramp(p0, 0.1, 0.5))), arm: { pos: [0, 0, 0], rot: [0, 0.5, 0] }, cam: cam(3.3, [0, 0.55, 0]), pain: 0.5 * (1 - p0) })
323
+ // 2 支具
324
+ const p1 = prog(1)
325
+ const v1 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, 0.75 + 0.2 * Math.sin(t * 0.8), 0] }, cam: cam(3.5, [0, 0.2, 0]), props: { splint: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintThumb: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintPalm: { vis: smooth(ramp(p1, 0.05, 0.3)) } }, pain: 0.4 })
326
+ // 3 局部治疗示意
327
+ const p2 = prog(2)
328
+ const v2 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, -0.35, 0] }, cam: painCam(1.5, 0.0, 0.1), skin: 1, xray: 0.9, bone: 1, tendon: 1, sheath: 1, thick: 0.9, focus: 0.7, pain: 0.8 * (1 - smooth(ramp(p2, 0.6, 1)) * 0.6), props: { syringe: { vis: smooth(ramp(p2, 0, 0.15)), depth: lerp(0.8, 0.12, smooth(ramp(p2, 0.05, 0.45))), push: smooth(ramp(p2, 0.5, 0.85)) }, cloud: { vis: smooth(ramp(p2, 0.5, 0.7)) * 0.8, r: 0.04 + 0.1 * smooth(ramp(p2, 0.5, 0.9)) } } })
329
+ // 4 手术松解示意
330
+ const p3 = prog(3)
331
+ const rel = smooth(ramp(p3, 0.35, 0.75))
332
+ const v3 = baseState({ time: t, pose: thumbCycle(POSE.rest, t, 0.8, lerp(0.3, 1, rel)), arm: { pos: [0, 0, 0], rot: [0, 0.85, 0] }, cam: painCam(1.25, 0.05, 0.1), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, thick: 1 - 0.8 * rel, friction: 0.8 * (1 - rel), focus: 1, pain: 0.9 * (1 - rel * 0.8), props: { ring: { vis: win(p3, 0.35, 0.8, 0.05, 0.2), r: rel } } })
333
+ const states = [v0, v1, v2, v3]
334
+ const titles = ['减少抓握', '支具固定', '局部治疗示意', '手术松解示意']
335
+ return {
336
+ views: states.map((s, k) => ({ rect: rects[k], state: s, bg: 'lab' })),
337
+ overlay: (ctx, api) => {
338
+ ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + 2 * ph) * 1920, 1080, 1920 - (y0 + 2 * ph) * 1920); ctx.restore()
339
+ states.forEach((_, k) => {
340
+ const [rx, ry, rw, rh] = rects[k]
341
+ O.panel(ctx, rx * 1080, ry * 1920, rw * 1080, rh * 1920, titles[k], k + 1, act(k), act(k) ? 0 : (u >= L[k].s ? 0.35 : 0.7))
342
+ })
343
+ // panel 1: no-grip icon
344
+ const a0 = act(0) ? 1 : 0.5
345
+ ctx.save(); ctx.globalAlpha = a0; ctx.translate(430, 420); ctx.strokeStyle = O.COL.red; ctx.lineWidth = 7
346
+ ctx.beginPath(); ctx.arc(0, 0, 52, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-36, -36); ctx.lineTo(36, 36); ctx.stroke(); ctx.restore()
347
+ O.actionIcon(ctx, 'grab', 430, 420, 60, '#fff', a0)
348
+ // panel 4 incision dashed line (on sheath)
349
+ if (act(3)) {
350
+ const s = api.anchor('pain', 3)
351
+ ctx.save(); ctx.strokeStyle = '#fff'; ctx.globalAlpha = win(p3, 0.15, 0.6, 0.1, 0.1); ctx.lineWidth = 4; ctx.setLineDash([12, 10])
352
+ ctx.beginPath(); ctx.moveTo(s.x - 10, s.y - 120); ctx.lineTo(s.x + 10, s.y + 120); ctx.stroke(); ctx.restore()
353
+ }
354
+ O.chip(ctx, 540, 1528 - 1528 + 245, '常见处理方式 · 需医生评估', { size: 36, color: O.COL.cyan, alpha: ramp(u, 0, 0.4) })
355
+ },
356
+ }
357
+ }
358
+
359
+ /* =========================================================================
360
+ SHOT 7 · 重点进入小针刀
361
+ ========================================================================= */
362
+ function shot7(u: number, d: number, t: number): Frame {
363
+ const L = SL[6]
364
+ const depth = u < L[3].s ? lerp(1.7, 0.72, smooth(ramp(u, 0.2, L[1].e))) : lerp(0.72, 0.1, smooth(ramp(u, L[3].s, d - 0.2)))
365
+ const state = baseState({
366
+ time: t, pose: POSE.rest,
367
+ arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] },
368
+ cam: painCam(lerp(2.6, 1.35, smooth(ramp(u, 0, L[2].s))), 0.1, 0.06),
369
+ skin: 1, xray: lerp(0.55, 1, smooth(ramp(u, L[2].s - 0.4, L[2].s + 0.3))), bone: 1, tendon: 1, sheath: 1,
370
+ thick: 1, friction: 0.5, pain: 0.85, focus: lerp(0.2, 0.8, smooth(ramp(u, L[2].s, L[2].s + 0.5))),
371
+ props: { knife: { vis: smooth(ramp(u, 0.1, 0.5)), depth } },
372
+ })
373
+ return {
374
+ views: full(state),
375
+ overlay: (ctx, api) => {
376
+ O.bigWord(ctx, '小针刀在做什么?', 540, 380, win(u, 0.2, L[1].s, 0.3, 0.3), '#ffffff', 70)
377
+ O.chip(ctx, 540, 1250, '扎一下就结束', { icon: 'cross', color: O.COL.red, alpha: win(u, L[1].s, L[2].s + 0.2), strike: ramp(u, L[1].s + 0.4, L[1].e) })
378
+ const n = api.anchor('narrow')
379
+ O.reticle(ctx, n.x, n.y, 80, win(u, L[2].s, d + 1, 0.3, 0.3), t, O.COL.red, smooth(ramp(u, L[2].s, L[2].s + 0.6)))
380
+ O.label(ctx, n.x, n.y, n.x + 210, n.y + 230, '狭窄位置', ramp(u, L[2].s + 0.6, L[2].s + 1.0), O.COL.red)
381
+ const k = api.anchor('knife')
382
+ O.label(ctx, k.x, k.y, k.x - 40, k.y - 170, '原创器械示意', win(u, 0.8, L[2].s), O.COL.teal, 34)
383
+ O.chip(ctx, 540, 1300, '精准松解', { icon: 'check', color: O.COL.teal, alpha: ramp(u, L[3].s + 0.8, L[3].s + 1.2), size: 52 })
384
+ },
385
+ }
386
+ }
387
+
388
+ /* =========================================================================
389
+ SHOT 8 · 小针刀治疗过程
390
+ ========================================================================= */
391
+ function shot8(u: number, d: number, t: number): Frame {
392
+ const L = SL[7]
393
+ const step = u < L[1].s - 0.1 ? 0 : u < L[2].s - 0.1 ? 1 : u < L[3].s - 0.1 ? 2 : 3
394
+ const sL = [L[0].s, L[1].s, L[2].s, L[3].s]
395
+ const loc = win(u, 0, L[1].s - 0.05, 0.2, 0.25)
396
+ const dis = win(u, L[1].s - 0.1, L[2].s, 0.2, 0.25)
397
+ const ane = win(u, L[2].s - 0.1, L[3].s, 0.2, 0.3)
398
+ const fin = smooth(ramp(u, L[3].s - 0.2, L[3].s + 0.4))
399
+ const relK = smooth(ramp(u, L[5].s, L[5].s + 0.9))
400
+ const xray = step === 0 ? 0.6 : step === 1 ? lerp(0.6, 0.12, smooth(ramp(u, L[1].s - 0.1, L[1].s + 0.3))) : step === 2 ? lerp(0.12, 0.5, smooth(ramp(u, L[2].s, L[2].s + 0.4))) : lerp(0.5, 1, fin)
401
+ const kDepth = lerp(0.9, 0.0, smooth(ramp(u, L[3].s + 0.3, L[4].e)))
402
+ const state = baseState({
403
+ time: t, pose: POSE.rest,
404
+ arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] },
405
+ cam: painCam(step === 3 ? lerp(1.6, 1.2, fin) : 1.6, 0.1, 0.06),
406
+ skin: 1, xray, bone: 1, tendon: 1, sheath: 1,
407
+ thick: 1 - 0.7 * relK, friction: 0.5 * (1 - relK), pain: 0.85 - 0.6 * relK, focus: 0.75,
408
+ slide: relK * Math.sin(t * 5) * 0.03,
409
+ props: {
410
+ probe: { vis: Math.max(loc, 0), lift: 0 },
411
+ locator: { vis: Math.max(loc, step === 3 ? win(u, L[3].s, L[4].e, 0.2, 0.3) * 0.8 : 0) },
412
+ swab: { vis: dis, r: 0.03 + 0.12 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) },
413
+ iodine: { vis: Math.max(dis, step >= 2 ? 0.55 * (1 - fin) : 0), r: 0.04 + 0.26 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) },
414
+ syringe: { vis: ane, depth: lerp(0.7, 0.13, smooth(ramp(u, L[2].s, L[2].s + 0.7))), push: smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.3)) },
415
+ cloud: { vis: ane * smooth(ramp(u, L[2].s + 0.7, L[2].s + 1.1)) * 0.8, r: 0.03 + 0.1 * smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.2)) },
416
+ knife: { vis: fin, depth: kDepth, release: relK * (1 - ramp(u, d - 0.3, d)) },
417
+ ring: { vis: relK > 0.01 ? (0.5 + 0.5 * Math.sin(t * 14)) * win(u, L[5].s, d, 0.1, 0.3) : 0, r: relK },
418
+ },
419
+ })
420
+ return {
421
+ views: full(state),
422
+ overlay: (ctx, api) => {
423
+ const active = step + (step < 3 ? smooth(ramp(u, sL[step + 1] - 0.4, sL[step + 1])) * 0 : 0)
424
+ O.steps(ctx, ['定位', '消毒', '麻醉', '松解'], active, 1, 330)
425
+ O.ultrasound(ctx, 190, 520, 700, 400, t, { alpha: loc, thick: 1, locator: ramp(u, 0.2, 0.5), title: '超声定位 · 示意' })
426
+ const us2 = win(u, L[3].s + 0.3, d, 0.3, 0.3)
427
+ O.ultrasound(ctx, 620, 520, 400, 240, t, { alpha: us2, thick: 1 - 0.7 * relK, knife: smooth(ramp(u, L[3].s + 0.3, L[4].e)), release: relK, title: '影像引导' })
428
+ const p = api.anchor('pain')
429
+ if (step === 0) O.label(ctx, p.x, p.y, p.x - 250, p.y + 230, '目标区域', ramp(u, 0.4, 0.8), '#ffffff')
430
+ if (step === 1) O.chip(ctx, 540, 1300, '皮肤消毒', { icon: 'check', color: '#e0a050', alpha: dis })
431
+ if (step === 2) O.chip(ctx, 540, 1300, '局部麻醉(必要时)', { icon: 'dot', color: '#59c8ff', alpha: ane })
432
+ if (step === 3) {
433
+ const k = api.anchor('knife'), tn = api.anchor('tendon'), sh = api.anchor('sheath')
434
+ const la = win(u, L[3].s + 0.6, L[5].s + 0.2, 0.3, 0.3)
435
+ O.label(ctx, k.x, k.y, k.x - 60, k.y - 150, '器械位置', la, O.COL.teal, 36)
436
+ O.label(ctx, tn.x, tn.y, tn.x + 200, tn.y + 150, '肌腱位置', la, '#ffffff', 36)
437
+ O.label(ctx, sh.x, sh.y, sh.x - 200, sh.y + 300, '腱鞘位置', la, O.COL.cyan, 36)
438
+ const ws = ['精准', '有限', '局部']
439
+ ws.forEach((w, i) => O.chip(ctx, 290 + i * 250, 1330, w, { color: O.COL.teal, fill: true, size: 50, alpha: ramp(u, L[5].s + 0.1 + i * 0.25, L[5].s + 0.35 + i * 0.25), scale: 0.8 + 0.2 * easeOut(ramp(u, L[5].s + i * 0.25, L[5].s + 0.4 + i * 0.25)) }))
440
+ if (relK > 0) O.flash(ctx, 0.25 * win(u, L[5].s, L[5].s + 0.3, 0.05, 0.25), '200,255,240')
441
+ }
442
+ },
443
+ }
444
+ }
445
+
446
+ /* =========================================================================
447
+ SHOT 9 · 松解前后对比
448
+ ========================================================================= */
449
+ function shot9(u: number, d: number, t: number): Frame {
450
+ const L = SL[8]
451
+ const after = smooth(ramp(u, L[1].s, L[2].e))
452
+ const base = { arm: { pos: [0, 0, 0] as V3, rot: [0, 0.85, 0.04] as V3 }, cam: painCam(1.35, 0.05, 0.1, 40), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, focus: 1, time: t }
453
+ const left = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, 0.45, 1), thick: 1, friction: 0.95, pain: 0.95 })
454
+ const right = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, lerp(0.45, 1, after), 1 - after), thick: lerp(1, 0.12, after), friction: lerp(0.95, 0, after), pain: lerp(0.95, 0.08, after) })
455
+ const y0 = 0.15, h = 0.62
456
+ return {
457
+ views: [{ rect: [0, y0, 0.5, h], state: left, bg: 'red' }, { rect: [0.5, y0, 0.5, h], state: right, bg: after > 0.5 ? 'clean' : 'lab' }],
458
+ overlay: (ctx) => {
459
+ ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + h) * 1920, 1080, 1920); ctx.restore()
460
+ const yT = y0 * 1920 + 60
461
+ const lOn = u < L[1].s ? 1 : 0.55, rOn = u >= L[1].s - 0.1 ? 1 : 0.5
462
+ O.chip(ctx, 270, yT, '治疗前', { color: O.COL.red, size: 48, alpha: lOn, fill: lOn > 0.9 })
463
+ O.chip(ctx, 810, yT, '治疗后示意', { color: O.COL.teal, size: 48, alpha: rOn, fill: rOn > 0.9 })
464
+ ctx.save(); const g = ctx.createLinearGradient(0, y0 * 1920, 0, (y0 + h) * 1920); g.addColorStop(0, 'rgba(95,212,255,0)'); g.addColorStop(0.5, 'rgba(95,212,255,0.9)'); g.addColorStop(1, 'rgba(95,212,255,0)')
465
+ ctx.fillStyle = g; ctx.fillRect(538, y0 * 1920, 4, h * 1920); ctx.restore()
466
+ const yb = (y0 + h) * 1920 - 70
467
+ O.chip(ctx, 160, yb, '受限', { color: O.COL.red, size: 38, icon: 'cross', alpha: ramp(u, 0.6, 1.0) })
468
+ O.chip(ctx, 390, yb, '摩擦', { color: O.COL.red, size: 38, icon: 'warn', alpha: ramp(u, 1.0, 1.4) })
469
+ O.chip(ctx, 690, yb, '空间改善', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[1].s + 0.3, L[1].s + 0.7) })
470
+ O.chip(ctx, 940, yb, '更顺畅', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[2].s, L[2].s + 0.4) })
471
+ O.crossSection(ctx, 270, 1330, 80, 1, 0.95, 0.9, t, '狭窄')
472
+ O.crossSection(ctx, 810, 1330, 80, lerp(1, 0.12, after), lerp(0.95, 0, after), 0.9, t, '改善')
473
+ },
474
+ }
475
+ }
476
+
477
+ /* =========================================================================
478
+ SHOT 10 · 从疼痛到恢复
479
+ ========================================================================= */
480
+ function shot10(u: number, d: number, t: number): Frame {
481
+ const n = 6, seg = d / n
482
+ const i = Math.min(n - 1, Math.floor(u / seg)), v = u - i * seg
483
+ const labels = ['治疗前:活动困难', '早期:轻微活动', '正常抓握', '正常拿杯子', '正常使用手机', '自然活动']
484
+ const pains = [1, 0.7, 0.45, 0.25, 0.1, 0]
485
+ const pain = lerp(pains[i], pains[Math.min(n - 1, i + 1)], smooth(ramp(v, seg * 0.6, seg)))
486
+ let pose: Pose, props: SceneState['props'] = {}
487
+ let rot: V3 = [0, 0.6, 0.04]
488
+ let cam = { pos: orbit([0, 0.5, 0], 4.3, 0.05, 0.1), target: [0, 0.5, 0] as V3, fov: 35 }
489
+ const blendIn = smooth(ramp(v, 0, 0.35))
490
+ if (i === 0) pose = thumbCycle(POSE.rest, v, 1.4, 0.3, 1)
491
+ else if (i === 1) pose = thumbCycle(POSE.rest, v, 0.7, 0.6)
492
+ else if (i === 2) { pose = mixPose(POSE.open, POSE.gripBall, blendIn * (0.85 + 0.15 * Math.sin(v * 6))); props = { ball: { vis: blendIn } } }
493
+ else if (i === 3) { pose = mixPose(POSE.open, POSE.cup, blendIn); props = { cup: { vis: blendIn } }; rot = [0, 0.5, 0.04 + 0.15 * smooth(ramp(v, 0.4, 1))] }
494
+ else if (i === 4) { pose = mixPose(POSE.open, POSE.phone, blendIn); pose[17] += 0.25 * Math.max(0, Math.sin(v * 12)); pose[19] += 0.15 * Math.max(0, Math.sin(v * 12)); props = { phone: { vis: blendIn } }; rot = [0.1, -0.25, 0]; cam = { pos: orbit([0, 0.6, 0], 4.1, 0.0, 0.12), target: [0, 0.6, 0], fov: 35 } }
495
+ else { pose = mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(v * 5)); rot = [0, 0.4 + 0.3 * Math.sin(v * 2), 0.04] }
496
+ const state = baseState({ time: t, pose, props, arm: { pos: [0, 0, 0], rot }, cam, pain, skin: 1 })
497
+ return {
498
+ views: full(state, i >= 3 ? 'clean' : 'lab'),
499
+ overlay: (ctx, api) => {
500
+ if (v < 0.12 && i > 0) O.flash(ctx, 0.18 * (1 - v / 0.12))
501
+ // progress track
502
+ const y = 1300, x0 = 140, x1 = 940
503
+ ctx.save()
504
+ const g = ctx.createLinearGradient(x0, 0, x1, 0); g.addColorStop(0, O.COL.red); g.addColorStop(0.5, O.COL.yellow); g.addColorStop(1, O.COL.green)
505
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke()
506
+ ctx.strokeStyle = g; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + (x1 - x0) * clamp(u / d), y); ctx.stroke()
507
+ for (let k = 0; k < n; k++) {
508
+ const x = x0 + (x1 - x0) * k / (n - 1)
509
+ ctx.fillStyle = k <= i ? '#fff' : 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(x, y, k === i ? 16 : 10, 0, 7); ctx.fill()
510
+ }
511
+ ctx.restore()
512
+ O.chip(ctx, 540, 1200, labels[i], { color: i === 0 ? O.COL.red : i < 2 ? O.COL.yellow : O.COL.green, size: 50, alpha: smooth(ramp(v, 0, 0.2)), icon: i === 0 ? 'warn' : 'check' })
513
+ if (pain > 0.3) { const p = api.anchor('pain'); O.painBurst(ctx, p.x, p.y, (pain - 0.3) * 0.6, t) }
514
+ ctx.save(); ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'left'; ctx.fillText('疼痛', x0 - 10, y - 34); ctx.textAlign = 'right'; ctx.fillText('功能恢复', x1 + 10, y - 34); ctx.restore()
515
+ },
516
+ }
517
+ }
518
+
519
+ /* =========================================================================
520
+ SHOT 11 · 最后的记忆点
521
+ ========================================================================= */
522
+ function shot11(u: number, d: number, t: number): Frame {
523
+ const L = SL[10]
524
+ const out = smooth(ramp(u, L[1].s + 0.6, d - 0.3))
525
+ const pc = painCam(lerp(1.4, 5.4, out), lerp(0.05, 0.0, out), 0.1)
526
+ const wide = { pos: orbit([0, 0.45, 0], 5.4, 0.0, 0.1), target: [0, 0.45, 0] as V3, fov: 35 }
527
+ const fol = 1 - out
528
+ const state = baseState({
529
+ time: t, pose: mixPose(POSE.rest, POSE.open, out * 0.5),
530
+ arm: { pos: [0, 0, 0], rot: [0, lerp(0.85, 0.35, out), 0.04] },
531
+ cam: { ...pc, follow: fol, pos: lerpVec(wide.pos, pc.pos, fol), target: lerpVec(wide.target, pc.target, fol) },
532
+ skin: 1, xray: 1 - smooth(ramp(u, L[2].s, d - 0.5)), bone: 1, tendon: 1, sheath: 1,
533
+ thick: lerp(0.7, 0.1, smooth(ramp(u, L[1].s, L[2].s))), pain: 0.75 * (1 - smooth(ramp(u, L[1].s, L[3].s))), focus: 0.4 * (1 - out),
534
+ })
535
+ return {
536
+ views: full(state, u > L[2].s ? 'clean' : 'lab'),
537
+ overlay: (ctx) => {
538
+ O.chip(ctx, 540, 1180, '忍住疼', { icon: 'cross', color: O.COL.red, size: 54, alpha: win(u, 0.3, L[1].s + 0.1), strike: ramp(u, L[0].s + 1.4, L[0].e) })
539
+ O.chip(ctx, 540, 1110, '找到疼痛原因', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 0.1, L[2].s + 0.1) })
540
+ O.chip(ctx, 540, 1240, '选择合适方式', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 1.1, L[2].s + 0.1) })
541
+ // slogan
542
+ const sA = ramp(u, L[3].s - 0.1, L[3].s + 0.4)
543
+ if (sA > 0) {
544
+ ctx.save()
545
+ ctx.globalAlpha = sA * 0.75; ctx.fillStyle = 'rgba(2,8,18,0.7)'; ctx.fillRect(0, 1000, 1080, 330); ctx.restore()
546
+ O.bigWord(ctx, '手越疼,', 540, 1090, sA, '#ffffff', 96)
547
+ O.bigWord(ctx, '越不能只靠硬扛。', 540, 1230, ramp(u, L[3].s + 0.35, L[3].s + 0.8), O.COL.yellow, 96)
548
+ }
549
+ O.chip(ctx, 540, 960, '记住一句话', { size: 40, color: O.COL.cyan, alpha: win(u, L[2].s, d + 1, 0.2, 0.2) })
550
+ },
551
+ }
552
+ }
553
+
554
+ /* =========================================================================
555
+ SHOT 12 · 评论区互动
556
+ ========================================================================= */
557
+ function shot12(u: number, d: number, t: number): Frame {
558
+ const state = baseState({
559
+ time: t, pose: mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(u * 2.4)),
560
+ arm: { pos: [0, 0.15, 0], rot: [0, 0.35 + 0.2 * Math.sin(u * 1.5), 0.04 + 0.08 * Math.sin(u * 3)] },
561
+ cam: { pos: orbit([0, 0.7, 0], 6.2, 0, 0.1), target: [0, 0.2, 0], fov: 35 },
562
+ })
563
+ const items = [['grab', '抓'], ['pinch', '捏'], ['twist', '拧'], ['baby', '抱孩子']] as const
564
+ return {
565
+ views: full(state, 'clean'),
566
+ fade: smooth(ramp(u, d - 0.5, d)),
567
+ overlay: (ctx) => {
568
+ const a = smooth(ramp(u, 0.1, 0.5))
569
+ ctx.save(); ctx.globalAlpha = a
570
+ ctx.fillStyle = 'rgba(3,12,26,0.86)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
571
+ ctx.beginPath(); ctx.roundRect(70, 980, 940, 430, 36); ctx.fill(); ctx.stroke()
572
+ ctx.font = `900 50px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'
573
+ ctx.fillText('评论区告诉我', 540, 1060)
574
+ ctx.font = `800 40px ${O.FONT}`; ctx.fillStyle = '#bfe9ff'
575
+ ctx.fillText('是抓、捏、拧,还是抱孩子?', 540, 1125)
576
+ ctx.restore()
577
+ items.forEach(([ic, w], k) => {
578
+ const p = ramp(u, 0.6 + k * 0.25, 1.0 + k * 0.25)
579
+ const x = 200 + k * 227, y = 1265
580
+ const bob = Math.sin(t * 4 + k) * 6
581
+ ctx.save(); ctx.globalAlpha = p; ctx.translate(x, y + bob); const sc = 0.6 + 0.4 * easeOut(p); ctx.scale(sc, sc)
582
+ ctx.fillStyle = 'rgba(95,212,255,0.12)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3
583
+ ctx.beginPath(); ctx.arc(0, 0, 82, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore()
584
+ O.actionIcon(ctx, ic, x, y - 16 + bob, 70, '#fff', p)
585
+ ctx.save(); ctx.globalAlpha = p; ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(w, x, y + 50 + bob); ctx.restore()
586
+ })
587
+ O.actionIcon(ctx, 'comment', 540, 880, 120 * (1 + 0.06 * Math.sin(t * 6)), O.COL.yellow, a)
588
+ },
589
+ }
590
+ }
591
+
592
+ const SHOT_FNS = [shot1, shot2, shot3, shot4, shot5, shot6, shot7, shot8, shot9, shot10, shot11, shot12]
593
+
594
+ export function shotAt(t: number) {
595
+ let i = SHOTS.length - 1
596
+ for (let k = 0; k < SHOTS.length; k++) if (t < SHOTS[k].end) { i = k; break }
597
+ return i
598
+ }
599
+
600
+ export function subtitleAt(t: number) {
601
+ let cur: (typeof LINES)[number] | null = null
602
+ for (let k = 0; k < LINES.length; k++) {
603
+ const l = LINES[k]
604
+ const next = LINES[k + 1]
605
+ const hold = next ? Math.min(next.start, l.start + l.dur + 0.5) : l.start + l.dur + 0.8
606
+ if (t >= l.start - 0.05 && t < hold) cur = l
607
+ }
608
+ return cur
609
+ }
610
+
611
+ /** The whole film: pure function of time */
612
+ export function frameAt(t: number): Frame {
613
+ t = clamp(t, 0, DURATION - 1e-4)
614
+ const i = shotAt(t)
615
+ const sh = SHOTS[i]
616
+ const u = t - sh.start, d = sh.end - sh.start
617
+ const f = SHOT_FNS[i](u, d, t)
618
+ const inner = f.overlay
619
+ const sub = subtitleAt(t)
620
+ f.overlay = (ctx: C, api: OverlayApi) => {
621
+ inner?.(ctx, api)
622
+ // transition flash at shot start
623
+ if (i > 0 && u < 0.2) O.flash(ctx, 0.22 * (1 - u / 0.2), '180,230,255')
624
+ O.header(ctx, i + 1, sh.title, i === 0 ? win(u, 0.8, 3.2, 0.4, 0.5) : win(u, 0.05, 2.4, 0.3, 0.5))
625
+ if (sub) {
626
+ const a = Math.min(smooth(ramp(t, sub.start - 0.05, sub.start + 0.12)), 1)
627
+ O.subtitle(ctx, sub.text, a)
628
+ }
629
+ O.footer(ctx)
630
+ }
631
+ return f
632
+ }
633
+
634
+ /* ---------------- sound cue list (for the procedural mix) ---------------- */
635
+ export type CueKind = 'whoosh' | 'pain' | 'tick' | 'scan' | 'pop' | 'release' | 'chime' | 'thud' | 'click' | 'swipe'
636
+ export function soundCues(): { t: number; k: CueKind; v?: number }[] {
637
+ const c: { t: number; k: CueKind; v?: number }[] = []
638
+ const S = (i: number) => SHOTS[i].start
639
+ const Ls = (i: number, j: number) => S(i) + SL[i][j].s
640
+ SHOTS.forEach((s, i) => { if (i > 0) c.push({ t: s.start - 0.15, k: 'whoosh', v: 0.6 }) })
641
+ // shot 1
642
+ c.push({ t: Ls(0, 0) + 1.05, k: 'pain', v: 1 }, { t: Ls(0, 1) - 0.2, k: 'whoosh', v: 0.5 }, { t: Ls(0, 1) + 0.7, k: 'scan', v: 0.6 })
643
+ c.push({ t: Ls(0, 1) + 1.2, k: 'tick' }, { t: Ls(0, 1) + 1.45, k: 'tick' }, { t: Ls(0, 1) + 1.7, k: 'tick' })
644
+ // shot 2
645
+ for (const j of [3, 4, 5]) c.push({ t: Ls(1, j) + 0.1, k: 'pop', v: 0.7 })
646
+ c.push({ t: Ls(1, 5) + 0.1, k: 'pain', v: 0.6 })
647
+ // shot 3
648
+ const d3 = SHOTS[2].end - S(2)
649
+ for (let k = 0; k < 3; k++) { c.push({ t: S(2) + k * d3 / 3 + 0.05, k: 'thud', v: 0.6 + k * 0.2 }); c.push({ t: S(2) + k * d3 / 3 + 0.35, k: 'pain', v: 0.4 + k * 0.25 }) }
650
+ // shot 4
651
+ const s0 = S(3) + SL[3][1].s - 0.05, seg4 = (SHOTS[3].end - s0) / 4
652
+ for (let k = 0; k < 4; k++) { c.push({ t: s0 + k * seg4, k: 'swipe', v: 0.7 }); c.push({ t: s0 + k * seg4 + 0.45, k: 'pain', v: 0.35 }) }
653
+ // shot 5
654
+ c.push({ t: Ls(4, 1), k: 'click', v: 0.8 }, { t: Ls(4, 3) + 0.5, k: 'scan', v: 0.8 }, { t: Ls(4, 4), k: 'tick' }, { t: Ls(4, 4) + 0.3, k: 'tick' }, { t: Ls(4, 4) + 0.6, k: 'tick' })
655
+ // shot 6
656
+ for (let j = 0; j < 4; j++) c.push({ t: Ls(5, j) - 0.1, k: 'click', v: 0.7 })
657
+ // shot 7
658
+ c.push({ t: Ls(6, 1) + 0.4, k: 'pop', v: 0.6 }, { t: Ls(6, 2), k: 'click', v: 0.8 }, { t: Ls(6, 3) + 0.9, k: 'chime', v: 0.5 })
659
+ // shot 8
660
+ for (let j = 0; j < 4; j++) c.push({ t: S(7) + SL[7][[0, 1, 2, 3][j]].s, k: 'click', v: 0.9 })
661
+ c.push({ t: Ls(7, 0) + 0.2, k: 'scan', v: 0.7 }, { t: Ls(7, 5), k: 'release', v: 1 })
662
+ // shot 9
663
+ c.push({ t: Ls(8, 1) + 0.3, k: 'release', v: 0.6 })
664
+ // shot 10
665
+ const d10 = SHOTS[9].end - S(9)
666
+ for (let k = 1; k < 6; k++) c.push({ t: S(9) + k * d10 / 6, k: 'tick', v: 0.8 })
667
+ // shot 11
668
+ c.push({ t: Ls(10, 0) + 1.4, k: 'pop', v: 0.6 }, { t: Ls(10, 3), k: 'chime', v: 1 })
669
+ // ending
670
+ for (let k = 0; k < 4; k++) c.push({ t: S(11) + 0.6 + k * 0.25, k: 'pop', v: 0.5 })
671
+ return c.sort((a, b) => a.t - b.t)
672
+ }
src/video/state.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { POSE, type SceneState } from './model'
2
+ import type { V3 } from './math'
3
+
4
+ export function baseState(over: Partial<SceneState> = {}): SceneState {
5
+ return {
6
+ cam: { pos: [0, 0.2, 5], target: [0, 0.2, 0], fov: 35 },
7
+ arm: { pos: [0, 0, 0], rot: [0, 0.6, 0] },
8
+ pose: [...POSE.rest],
9
+ skin: 1, xray: 0, bone: 0, tendon: 0, sheath: 0, thick: 0, friction: 0, pain: 0,
10
+ props: {},
11
+ time: 0,
12
+ ...over,
13
+ }
14
+ }
15
+
16
+ /** camera orbiting helper: spherical around target */
17
+ export function orbit(target: V3, dist: number, yaw: number, pitch: number): V3 {
18
+ return [target[0] + dist * Math.sin(yaw) * Math.cos(pitch), target[1] + dist * Math.sin(pitch), target[2] + dist * Math.cos(yaw) * Math.cos(pitch)]
19
+ }
src/video/timelineData.ts ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // generated from public/audio/timeline.json by scripts/gen_tts.py
2
+ export const TL = {
3
+ "voice": "zh-CN-YunyangNeural",
4
+ "rate": "+38%",
5
+ "total": 88.234,
6
+ "shots": [
7
+ {
8
+ "index": 0,
9
+ "title": "疼痛开始",
10
+ "start": 0.0,
11
+ "end": 6.237
12
+ },
13
+ {
14
+ "index": 1,
15
+ "title": "腱鞘炎到底发生了什么",
16
+ "start": 6.237,
17
+ "end": 15.339
18
+ },
19
+ {
20
+ "index": 2,
21
+ "title": "为什么一动就疼",
22
+ "start": 15.339,
23
+ "end": 20.421
24
+ },
25
+ {
26
+ "index": 3,
27
+ "title": "哪些人更容易出现",
28
+ "start": 20.421,
29
+ "end": 26.929
30
+ },
31
+ {
32
+ "index": 4,
33
+ "title": "不要一上来就直接治疗",
34
+ "start": 26.929,
35
+ "end": 34.295
36
+ },
37
+ {
38
+ "index": 5,
39
+ "title": "常见治疗方法",
40
+ "start": 34.295,
41
+ "end": 44.102
42
+ },
43
+ {
44
+ "index": 6,
45
+ "title": "重点进入小针刀",
46
+ "start": 44.102,
47
+ "end": 51.138
48
+ },
49
+ {
50
+ "index": 7,
51
+ "title": "小针刀治疗过程",
52
+ "start": 51.138,
53
+ "end": 61.586
54
+ },
55
+ {
56
+ "index": 8,
57
+ "title": "松解前后对比",
58
+ "start": 61.586,
59
+ "end": 67.382
60
+ },
61
+ {
62
+ "index": 9,
63
+ "title": "从疼痛到恢复",
64
+ "start": 67.382,
65
+ "end": 74.663
66
+ },
67
+ {
68
+ "index": 10,
69
+ "title": "最后的记忆点",
70
+ "start": 74.663,
71
+ "end": 83.494
72
+ },
73
+ {
74
+ "index": 11,
75
+ "title": "评论区互动",
76
+ "start": 83.494,
77
+ "end": 88.234
78
+ }
79
+ ],
80
+ "lines": [
81
+ {
82
+ "shot": 0,
83
+ "text": "手指一弯,手腕一动,",
84
+ "file": "s01_01.mp3",
85
+ "start": 2.3,
86
+ "off": 0.128,
87
+ "dur": 1.186
88
+ },
89
+ {
90
+ "shot": 0,
91
+ "text": "这里为什么突然疼?",
92
+ "file": "s01_02.mp3",
93
+ "start": 3.586,
94
+ "off": 0.155,
95
+ "dur": 0.952
96
+ },
97
+ {
98
+ "shot": 1,
99
+ "text": "腱鞘,本来像一条小通道,",
100
+ "file": "s02_01.mp3",
101
+ "start": 6.387,
102
+ "off": 0.136,
103
+ "dur": 1.386
104
+ },
105
+ {
106
+ "shot": 1,
107
+ "text": "让肌腱顺畅滑动。",
108
+ "file": "s02_02.mp3",
109
+ "start": 7.873,
110
+ "off": 0.156,
111
+ "dur": 1.098
112
+ },
113
+ {
114
+ "shot": 1,
115
+ "text": "但长期反复摩擦,",
116
+ "file": "s02_03.mp3",
117
+ "start": 9.272,
118
+ "off": 0.135,
119
+ "dur": 1.079
120
+ },
121
+ {
122
+ "shot": 1,
123
+ "text": "腱鞘可能出现肿胀、增厚。",
124
+ "file": "s02_04.mp3",
125
+ "start": 10.451,
126
+ "off": 0.143,
127
+ "dur": 1.61
128
+ },
129
+ {
130
+ "shot": 1,
131
+ "text": "通道变得更紧。",
132
+ "file": "s02_05.mp3",
133
+ "start": 12.161,
134
+ "off": 0.172,
135
+ "dur": 0.914
136
+ },
137
+ {
138
+ "shot": 1,
139
+ "text": "肌腱经过这里,就会越来越不顺。",
140
+ "file": "s02_06.mp3",
141
+ "start": 13.174,
142
+ "off": 0.159,
143
+ "dur": 1.765
144
+ },
145
+ {
146
+ "shot": 2,
147
+ "text": "这时候,你越抓、越捏、越拧,",
148
+ "file": "s03_01.mp3",
149
+ "start": 15.489,
150
+ "off": 0.153,
151
+ "dur": 1.82
152
+ },
153
+ {
154
+ "shot": 2,
155
+ "text": "肌腱移动越明显。",
156
+ "file": "s03_02.mp3",
157
+ "start": 17.409,
158
+ "off": 0.135,
159
+ "dur": 1.013
160
+ },
161
+ {
162
+ "shot": 2,
163
+ "text": "疼痛,也可能越来越明显。",
164
+ "file": "s03_03.mp3",
165
+ "start": 18.522,
166
+ "off": 0.155,
167
+ "dur": 1.4
168
+ },
169
+ {
170
+ "shot": 3,
171
+ "text": "哪些人更容易出现?",
172
+ "file": "s04_01.mp3",
173
+ "start": 20.571,
174
+ "off": 0.168,
175
+ "dur": 0.993
176
+ },
177
+ {
178
+ "shot": 3,
179
+ "text": "长期重复用手的人。",
180
+ "file": "s04_02.mp3",
181
+ "start": 21.664,
182
+ "off": 0.147,
183
+ "dur": 1.19
184
+ },
185
+ {
186
+ "shot": 3,
187
+ "text": "经常抓握、捏、拧的人。",
188
+ "file": "s04_03.mp3",
189
+ "start": 22.954,
190
+ "off": 0.144,
191
+ "dur": 1.53
192
+ },
193
+ {
194
+ "shot": 3,
195
+ "text": "还有部分需要频繁抱孩子的新手妈妈。",
196
+ "file": "s04_04.mp3",
197
+ "start": 24.584,
198
+ "off": 0.141,
199
+ "dur": 2.046
200
+ },
201
+ {
202
+ "shot": 4,
203
+ "text": "出现疼痛,不是马上做一种治疗。",
204
+ "file": "s05_01.mp3",
205
+ "start": 27.079,
206
+ "off": 0.142,
207
+ "dur": 1.873
208
+ },
209
+ {
210
+ "shot": 4,
211
+ "text": "先判断位置。",
212
+ "file": "s05_02.mp3",
213
+ "start": 29.052,
214
+ "off": 0.133,
215
+ "dur": 0.747
216
+ },
217
+ {
218
+ "shot": 4,
219
+ "text": "看活动时哪里疼。",
220
+ "file": "s05_03.mp3",
221
+ "start": 29.899,
222
+ "off": 0.144,
223
+ "dur": 1.053
224
+ },
225
+ {
226
+ "shot": 4,
227
+ "text": "必要时结合检查,",
228
+ "file": "s05_04.mp3",
229
+ "start": 31.052,
230
+ "off": 0.145,
231
+ "dur": 0.988
232
+ },
233
+ {
234
+ "shot": 4,
235
+ "text": "判断到底是哪一种腱鞘问题。",
236
+ "file": "s05_05.mp3",
237
+ "start": 32.14,
238
+ "off": 0.163,
239
+ "dur": 1.754
240
+ },
241
+ {
242
+ "shot": 5,
243
+ "text": "症状比较轻,可以先减少诱发动作。",
244
+ "file": "s06_01.mp3",
245
+ "start": 34.445,
246
+ "off": 0.155,
247
+ "dur": 1.964
248
+ },
249
+ {
250
+ "shot": 5,
251
+ "text": "必要时使用支具,配合止痛、抗炎等处理。",
252
+ "file": "s06_02.mp3",
253
+ "start": 36.509,
254
+ "off": 0.146,
255
+ "dur": 2.465
256
+ },
257
+ {
258
+ "shot": 5,
259
+ "text": "症状持续,还可以考虑局部注射等方法。",
260
+ "file": "s06_03.mp3",
261
+ "start": 39.073,
262
+ "off": 0.155,
263
+ "dur": 2.213
264
+ },
265
+ {
266
+ "shot": 5,
267
+ "text": "少数长���不缓解的情况,可能需要手术松解。",
268
+ "file": "s06_04.mp3",
269
+ "start": 41.386,
270
+ "off": 0.141,
271
+ "dur": 2.415
272
+ },
273
+ {
274
+ "shot": 6,
275
+ "text": "那小针刀,到底是在做什么?",
276
+ "file": "s07_01.mp3",
277
+ "start": 44.252,
278
+ "off": 0.163,
279
+ "dur": 1.581
280
+ },
281
+ {
282
+ "shot": 6,
283
+ "text": "不是“扎一下就结束”。",
284
+ "file": "s07_02.mp3",
285
+ "start": 45.933,
286
+ "off": 0.148,
287
+ "dur": 1.022
288
+ },
289
+ {
290
+ "shot": 6,
291
+ "text": "核心是:找到狭窄的位置,",
292
+ "file": "s07_03.mp3",
293
+ "start": 47.055,
294
+ "off": 0.168,
295
+ "dur": 1.418
296
+ },
297
+ {
298
+ "shot": 6,
299
+ "text": "对增厚、卡压的区域进行精准松解。",
300
+ "file": "s07_04.mp3",
301
+ "start": 48.573,
302
+ "off": 0.153,
303
+ "dur": 2.165
304
+ },
305
+ {
306
+ "shot": 7,
307
+ "text": "先定位。",
308
+ "file": "s08_01.mp3",
309
+ "start": 51.238,
310
+ "off": 0.132,
311
+ "dur": 0.427
312
+ },
313
+ {
314
+ "shot": 7,
315
+ "text": "再进行消毒。",
316
+ "file": "s08_02.mp3",
317
+ "start": 52.465,
318
+ "off": 0.141,
319
+ "dur": 0.764
320
+ },
321
+ {
322
+ "shot": 7,
323
+ "text": "必要时进行局部麻醉。",
324
+ "file": "s08_03.mp3",
325
+ "start": 54.129,
326
+ "off": 0.147,
327
+ "dur": 1.419
328
+ },
329
+ {
330
+ "shot": 7,
331
+ "text": "随后在影像或规范定位辅助下,",
332
+ "file": "s08_04.mp3",
333
+ "start": 56.248,
334
+ "off": 0.134,
335
+ "dur": 1.828
336
+ },
337
+ {
338
+ "shot": 7,
339
+ "text": "让器械到达目标区域。",
340
+ "file": "s08_05.mp3",
341
+ "start": 58.176,
342
+ "off": 0.152,
343
+ "dur": 1.206
344
+ },
345
+ {
346
+ "shot": 7,
347
+ "text": "进行松解。",
348
+ "file": "s08_06.mp3",
349
+ "start": 59.882,
350
+ "off": 0.142,
351
+ "dur": 0.604
352
+ },
353
+ {
354
+ "shot": 8,
355
+ "text": "松解前,肌腱经过狭窄区域时,明显受限。",
356
+ "file": "s09_01.mp3",
357
+ "start": 61.736,
358
+ "off": 0.136,
359
+ "dur": 2.51
360
+ },
361
+ {
362
+ "shot": 8,
363
+ "text": "松解后,通道空间改善。",
364
+ "file": "s09_02.mp3",
365
+ "start": 64.346,
366
+ "off": 0.145,
367
+ "dur": 1.359
368
+ },
369
+ {
370
+ "shot": 8,
371
+ "text": "肌腱活动更顺畅。",
372
+ "file": "s09_03.mp3",
373
+ "start": 65.805,
374
+ "off": 0.139,
375
+ "dur": 1.077
376
+ },
377
+ {
378
+ "shot": 9,
379
+ "text": "疼痛改善,不代表马上就能过度使用。",
380
+ "file": "s10_01.mp3",
381
+ "start": 67.532,
382
+ "off": 0.146,
383
+ "dur": 2.072
384
+ },
385
+ {
386
+ "shot": 9,
387
+ "text": "治疗之后,还要根据情况逐步恢复活动。",
388
+ "file": "s10_02.mp3",
389
+ "start": 69.703,
390
+ "off": 0.146,
391
+ "dur": 2.27
392
+ },
393
+ {
394
+ "shot": 9,
395
+ "text": "减少再次刺激。",
396
+ "file": "s10_03.mp3",
397
+ "start": 72.074,
398
+ "off": 0.129,
399
+ "dur": 0.928
400
+ },
401
+ {
402
+ "shot": 9,
403
+ "text": "让手部功能慢慢回来。",
404
+ "file": "s10_04.mp3",
405
+ "start": 73.102,
406
+ "off": 0.139,
407
+ "dur": 1.261
408
+ },
409
+ {
410
+ "shot": 10,
411
+ "text": "所以腱鞘炎,真正要解决的不是“忍住疼”。",
412
+ "file": "s11_01.mp3",
413
+ "start": 74.813,
414
+ "off": 0.151,
415
+ "dur": 2.365
416
+ },
417
+ {
418
+ "shot": 10,
419
+ "text": "而是找到疼痛的原因,选择合适的治疗方式。",
420
+ "file": "s11_02.mp3",
421
+ "start": 77.278,
422
+ "off": 0.268,
423
+ "dur": 2.484
424
+ },
425
+ {
426
+ "shot": 10,
427
+ "text": "记住一句话:",
428
+ "file": "s11_03.mp3",
429
+ "start": 79.862,
430
+ "off": 0.147,
431
+ "dur": 0.576
432
+ },
433
+ {
434
+ "shot": 10,
435
+ "text": "手越疼,越不能只靠硬扛。",
436
+ "file": "s11_04.mp3",
437
+ "start": 80.788,
438
+ "off": 0.148,
439
+ "dur": 1.606
440
+ },
441
+ {
442
+ "shot": 11,
443
+ "text": "你是哪个动作一做,手腕或者手指就开始疼?",
444
+ "file": "s12_01.mp3",
445
+ "start": 83.644,
446
+ "off": 0.147,
447
+ "dur": 2.29
448
+ }
449
+ ]
450
+ } as const