Spaces:
Running
Running
Step 3-4: 3D 解剖模型与分镜时间轴
Browse files- README.md +3 -2
- scripts/gen_tts.py +5 -0
- scripts/shot.py +25 -0
- scripts/stat.py +10 -0
- src/App.tsx +7 -20
- src/video/Debug.tsx +35 -0
- src/video/TDebug.tsx +37 -0
- src/video/engine.ts +154 -0
- src/video/math.ts +37 -0
- src/video/model.ts +826 -0
- src/video/overlay.ts +386 -0
- src/video/scenes.ts +672 -0
- src/video/state.ts +19 -0
- src/video/timelineData.ts +450 -0
README.md
CHANGED
|
@@ -58,8 +58,8 @@ pinned: false
|
|
| 58 |
|
| 59 |
1. ✅ 编写本计划 README,初始化 Space(static)。
|
| 60 |
2. ✅ 生成中文旁白逐句音频 + 时间轴清单(`timeline.json`)。
|
| 61 |
-
3.
|
| 62 |
-
4.
|
| 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 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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
|