animations

This commit is contained in:
ryanfitzpatrickio
2026-08-03 16:04:46 -05:00
parent 8b28e299e6
commit fb1ebeed05
28 changed files with 5246 additions and 105 deletions
+1
View File
@@ -6,3 +6,4 @@ shots/
.dev.vars .dev.vars
.dev.vars.* .dev.vars.*
!.dev.vars.example !.dev.vars.example
.freemocap-venv/
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+24
View File
@@ -27,6 +27,27 @@ npm run capture # boots the app headless and screenshots it into shots/
npm run deploy # vite build + Cloudflare Workers static assets npm run deploy # vite build + Cloudflare Workers static assets
``` ```
The browser-based **Animation Studio** lives at
[`/animation.html`](http://localhost:5174/animation.html). Upload a reference
video, generate pose keys directly on Tilt's 23-bone rig, correct individual
bones with the rotation gizmo, optionally seed and bake butt/grip/blade stick
landmarks, and export either reloadable
`.tiltanim.json` or a JavaScript pose module for `src/anim/poses/generated/`.
The Capture source menu has two mocap paths:
- **Browser MediaPipe** processes one uploaded video locally for the quickest
reference-to-pose workflow.
- **Local FreeMoCap** sends the same uploaded video through an isolated local
[FreeMoCap](https://github.com/freemocap/freemocap) worker, then loads the
filtered motion directly into the editor. Run `npm run freemocap:setup` once
before `npm run dev`. Existing `freemocap_data_by_frame.csv` and
`*_body_3d_xyz.csv` files remain available through the advanced import.
The FreeMoCap worker is intentionally local-development only; a static
Cloudflare deployment cannot run its Python/native processing stack. Browser
MediaPipe remains available everywhere.
Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**. Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**.
In the browser: In the browser:
@@ -134,6 +155,9 @@ src/
poses/goalie.js ready, butterfly, shuffle, reach poses/goalie.js ready, butterfly, shuffle, reach
studio/ studio/
img2mesh.js character studio: pose presets, fixed views, capture API img2mesh.js character studio: pose presets, fixed views, capture API
animationStudio.js reference mocap, pose editor, timeline, clip storage
mediapipePose.js browser pose detection and native-rig retargeting
freemocapImport.js FreeMoCap body XYZ CSV adapter
render/ rink, materials, camera render/ rink, materials, camera
game/ game/
match.js the loop match.js the loop
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>tilt — animation studio</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🎞️</text></svg>">
<style>
:root {
color-scheme:dark; --bg:#080b10; --panel:#0e141d; --panel2:#111a25; --line:#223245;
--muted:#718399; --text:#dce7f0; --accent:#68e0c2; --warm:#f6c85f; --danger:#ef6b73;
}
* { box-sizing:border-box; }
[hidden] { display:none !important; }
html,body { margin:0; min-height:100%; background:var(--bg); color:var(--text);
font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif; }
body { height:100vh; overflow:hidden; display:grid; grid-template-rows:54px minmax(0,1fr) 160px; }
button,input,select { font:inherit; }
button { border:1px solid var(--line); background:#152131; color:var(--text); border-radius:6px;
padding:7px 10px; cursor:pointer; }
button:hover:not(:disabled) { border-color:#4c708f; background:#1a2a3d; }
button:disabled { opacity:.45; cursor:default; }
button.primary { border-color:#338b78; background:#12382f; color:#a8f3df; }
button.danger { color:#ffabb0; }
input,select { min-width:0; border:1px solid var(--line); background:#0b1119; color:var(--text);
border-radius:5px; padding:6px 8px; }
input[type=range] { padding:0; accent-color:var(--accent); }
input[type=checkbox] { accent-color:var(--accent); }
.topbar { min-width:0; display:flex; align-items:center; gap:12px; padding:0 14px; border-bottom:1px solid var(--line);
background:#0b1017; }
.brand { letter-spacing:.22em; font-size:12px; font-weight:800; white-space:nowrap; }
.topbar .brand,.topbar button,.topbar .crumb { flex:0 0 auto; }
.brand span { color:var(--accent); }
.crumb { color:#405064; }
#clipName { width:min(240px,25vw); min-width:120px; flex:0 1 240px; }
.spacer { flex:1; }
.save-state { color:var(--muted); font-size:11px; min-width:74px; max-width:170px; overflow:hidden;
white-space:nowrap; text-overflow:ellipsis; text-align:right; }
.workspace { min-height:0; display:grid; grid-template-columns:minmax(270px,.82fr) minmax(390px,1.45fr) 286px; }
.pane { min-width:0; min-height:0; position:relative; border-right:1px solid var(--line); background:var(--panel); }
.pane-title { height:36px; display:flex; align-items:center; justify-content:space-between; padding:0 11px;
border-bottom:1px solid var(--line); color:#91a4b8; font-size:10px; font-weight:750; letter-spacing:.14em; text-transform:uppercase; }
.video-wrap { position:absolute; inset:36px 0 0; display:grid; place-items:center; overflow:hidden; background:#05070a; }
#referenceVideo { max-width:100%; max-height:100%; width:100%; height:100%; object-fit:contain; }
#poseOverlay { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; pointer-events:none; }
#poseOverlay.picking { pointer-events:auto; cursor:crosshair; }
.video-empty { position:absolute; inset:0; display:grid; place-items:center; padding:24px; text-align:center;
color:var(--muted); font-size:12px; background:radial-gradient(circle at 50% 45%,#111b26,#06080c 72%); }
.video-empty[hidden] { display:none; }
.video-empty strong { display:block; color:var(--text); font-size:17px; margin-bottom:7px; }
.video-empty small { display:block; margin-top:10px; color:#54677b; line-height:1.5; }
.stage-pane { background:#0a0f16; }
#stage { position:absolute; inset:36px 0 0; width:100%; height:calc(100% - 36px); display:block; touch-action:none; }
.stage-hint { position:absolute; left:12px; bottom:10px; color:#587086; font-size:10px; pointer-events:none; }
.confidence { font-variant-numeric:tabular-nums; color:var(--accent); }
.inspector { min-height:0; overflow:auto; background:#0b1119; }
.section { padding:12px; border-bottom:1px solid var(--line); }
.section h2 { margin:0 0 10px; color:#8fa6bb; font-size:10px; letter-spacing:.14em; text-transform:uppercase; }
.stack { display:grid; gap:7px; }
.row { display:flex; gap:7px; align-items:center; }
.row > * { flex:1; }
.field { display:grid; grid-template-columns:58px minmax(0,1fr); gap:7px; align-items:center; color:var(--muted); font-size:11px; }
.field.compact { grid-template-columns:38px minmax(0,1fr) 52px; }
.field input[type=number] { width:52px; padding:5px; text-align:right; }
.toggle { display:flex; align-items:center; gap:7px; color:#91a4b8; font-size:11px; }
#status { min-height:32px; color:var(--muted); font-size:10px; line-height:1.5; }
.hint { color:var(--muted); font-size:10px; line-height:1.5; }
progress { width:100%; height:6px; border:0; accent-color:var(--accent); }
.timeline { min-width:0; display:grid; grid-template-rows:42px 1fr; background:#0a0f16; border-top:1px solid var(--line); }
.transport { display:flex; align-items:center; gap:7px; padding:0 12px; border-bottom:1px solid var(--line); }
.transport button { padding:5px 9px; }
.timecode { width:92px; color:var(--warm); font:600 12px ui-monospace,SFMono-Regular,monospace; }
.timeline-main { position:relative; min-height:0; padding:24px 15px 12px; }
#scrubber { position:absolute; left:15px; right:15px; top:10px; width:calc(100% - 30px); margin:0; z-index:3; }
#markers { position:absolute; left:22px; right:22px; top:49px; height:40px; border-top:1px solid #33475d;
background:repeating-linear-gradient(90deg,transparent 0,transparent calc(5% - 1px),#172536 5%); }
.key-marker { position:absolute; top:-7px; width:12px; height:12px; padding:0; transform:translateX(-50%) rotate(45deg);
border:1px solid #bf953a; border-radius:2px; background:var(--warm); }
.key-marker.current { background:var(--accent); border-color:#b9ffed; box-shadow:0 0 10px #68e0c288; }
.timeline-labels { position:absolute; left:22px; right:22px; top:95px; display:flex; justify-content:space-between;
color:#4c6074; font:10px ui-monospace,SFMono-Regular,monospace; }
#fileInput,#clipInput,#freeMocapInput { display:none; }
@media (max-width:980px) {
body { height:auto; min-height:100vh; overflow:auto; display:block; }
.topbar { height:auto; min-height:54px; flex-wrap:wrap; padding:9px 12px; }
.workspace { grid-template-columns:1fr; grid-template-rows:360px 500px auto; }
.pane { border-right:0; border-bottom:1px solid var(--line); }
.inspector { overflow:visible; }
.timeline { height:160px; position:sticky; bottom:0; z-index:10; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span>TILT</span> / ANIMATION</div><span class="crumb"></span>
<input id="clipName" value="reference-motion" aria-label="clip name">
<span id="saveState" class="save-state">new clip</span><span class="spacer"></span>
<button id="loadProject">Open saved</button><button id="saveProject">Save</button>
<button id="importClip">Import</button><button id="exportJson">Export JSON</button>
<button id="exportModule" class="primary">Export pose module</button>
</header>
<main class="workspace">
<section class="pane">
<div class="pane-title"><span>Reference video</span><button id="chooseVideo">Choose video</button></div>
<div class="video-wrap">
<video id="referenceVideo" playsinline muted></video><canvas id="poseOverlay"></canvas>
<div id="videoEmpty" class="video-empty"><div><strong>Drop in movement</strong>Upload a side or ¾ view with the full body visible.<small>MP4, WebM, or MOV supported by your browser.<br>The file stays on this device.</small></div></div>
</div>
</section>
<section class="pane stage-pane">
<div class="pane-title"><span>Tilt rig preview</span><span id="confidence" class="confidence">NO TRACK</span></div>
<canvas id="stage"></canvas><div class="stage-hint">drag background to orbit · click a joint to edit · Q/W rotate gizmo</div>
</section>
<aside class="inspector">
<section class="section"><h2>Capture</h2><div class="stack">
<div class="field"><label for="mocapSource">source</label><select id="mocapSource"><option value="browser">Browser MediaPipe</option><option value="freemocap">Local FreeMoCap</option></select></div>
<div id="browserMocapControls" class="stack">
<div class="field"><label for="captureFps">sample fps</label><input id="captureFps" type="number" min="1" max="30" value="12"></div>
<div class="row"><div class="field"><label for="trimIn">in</label><input id="trimIn" type="number" min="0" step="0.01" value="0"></div><div class="field"><label for="trimOut">out</label><input id="trimOut" type="number" min="0" step="0.01" value="0"></div></div>
<button id="extract" class="primary">Generate keys from video</button>
</div>
<div id="freeMocapControls" class="stack" hidden>
<div class="field"><label for="freeMocapFps">fallback fps</label><input id="freeMocapFps" type="number" min="1" max="240" value="30"></div>
<button id="importFreeMocap" class="primary">Process uploaded video with FreeMoCap</button>
<div id="freeMocapState" class="hint">Checking local worker…</div>
<button id="importFreeMocapCsv">Advanced: import existing XYZ CSV</button>
<input id="freeMocapInput" type="file" accept=".csv,text/csv">
</div>
<label class="toggle"><input id="mirrorPose" type="checkbox"> mirror source pose</label>
<progress id="progress" max="1" value="0"></progress>
<div id="status">Choose a video, set a short in/out range, then generate.</div>
</div></section>
<section class="section"><h2>Stick landmarks</h2><div class="stack">
<div class="field"><label for="shotSide">socket</label><select id="shotSide"><option value="right">right shot · right top hand</option><option value="left">left shot · left top hand</option></select></div>
<label class="toggle"><input id="trackStick" type="checkbox"> bake stick with body keys</label>
<div class="row"><button id="seedStick">Mark both ends at IN</button><button id="clearStick">Clear</button></div>
<button id="correctStick">Correct stick on current key</button>
<button id="applyStick" class="primary">Apply guide to rig + key</button>
<button id="flipStick">Flip baked stick direction</button>
<div id="stickStatus" class="hint">Choose the top-hand socket, then mark both shaft ends in either order.</div>
</div></section>
<section class="section"><h2>Keyframe pose</h2><div class="stack">
<div class="field"><label for="boneSelect">bone</label><select id="boneSelect"></select></div>
<div class="field compact"><label>X</label><input class="rot" data-axis="x" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="x" type="number" min="-360" max="360" step="1"></div>
<div class="field compact"><label>Y</label><input class="rot" data-axis="y" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="y" type="number" min="-360" max="360" step="1"></div>
<div class="field compact"><label>Z</label><input class="rot" data-axis="z" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="z" type="number" min="-360" max="360" step="1"></div>
<div class="row"><button id="resetBone">Reset bone</button><button id="setKey" class="primary">Set / update key</button></div>
<div class="row"><button id="deleteKey" class="danger">Delete key</button><button id="smoothKeys">Smooth imported keys</button></div>
</div></section>
<section class="section"><h2>Clip</h2><div class="stack">
<div class="field"><label for="loopClip">playback</label><select id="loopClip"><option value="loop">loop</option><option value="once">play once</option></select></div>
<div class="field"><span>keys</span><strong id="keyCount">0</strong></div>
<div class="field"><span>duration</span><strong id="durationLabel">0.00 s</strong></div>
<button id="newClip">New empty clip</button>
</div></section>
</aside>
</main>
<footer class="timeline">
<div class="transport"><button id="prevKey" title="previous key">◀|</button><button id="playPause" class="primary">▶ Play</button><button id="nextKey" title="next key">|▶</button><span id="timecode" class="timecode">00:00.000</span><span id="timelineSource" class="save-state">clip</span></div>
<div class="timeline-main"><input id="scrubber" type="range" min="0" max="1" step="0.001" value="0"><div id="markers"></div><div class="timeline-labels"><span>0.00</span><span id="timelineEnd">1.00 s</span></div></div>
</footer>
<input id="fileInput" type="file" accept="video/*"><input id="clipInput" type="file" accept="application/json,.json,.tiltanim">
<script type="module" src="/src/studio/animationStudio.js"></script>
</body>
</html>
+3 -1
View File
@@ -66,6 +66,8 @@
#menu .foot { #menu .foot {
margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em; margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em;
} }
#menu .studio-link { margin-top:14px; color:#6a849c; font-size:11px; text-decoration:none; }
#menu .studio-link:hover { color:#9fc6e5; }
</style> </style>
</head> </head>
<body> <body>
@@ -88,9 +90,9 @@
</button> </button>
</div> </div>
<p class="foot">Esc returns here · pad or keyboard once you&rsquo;re in</p> <p class="foot">Esc returns here · pad or keyboard once you&rsquo;re in</p>
<a class="studio-link" href="/animation.html">Open animation studio →</a>
</div> </div>
<div id="boot">TILT&hellip;</div> <div id="boot">TILT&hellip;</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+7
View File
@@ -8,6 +8,7 @@
"name": "tilt", "name": "tilt",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@mediapipe/tasks-vision": "^0.10.35",
"box3d.js": "^0.0.2", "box3d.js": "^0.0.2",
"three": "^0.185.1" "three": "^0.185.1"
}, },
@@ -1231,6 +1232,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+3 -1
View File
@@ -8,17 +8,19 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs", "test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/animationClip.mjs && node test/freemocapImport.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs",
"capture": "node tools/capture.mjs", "capture": "node tools/capture.mjs",
"img2mesh": "node tools/img2mesh.mjs", "img2mesh": "node tools/img2mesh.mjs",
"img2mesh:player": "node tools/img2mesh.mjs --subject player", "img2mesh:player": "node tools/img2mesh.mjs --subject player",
"img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie", "img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie",
"freemocap:setup": "sh tools/setup-freemocap.sh",
"deploy": "npm run build && wrangler deploy", "deploy": "npm run build && wrangler deploy",
"deploy:dry": "npm run build && wrangler deploy --dry-run", "deploy:dry": "npm run build && wrangler deploy --dry-run",
"pages:deploy": "npm run build && wrangler pages deploy dist --project-name=tilt", "pages:deploy": "npm run build && wrangler pages deploy dist --project-name=tilt",
"cf:whoami": "wrangler whoami" "cf:whoami": "wrangler whoami"
}, },
"dependencies": { "dependencies": {
"@mediapipe/tasks-vision": "^0.10.35",
"box3d.js": "^0.0.2", "box3d.js": "^0.0.2",
"three": "^0.185.1" "three": "^0.185.1"
}, },
+121
View File
@@ -0,0 +1,121 @@
/**
* Player definition — identity traits that are not sim state.
*
* Appearance (body style, jersey) and locomotion (velocity, effort) live
* elsewhere. This bag is the stable "who is this skater" record: how they hold
* a stick, which side they shoot from, and anything else that picks a pose set
* rather than a frame of motion.
*
* Kept plain and three.js-free so the same record can travel over the wire.
*/
/** Which side of the body the stick lives on, and which way they shoot. */
export const SHOT_SIDE = Object.freeze({
LEFT: 'left',
RIGHT: 'right',
});
/**
* Defaults for a new player bag.
*
* Authored stickwork (GRIP targets, arm poses) is written for a **right**
* shot: top hand on the right, lower hand on the left, forehand at X in
* skater space. A **left** shot mirrors that set across the body midline.
*/
export const PLAYER_DEFAULTS = Object.freeze({
shotSide: SHOT_SIDE.RIGHT,
});
/**
* Accept the spellings we are likely to see and fold them to `'left' | 'right'`.
*
* @param {unknown} v
* @returns {'left' | 'right'}
*/
export function normalizeShotSide(v) {
if (v === SHOT_SIDE.LEFT || v === 'L' || v === 'l' || v === -1 || v === false) {
return SHOT_SIDE.LEFT;
}
if (v === SHOT_SIDE.RIGHT || v === 'R' || v === 'r' || v === 1 || v === true) {
return SHOT_SIDE.RIGHT;
}
return PLAYER_DEFAULTS.shotSide;
}
/**
* Sign used to mirror authored right-shot content onto a left shot.
* `+1` = as authored (right), `-1` = mirror across the sagittal plane (left).
*
* @param {unknown} side
* @returns {1 | -1}
*/
export function shotSign(side) {
return normalizeShotSide(side) === SHOT_SIDE.LEFT ? -1 : 1;
}
/**
* Top hand on the stick for this shot side.
* Right shot: right hand on top. Left shot: left hand on top.
*
* @param {unknown} side
* @returns {'L' | 'R'}
*/
export function topHandFor(side) {
return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'L' : 'R';
}
/**
* Lower (blade-side) hand — the one IK pins to the shaft.
*
* @param {unknown} side
* @returns {'L' | 'R'}
*/
export function lowerHandFor(side) {
return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'R' : 'L';
}
/**
* Rough real-world split (~62% left shot in the NHL). Used when a roster
* entry does not specify a side, so a lineup is not all clones.
*
* @param {number} u01 uniform in [0, 1)
* @returns {'left' | 'right'}
*/
export function rollShotSide(u01) {
const u = Number.isFinite(u01) ? u01 : 0.5;
return u < 0.62 ? SHOT_SIDE.LEFT : SHOT_SIDE.RIGHT;
}
/**
* Normalize a partial player bag. Missing keys take the defaults.
*
* @param {unknown} raw
* @returns {{ shotSide: 'left' | 'right' }}
*/
export function normalizePlayer(raw) {
if (!raw || typeof raw !== 'object') {
return { shotSide: PLAYER_DEFAULTS.shotSide };
}
return {
shotSide: normalizeShotSide(/** @type {{ shotSide?: unknown }} */ (raw).shotSide),
};
}
/**
* Compact wire form for roster snapshots.
* @returns {{ ss: 'L' | 'R' }}
*/
export function packPlayer(player) {
const p = normalizePlayer(player);
return { ss: p.shotSide === SHOT_SIDE.LEFT ? 'L' : 'R' };
}
/** Inverse of packPlayer — also accepts a full `{ shotSide }` bag. */
export function unpackPlayer(raw) {
if (!raw || typeof raw !== 'object') return normalizePlayer(null);
if ('shotSide' in raw) return normalizePlayer(raw);
const ss = /** @type {{ ss?: unknown }} */ (raw).ss;
if (ss === 'L' || ss === 'l') return { shotSide: SHOT_SIDE.LEFT };
if (ss === 'R' || ss === 'r') return { shotSide: SHOT_SIDE.RIGHT };
return normalizePlayer(null);
}
+8
View File
@@ -1,5 +1,6 @@
import { clamp, lerpAngle, wrapAngle } from './scalar.js'; import { clamp, lerpAngle, wrapAngle } from './scalar.js';
import { clampToRink } from './rink.js'; import { clampToRink } from './rink.js';
import { normalizePlayer } from './player.js';
/** /**
* Skating locomotion. * Skating locomotion.
@@ -55,6 +56,7 @@ export const SKATE = Object.freeze({
}); });
export function createSkaterState(id, spawn = {}, opts = {}) { export function createSkaterState(id, spawn = {}, opts = {}) {
const player = normalizePlayer(opts.player ?? opts);
return { return {
id, id,
name: opts.name ?? `Skater ${id}`, name: opts.name ?? `Skater ${id}`,
@@ -62,6 +64,12 @@ export function createSkaterState(id, spawn = {}, opts = {}) {
seed: opts.seed ?? 1337, seed: opts.seed ?? 1337,
team: opts.team ?? 0, team: opts.team ?? 0,
/**
* Stable identity traits — shot side, and anything else that picks a pose
* set rather than a frame of motion. See `shared/player.js`.
*/
shotSide: player.shotSide,
x: spawn.x ?? 0, x: spawn.x ?? 0,
y: 0, y: 0,
z: spawn.z ?? 0, z: spawn.z ?? 0,
+345
View File
@@ -0,0 +1,345 @@
import * as THREE from 'three';
import { BONEDEF } from '../character/skeleton.js';
/**
* Compact animation format shared by the reference-video studio and runtime.
*
* The tracks are local bone quaternions, just like the authored pose functions
* in `anim/poses`: they are independent of body proportions and can be applied
* directly to every Tilt skater built from the 23-bone skeleton.
*/
export const TILT_CLIP_FORMAT = 'tilt-animation';
export const TILT_CLIP_VERSION = 1;
export const TILT_RIG = 'tilt-23';
export const CLIP_BONES = BONEDEF.map(([name]) => name);
const _qa = new THREE.Quaternion();
const _qb = new THREE.Quaternion();
const _stickTarget = new THREE.Vector3();
const _stickHand = new THREE.Vector3();
const _stickHandLocal = new THREE.Vector3();
const _stickHandQ = new THREE.Quaternion();
const _stickDirection = new THREE.Vector3();
const _stickLowerHand = new THREE.Vector3();
const _sampledStick = {};
const STICK_TRACK_REACH = 1.12;
export function createTiltClip({ name = 'reference-motion', fps = 12, loop = true, shotSide = 'right' } = {}) {
return {
format: TILT_CLIP_FORMAT,
version: TILT_CLIP_VERSION,
rig: TILT_RIG,
name,
fps,
loop,
shotSide: shotSide === 'left' ? 'left' : 'right',
duration: 0,
keyframes: [],
};
}
function finiteNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normalizedQuat(value) {
if (!Array.isArray(value) || value.length !== 4) return [0, 0, 0, 1];
_qa.set(
finiteNumber(value[0]),
finiteNumber(value[1]),
finiteNumber(value[2]),
finiteNumber(value[3], 1),
);
if (_qa.lengthSq() < 1e-8) _qa.identity();
else _qa.normalize();
return _qa.toArray();
}
function finiteArray(value, length) {
if (!Array.isArray(value) || value.length !== length) return null;
const out = value.map((n) => finiteNumber(n));
return out.every(Number.isFinite) ? out : null;
}
function sanitizeStick(stick) {
if (!stick) return null;
const butt = finiteArray(stick.butt, 2);
const grip = finiteArray(stick.grip, 2);
const blade = finiteArray(stick.blade, 2);
const target = finiteArray(stick.target, 3);
if (!butt || !blade || !target) return null;
const dx = blade[0] - butt[0];
const dy = blade[1] - butt[1];
const suppliedAngle = Number(stick.angle);
return {
butt: butt.map((n) => Math.max(0, Math.min(1, n))),
grip: (grip ?? butt).map((n) => Math.max(0, Math.min(1, n))),
blade: blade.map((n) => Math.max(0, Math.min(1, n))),
target,
// Older clips only stored the two 2D marks and an unstable affine target.
// Deriving the camera-plane shaft angle here migrates them on load.
angle: Number.isFinite(suppliedAngle) ? suppliedAngle : Math.atan2(-dy, dx),
roll: finiteNumber(stick.roll),
alignHands: stick.alignHands === true,
confidence: Math.max(0, Math.min(1, finiteNumber(stick.confidence, 1))),
};
}
export function sanitizeKeyframe(frame) {
const rotations = {};
for (const name of CLIP_BONES) {
if (frame?.rotations?.[name]) rotations[name] = normalizedQuat(frame.rotations[name]);
}
const root = Array.isArray(frame?.root) && frame.root.length === 3
? frame.root.map((n) => finiteNumber(n))
: [0, 0, 0];
const clean = {
time: Math.max(0, finiteNumber(frame?.time)),
root,
rotations,
confidence: Math.max(0, Math.min(1, finiteNumber(frame?.confidence, 1))),
};
const stick = sanitizeStick(frame?.stick);
if (stick) clean.stick = stick;
return clean;
}
/**
* Keep a detected shaft line on one continuous angular branch.
*
* Video tracking sees a thin line much more reliably than it identifies which
* end is which, so the same line can arrive as angle or angle + PI. Choosing
* the representation nearest the previous key removes those false half-turns
* without changing the visible 2D shaft line.
*/
function stabilizeStickAngles(clip) {
let previous = null;
for (const frame of clip.keyframes) {
if (!frame.stick || !Number.isFinite(frame.stick.angle)) continue;
let angle = frame.stick.angle;
if (previous !== null) {
while (angle - previous > Math.PI / 2) angle -= Math.PI;
while (angle - previous < -Math.PI / 2) angle += Math.PI;
}
frame.stick.angle = angle;
previous = angle;
}
return clip;
}
export function sanitizeTiltClip(input) {
if (!input || input.format !== TILT_CLIP_FORMAT) {
throw new Error('Not a Tilt animation clip');
}
if (Number(input.version) !== TILT_CLIP_VERSION) {
throw new Error(`Unsupported Tilt animation version: ${input.version}`);
}
if (input.rig !== TILT_RIG) throw new Error(`Clip targets ${input.rig}, expected ${TILT_RIG}`);
const clip = createTiltClip({
name: String(input.name || 'reference-motion'),
fps: Math.max(1, Math.min(60, finiteNumber(input.fps, 12))),
loop: input.loop !== false,
shotSide: input.shotSide,
});
clip.keyframes = (Array.isArray(input.keyframes) ? input.keyframes : [])
.map(sanitizeKeyframe)
.sort((a, b) => a.time - b.time);
// Later duplicate frames win. This also keeps the timeline deterministic.
clip.keyframes = clip.keyframes.filter((frame, i, all) => (
i === all.length - 1 || Math.abs(all[i + 1].time - frame.time) > 1e-5
));
stabilizeStickAngles(clip);
clip.duration = clip.keyframes.length
? Math.max(finiteNumber(input.duration), clip.keyframes.at(-1).time)
: Math.max(0, finiteNumber(input.duration));
return clip;
}
export function captureSkeletonKeyframe(skelData, time, { confidence = 1 } = {}) {
const rotations = {};
for (const name of CLIP_BONES) rotations[name] = skelData.bones[name].quaternion.toArray();
return sanitizeKeyframe({
time,
root: skelData.bones.root.position.toArray(),
rotations,
confidence,
});
}
export function setClipKeyframe(clip, frame, epsilon = 1 / 240) {
const next = sanitizeKeyframe(frame);
const index = clip.keyframes.findIndex((item) => Math.abs(item.time - next.time) <= epsilon);
if (index >= 0) clip.keyframes[index] = next;
else clip.keyframes.push(next);
clip.keyframes.sort((a, b) => a.time - b.time);
clip.duration = Math.max(clip.duration || 0, next.time);
return next;
}
export function deleteClipKeyframe(clip, time, epsilon = 1 / 240) {
const index = clip.keyframes.findIndex((item) => Math.abs(item.time - time) <= epsilon);
if (index < 0) return false;
clip.keyframes.splice(index, 1);
clip.duration = clip.keyframes.length ? clip.keyframes.at(-1).time : 0;
return true;
}
export function frameSpan(clip, rawTime) {
const frames = clip.keyframes;
if (!frames.length) return null;
const duration = Math.max(clip.duration || 0, frames.at(-1).time);
let time = finiteNumber(rawTime);
if (clip.loop && duration > 0) time = ((time % duration) + duration) % duration;
else time = Math.max(0, Math.min(duration, time));
if (time <= frames[0].time) return { a: frames[0], b: frames[0], alpha: 0, time };
if (time >= frames.at(-1).time) return { a: frames.at(-1), b: frames.at(-1), alpha: 0, time };
let lo = 0;
let hi = frames.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (frames[mid].time <= time) lo = mid;
else hi = mid;
}
const a = frames[lo];
const b = frames[hi];
const alpha = (time - a.time) / Math.max(1e-6, b.time - a.time);
return { a, b, alpha, time };
}
export function applyTiltClip(skelData, clip, time) {
const span = frameSpan(clip, time);
if (!span) return false;
const { a, b, alpha } = span;
for (const name of CLIP_BONES) {
const av = a.rotations[name] ?? [0, 0, 0, 1];
const bv = b.rotations[name] ?? av;
_qa.fromArray(av);
_qb.fromArray(bv);
skelData.bones[name].quaternion.slerpQuaternions(_qa, _qb, alpha);
}
skelData.bones.root.position.set(
a.root[0] + (b.root[0] - a.root[0]) * alpha,
a.root[1] + (b.root[1] - a.root[1]) * alpha,
a.root[2] + (b.root[2] - a.root[2]) * alpha,
);
skelData.rootBone.updateMatrixWorld(true);
return true;
}
function lerpArray(a, b, alpha, out) {
for (let i = 0; i < a.length; i++) out[i] = a[i] + (b[i] - a[i]) * alpha;
return out;
}
/** Sample the optional baked stick landmarks and rig-local blade target. */
export function sampleTiltStick(clip, time, out = {}) {
const span = frameSpan(clip, time);
if (!span) return null;
let a = span.a.stick;
let b = span.b.stick;
if (!a && !b) return null;
if (!a) a = b;
if (!b) b = a;
const alpha = span.alpha;
out.butt = lerpArray(a.butt, b.butt, alpha, out.butt ?? [0, 0]);
out.grip = lerpArray(a.grip, b.grip, alpha, out.grip ?? [0, 0]);
out.blade = lerpArray(a.blade, b.blade, alpha, out.blade ?? [0, 0]);
out.target = lerpArray(a.target, b.target, alpha, out.target ?? [0, 0, 0]);
const angleA = Number.isFinite(a.angle)
? a.angle
: Math.atan2(-(a.blade[1] - a.butt[1]), a.blade[0] - a.butt[0]);
const angleB = Number.isFinite(b.angle)
? b.angle
: Math.atan2(-(b.blade[1] - b.butt[1]), b.blade[0] - b.butt[0]);
const angleDelta = Math.atan2(Math.sin(angleB - angleA), Math.cos(angleB - angleA));
out.angle = angleA + angleDelta * alpha;
out.roll = a.roll + (b.roll - a.roll) * alpha;
out.alignHands = alpha < 0.5 ? a.alignHands === true : b.alignHands === true;
out.confidence = a.confidence + (b.confidence - a.confidence) * alpha;
return out;
}
/** Aim only the real stick, leaving the currently edited skeleton pose intact. */
export function applyTiltStickPose(skater, stick) {
if (stick && skater.stick) {
// The socket bone is the source of truth. Skaters can shoot from either
// side, and assuming handR here made left-shot clips orbit the wrong hand.
const socketBone = skater.stick.group.parent;
if (!socketBone?.isBone) return false;
const lowerSide = socketBone === skater.skelData.bones.handL ? 'R' : 'L';
const lowerBone = skater.skelData.bones[`hand${lowerSide}`];
socketBone.getWorldPosition(_stickHand);
socketBone.getWorldQuaternion(_stickHandQ).invert();
if (stick.alignHands && lowerBone && typeof skater.stick.aimThroughHands === 'function') {
lowerBone.getWorldPosition(_stickLowerHand);
skater.stick.aimThroughHands(_stickHand, _stickLowerHand, _stickHandQ, stick.roll);
} else {
if (Number.isFinite(stick.angle)) {
socketBone.getWorldPosition(_stickHand);
_stickHandLocal.copy(_stickHand);
skater.mover.worldToLocal(_stickHandLocal);
_stickDirection.set(Math.cos(stick.angle), Math.sin(stick.angle), 0).multiplyScalar(STICK_TRACK_REACH);
_stickTarget.copy(_stickHandLocal).add(_stickDirection);
} else _stickTarget.fromArray(stick.target);
skater.mover.localToWorld(_stickTarget);
skater.stick.aimAt(_stickTarget, _stickHand, _stickHandQ, stick.roll);
}
skater.mover.updateMatrixWorld(true);
return true;
}
return false;
}
/** Sample and aim only the stick, without reloading the skeleton key. */
export function applyTiltStick(skater, clip, time) {
const stick = sampleTiltStick(clip, time, _sampledStick);
return applyTiltStickPose(skater, stick);
}
/** Apply the skeleton pose and, when present, aim the skater's real stick. */
export function applyTiltAnimation(skater, clip, time) {
if (!applyTiltClip(skater.skelData, clip, time)) return false;
applyTiltStick(skater, clip, time);
return true;
}
/** One smoothing pass over imported quaternion tracks; first/last stay fixed. */
export function smoothTiltClip(clip, amount = 0.35) {
const weight = Math.max(0, Math.min(1, amount));
if (clip.keyframes.length < 3 || weight === 0) return clip;
stabilizeStickAngles(clip);
const source = clip.keyframes.map((frame) => structuredClone(frame));
for (let i = 1; i < clip.keyframes.length - 1; i++) {
for (const name of CLIP_BONES) {
const prev = source[i - 1].rotations[name];
const cur = source[i].rotations[name];
const next = source[i + 1].rotations[name];
if (!prev || !cur || !next) continue;
_qa.fromArray(prev).slerp(_qb.fromArray(next), 0.5);
_qb.fromArray(cur).slerp(_qa, weight);
clip.keyframes[i].rotations[name] = _qb.normalize().toArray();
}
const prevStick = source[i - 1].stick;
const curStick = source[i].stick;
const nextStick = source[i + 1].stick;
if (prevStick && curStick && nextStick) {
const neighbors = (prevStick.angle + nextStick.angle) * 0.5;
clip.keyframes[i].stick.angle = curStick.angle + (neighbors - curStick.angle) * weight;
}
}
return clip;
}
export function clipAsJson(clip) {
return JSON.stringify(sanitizeTiltClip(clip), null, 2) + '\n';
}
export function clipAsModule(clip) {
const safeName = String(clip.name || 'referenceMotion').replace(/[^a-zA-Z0-9_$]/g, '_');
const exportName = /^[a-zA-Z_$]/.test(safeName) ? safeName : `clip_${safeName}`;
return `// Generated by Tilt Animation Studio for src/anim/poses/generated/.\nimport { applyTiltAnimation } from '../../clip.js';\n\nexport const ${exportName} = ${clipAsJson(clip).trim()};\n\nexport function play${exportName[0].toUpperCase()}${exportName.slice(1)}(skater, time) {\n return applyTiltAnimation(skater, ${exportName}, time);\n}\n\nexport default ${exportName};\n`;
}
File diff suppressed because it is too large Load Diff
+132 -40
View File
@@ -1,5 +1,6 @@
import { E } from '../../core/math.js'; import { E } from '../../core/math.js';
import { clamp, lerp } from '../../../shared/scalar.js'; import { clamp, lerp } from '../../../shared/scalar.js';
import * as THREE from 'three';
/** /**
* Upper-body authoring for everything done with the stick. * Upper-body authoring for everything done with the stick.
@@ -11,9 +12,9 @@ import { clamp, lerp } from '../../../shared/scalar.js';
* *
* Each one is a function of a single phase 0..1 so the animator can drive it * Each one is a function of a single phase 0..1 so the animator can drive it
* from a timer, hold it (wind-up), or run it once and blend out (shoot, pass, * from a timer, hold it (wind-up), or run it once and blend out (shoot, pass,
* poke). The right arm carries the stick; the left joins it for two-handed * poke). Poses are authored for a **right** shot (top hand right, lower hand
* work and is pinned onto the shaft by IK afterwards, so what is authored here * left, forehand at X). A left shot runs the same functions and then
* for the left side is only a starting guess that the IK refines. * `mirrorStickwork` swaps the arms and flips the coil across the midline.
*/ */
/** /**
@@ -36,6 +37,39 @@ export const STICK_SPINE = ['spine1', 'spine2', 'spine3', 'neck', 'head'];
export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE); export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE);
/** Left/right arm pairs the mirror swaps. */
const ARM_PAIRS = [
['clavicleL', 'clavicleR'],
['upperArmL', 'upperArmR'],
['forearmL', 'forearmR'],
['handL', 'handR'],
];
const _mirrorL = new THREE.Euler();
const _mirrorR = new THREE.Euler();
/**
* Mirror a right-shot stickwork pose onto a left shot.
*
* Arms swap sides with yaw/roll flipped; spine coil flips the same way. Call
* after any stickwork writer when `shotSign < 0`. Idempotent only if you do
* not call it twice the animator applies it once per layer write.
*/
export function mirrorStickwork(P) {
for (const [l, r] of ARM_PAIRS) {
if (!P.q[l] || !P.q[r]) continue;
_mirrorL.setFromQuaternion(P.q[l], 'XYZ');
_mirrorR.setFromQuaternion(P.q[r], 'XYZ');
E(P.q[l], _mirrorR.x, -_mirrorR.y, -_mirrorR.z);
E(P.q[r], _mirrorL.x, -_mirrorL.y, -_mirrorL.z);
}
for (const n of STICK_SPINE) {
if (!P.q[n]) continue;
_mirrorL.setFromQuaternion(P.q[n], 'XYZ');
E(P.q[n], _mirrorL.x, -_mirrorL.y, -_mirrorL.z);
}
}
/** /**
* The neutral carry, and the hustle variant. * The neutral carry, and the hustle variant.
* *
@@ -102,8 +136,9 @@ export function poseCarry(P, { hustle = 0, reach = 0, lateral = 0 }) {
* Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* the * Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* the
* animator parks here for as long as the Skill Stick is pulled back. * animator parks here for as long as the Skill Stick is pulled back.
* *
* Hands high and back, stick raised behind the head not hanging blade-down * Both hands stay on the stick, relatively square, and the whole grip draws
* from waist height. The torso coils open so the follow-through has something * back and *up* as a unit from the carry not a golf swing that parks the
* stick behind the head. The torso coils open so the downswing has something
* to spend. * to spend.
*/ */
export function poseWindup(P, { phase = 0, aim = 0 }) { export function poseWindup(P, { phase = 0, aim = 0 }) {
@@ -112,58 +147,115 @@ export function poseWindup(P, { phase = 0, aim = 0 }) {
// left is the opposite sign. // left is the opposite sign.
const side = -aim; const side = -aim;
// Torso coils open, loading the shot side. // Torso coils open on the shot side — enough load, not a full pirouette.
E(P.q.spine1, -0.06 - 0.08 * w, -0.18 - 0.42 * w + side * 0.08, -0.05 * w); E(P.q.spine1, -0.03 - 0.04 * w, -0.1 - 0.18 * w + side * 0.08, -0.02 * w);
E(P.q.spine2, -0.07 - 0.1 * w, -0.22 - 0.48 * w + side * 0.1, -0.06 * w); E(P.q.spine2, -0.04 - 0.05 * w, -0.12 - 0.22 * w + side * 0.1, -0.03 * w);
E(P.q.spine3, -0.04 - 0.07 * w, -0.18 - 0.38 * w + side * 0.08, -0.04 * w); E(P.q.spine3, -0.02 - 0.04 * w, -0.08 - 0.16 * w + side * 0.08, -0.02 * w);
// Eyes stay on the target while the body turns away from it. // Eyes stay on the target while the body turns away from it.
E(P.q.neck, 0.04, 0.28 + 0.42 * w - side * 0.2, 0); E(P.q.neck, 0.02, 0.12 + 0.18 * w - side * 0.16, 0);
E(P.q.head, 0.04, 0.22 + 0.32 * w - side * 0.25, 0); E(P.q.head, 0.02, 0.1 + 0.14 * w - side * 0.18, 0);
// Top hand: high and back, roughly shoulder/head height, so the aimed stick // Carry-like arms that lift and pull back *together* on the forehand side.
// can sit up behind the head instead of dangling at the hip. // Keeping the seed close to the two-handed carry means the lower-hand IK only
E(P.q.clavicleR, -0.1 * w, -0.22 * w, -0.1); // finishes the last few centimetres, and the hands stay square on the shaft.
E(P.q.upperArmR, 0.15 + 0.65 * w, -0.55 - 0.35 * w + side * 0.15, -0.55 - 0.35 * w); E(P.q.clavicleR, -0.02 * w, -0.05 * w, -0.05);
E(P.q.forearmR, -0.45 - 0.25 * w, 0.22, -0.12); E(
E(P.q.handR, -0.05, 0.2, 0.22); P.q.upperArmR,
lerp(-0.42, -0.3, w) + side * 0.03,
lerp(0.42, 0.22, w) + side * 0.1,
lerp(0.68, 0.62, w),
);
E(
P.q.forearmR,
lerp(-1.35, -1.2, w),
lerp(0.02, 0.05, w),
lerp(0.32, 0.28, w),
);
E(P.q.handR, lerp(-0.12, -0.09, w), lerp(0.12, 0.13, w), lerp(0.04, 0.06, w));
// Lower hand comes up with it; IK pins it to the shaft. E(P.q.clavicleL, 0.03, lerp(0.06, 0.07, w), lerp(-0.06, -0.03, w));
E(P.q.clavicleL, 0.04, 0.12 * w, 0.08); E(
E(P.q.upperArmL, -0.35 - 0.1 * w, 0.45 + 0.2 * w, 0.4 + 0.15 * w); P.q.upperArmL,
E(P.q.forearmL, -0.85 - 0.15 * w, -0.18, -0.12); lerp(-0.38, -0.26, w),
E(P.q.handL, -0.08, 0, -0.1); lerp(0.1, 0.16, w) + side * 0.06,
lerp(-0.48, -0.4, w),
);
E(
P.q.forearmL,
lerp(-1.32, -1.2, w),
lerp(-0.08, -0.09, w),
lerp(0.18, 0.15, w),
);
E(P.q.handL, -0.08, 0, lerp(0.08, 0.02, w));
} }
/** /**
* Follow-through. `phase` 0..1 runs once, fast. * Shot swing. `phase` 0..1 runs once, fast.
* *
* The coil released: the torso whips through the shot, the stick sweeps across * Three beats, matching the grip path: still loaded square through the puck
* and finishes high. Front-loaded easing, so the contact reads at the start of * with the blade on the ice follow-through high across the body. Front-loaded
* the animation rather than in the middle of it. * easing so contact reads early, not in the middle of a slow blend.
*/ */
export function poseShot(P, { phase = 0, power = 1, aim = 0 }) { export function poseShot(P, { phase = 0, power = 1, aim = 0 }) {
const t = clamp(phase, 0, 1); const t = clamp(phase, 0, 1);
// Fast out of the coil, then settle. // Fast out of the coil, then settle into the follow.
const s = 1 - (1 - t) * (1 - t); const s = 1 - (1 - t) * (1 - t);
const p = clamp(power, 0.2, 1); const p = clamp(power, 0.2, 1);
// 0..1 through the "square on the ice" window, then 0..1 into the follow.
// Contact lands around t≈0.28 so the blade is flush when the puck leaves.
const down = clamp(t / 0.28, 0, 1);
const through = clamp((t - 0.28) / 0.55, 0, 1);
const square = 1 - (1 - down) * (1 - down);
const twist = lerp(-0.42 * p, 0.44 * p, s); const twist = lerp(-0.28 * p, 0.38 * p, s);
E(P.q.spine1, -0.06 + 0.12 * s, twist * 0.9, 0.04 * s); E(P.q.spine1, -0.04 + 0.1 * s, twist * 0.9, 0.03 * s);
E(P.q.spine2, -0.07 + 0.14 * s, twist, 0.05 * s); E(P.q.spine2, -0.05 + 0.12 * s, twist, 0.04 * s);
E(P.q.spine3, -0.05 + 0.1 * s, twist * 0.8, 0.03 * s); E(P.q.spine3, -0.03 + 0.08 * s, twist * 0.8, 0.03 * s);
E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0); E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0);
E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0); E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0);
// Top hand drives through and finishes high across the body. // Arms: wind-up square → contact square (carry-like) → follow high.
E(P.q.clavicleR, lerp(-0.05, 0.04, s), lerp(-0.14, 0.1, s), -0.06); // The early half is deliberately close to the carry pose so both hands stay
E(P.q.upperArmR, lerp(0.3, -1.05 * p, s), lerp(-0.94, 0.3, s), lerp(-0.72, -0.1, s)); // on the shaft and the stick reads flat through the ice, not rotating off it.
E(P.q.forearmR, lerp(-1.12, -0.42, s), 0.16, -0.1); E(
E(P.q.handR, -0.1, 0.1, 0.16); P.q.clavicleR,
lerp(lerp(-0.02, 0.02, square), 0.04, through),
lerp(lerp(-0.06, -0.04, square), 0.1, through),
-0.05,
);
E(
P.q.upperArmR,
lerp(lerp(-0.3, -0.42, square), -1.0 * p, through),
lerp(lerp(0.22, 0.4, square), 0.28, through),
lerp(lerp(0.62, 0.68, square), -0.08, through),
);
E(
P.q.forearmR,
lerp(lerp(-1.2, -1.34, square), -0.4, through),
lerp(0.05, 0.12, through),
lerp(0.28, -0.08, through),
);
E(P.q.handR, -0.1, 0.1, 0.12);
E(P.q.clavicleL, 0.03, lerp(0.1, -0.04, s), 0.06); E(
E(P.q.upperArmL, lerp(-0.86, -0.3, s), lerp(0.66, 0.12, s), lerp(0.4, 0.5, s)); P.q.clavicleL,
E(P.q.forearmL, lerp(-1.36, -0.6, s), -0.28, -0.2); 0.03,
E(P.q.handL, -0.08, 0, -0.14); lerp(lerp(0.07, 0.06, square), -0.04, through),
lerp(-0.03, 0.05, through),
);
E(
P.q.upperArmL,
lerp(lerp(-0.26, -0.38, square), -0.28, through),
lerp(lerp(0.16, 0.12, square), 0.1, through),
lerp(lerp(-0.4, -0.48, square), 0.48, through),
);
E(
P.q.forearmL,
lerp(lerp(-1.2, -1.32, square), -0.55, through),
lerp(-0.09, -0.2, through),
lerp(0.15, -0.14, through),
);
E(P.q.handL, -0.08, 0, -0.08);
} }
/** /**
+129 -16
View File
@@ -1,11 +1,15 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { E, clamp, segDist, smooth } from '../core/math.js'; import { E, clamp, segDist, smooth } from '../core/math.js';
import { lerp, lerpAngle } from '../../shared/scalar.js'; import { lerp, lerpAngle } from '../../shared/scalar.js';
import { lowerHandFor, normalizeShotSide, shotSign, topHandFor } from '../../shared/player.js';
import { poseSkate, poseStop } from './poses/skate.js'; import { poseSkate, poseStop } from './poses/skate.js';
import { import {
STICK_ARMS, STICK_BONES, STICK_SPINE, STICK_ARMS, STICK_BONES, STICK_SPINE,
poseCarry, posePass, posePoke, poseShot, poseWindup, mirrorStickwork,
poseCarry, posePass, posePoke,
} from './poses/stickwork.js'; } from './poses/stickwork.js';
import { frameSpan, sampleTiltStick } from './clip.js';
import { shot1 } from './clips/shot1.js';
import { STICK } from '../character/stick.js'; import { STICK } from '../character/stick.js';
/** /**
@@ -133,18 +137,68 @@ export function buildAnimator(skelData, mover) {
actionTime: 0, actionTime: 0,
actionPower: 1, actionPower: 1,
actionAim: 0, actionAim: 0,
/** A released held wind-up starts shot1 at its midpoint, not frame zero. */
actionFromWindup: false,
/** Eased 0..1 between the settled grip and the one-handed dangle. */ /** Eased 0..1 between the settled grip and the one-handed dangle. */
hustleGrip: 0, hustleGrip: 0,
/**
* Shot side which hand is on top of the stick, which side the blade
* lives on, and whether stickwork poses are mirrored. Authored content is
* for `'right'`; `'left'` flips grips and arms across the body.
*/
shotSide: 'right',
/** `+1` right (as authored), `-1` left (mirrored). */
shotSign: 1,
/** Top hand bone suffix for this shot side: `'R'` or `'L'`. */
topHand: 'R',
/** Lower hand bone suffix — the one IK pins to the shaft. */
lowerHand: 'L',
};
/** Apply a player shot side. Call once at create (or if a roster swaps it). */
anim.setShotSide = function setShotSide(side) {
anim.shotSide = normalizeShotSide(side);
anim.shotSign = shotSign(anim.shotSide);
anim.topHand = topHandFor(anim.shotSide);
anim.lowerHand = lowerHandFor(anim.shotSide);
}; };
/** How long each one-shot action runs, seconds. */ /** How long each one-shot action runs, seconds. */
const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 }; const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 };
/** Seconds to blend the override in and out over the skating pose. */ /** Seconds to blend the override in and out over the skating pose. */
const ACTION_BLEND = 0.09; const ACTION_BLEND = 0.09;
/** shot1 reaches its final key before the normal action fade begins. */
const SHOT_MOTION_TIME = ACTION_TIME.shoot - ACTION_BLEND;
/** Scratch pose the action layer writes into before being blended over. */ /** Scratch pose the action layer writes into before being blended over. */
const overlay = newPose(); const overlay = newPose();
const _actionSpine = new THREE.Quaternion(); const _actionSpine = new THREE.Quaternion();
const _clipQa = new THREE.Quaternion();
const _clipQb = new THREE.Quaternion();
/** Map game action time onto the saved reference motion. */
function shot1Time() {
if (anim.action === 'windup') return clamp(anim.charge, 0, 1) * shot1.duration * 0.5;
if (anim.action === 'shoot') {
const phase = clamp(anim.actionTime / SHOT_MOTION_TIME, 0, 1);
const start = anim.actionFromWindup ? 0.5 : 0;
return (start + phase * (1 - start)) * shot1.duration;
}
return null;
}
/** Sample shot1 into the upper-body action layer without replacing skating legs. */
function poseShot1(P, time) {
const span = frameSpan(shot1, time);
if (!span) return false;
for (const name of STICK_BONES) {
const a = span.a.rotations[name] ?? [0, 0, 0, 1];
const b = span.b.rotations[name] ?? a;
P.q[name].slerpQuaternions(_clipQa.fromArray(a), _clipQb.fromArray(b), span.alpha);
}
return true;
}
const _localFoot = new THREE.Vector3(); const _localFoot = new THREE.Vector3();
@@ -256,6 +310,7 @@ export function buildAnimator(skelData, mover) {
* instead, because it is held for as long as the stick is pulled back. * instead, because it is held for as long as the stick is pulled back.
*/ */
anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) { anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) {
anim.actionFromWindup = name === 'shoot' && anim.action === 'windup';
anim.action = name; anim.action = name;
anim.actionTime = 0; anim.actionTime = 0;
anim.actionPower = power; anim.actionPower = power;
@@ -276,7 +331,10 @@ export function buildAnimator(skelData, mover) {
if (anim.action === 'windup') { if (anim.action === 'windup') {
// Held. Blends in over ACTION_BLEND and then stays until released. // Held. Blends in over ACTION_BLEND and then stays until released.
const w = Math.min(1, anim.actionTime / ACTION_BLEND); const w = Math.min(1, anim.actionTime / ACTION_BLEND);
poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim }); poseShot1(overlay, shot1Time());
// shot1 was authored as a left shot. Mirror only when the runtime
// skater uses the opposite socket side.
if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay);
return w; return w;
} }
@@ -288,14 +346,23 @@ export function buildAnimator(skelData, mover) {
} }
// Snap in, ease out — a shot should look like it started the instant the // Snap in, ease out — a shot should look like it started the instant the
// button did, and a slow blend in front of it steals that. // button did, and a slow blend in front of it steals that.
const w = t > 1 - ACTION_BLEND / duration const fading = t > 1 - ACTION_BLEND / duration;
const w = fading
? Math.max(0, (1 - t) * duration / ACTION_BLEND) ? Math.max(0, (1 - t) * duration / ACTION_BLEND)
// A released held wind-up is already fully blended in. Dropping its
// weight back to zero for the shoot action caused a one-frame snap to
// carry before the second half of shot1 began.
: anim.action === 'shoot' && anim.actionFromWindup
? 1
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5)); : Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
const args = { phase: t, power: anim.actionPower, aim: anim.actionAim }; const args = { phase: t, power: anim.actionPower, aim: anim.actionAim };
if (anim.action === 'shoot') poseShot(overlay, args); if (anim.action === 'shoot') poseShot1(overlay, shot1Time());
else if (anim.action === 'pass') posePass(overlay, args); else if (anim.action === 'pass') posePass(overlay, args);
else posePoke(overlay, args); else posePoke(overlay, args);
if (anim.action === 'shoot') {
if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay);
} else if (anim.shotSign < 0) mirrorStickwork(overlay);
return w; return w;
} }
@@ -303,8 +370,13 @@ export function buildAnimator(skelData, mover) {
function gripFor() { function gripFor() {
if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)]; if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
if (anim.action === 'shoot') { if (anim.action === 'shoot') {
// Three beats matching poseShot: loaded → blade square on the ice →
// follow-through. Contact is short and early so the bottom of the blade
// is flush when the puck leaves, not halfway through a blend to high.
const t = anim.actionTime / (ACTION_TIME.shoot); const t = anim.actionTime / (ACTION_TIME.shoot);
return ['windup', 'follow', Math.min(1, t / 0.45)]; if (t < 0.28) return ['windup', 'contact', Math.min(1, t / 0.28)];
if (t < 0.55) return ['contact', 'follow', (t - 0.28) / 0.27];
return ['follow', 'follow', 1];
} }
if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)]; if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)];
if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5]; if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5];
@@ -530,7 +602,9 @@ export function buildAnimator(skelData, mover) {
const _shaftB = new THREE.Vector3(); const _shaftB = new THREE.Vector3();
const _shaftDir = new THREE.Vector3(); const _shaftDir = new THREE.Vector3();
const _handPos = new THREE.Vector3(); const _handPos = new THREE.Vector3();
const _lowerHandPos = new THREE.Vector3();
const _handQuat = new THREE.Quaternion(); const _handQuat = new THREE.Quaternion();
const _shot1Stick = {};
anim.update = function update(dt) { anim.update = function update(dt) {
dt *= anim.speed; dt *= anim.speed;
@@ -574,6 +648,8 @@ export function buildAnimator(skelData, mover) {
reach: anim.handling.y, reach: anim.handling.y,
lateral: anim.handling.x, lateral: anim.handling.x,
}); });
// Authored for a right shot; left shots run the mirrored pose set.
if (anim.shotSign < 0) mirrorStickwork(overlay);
for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]); for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]);
for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]); for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]);
@@ -602,40 +678,77 @@ export function buildAnimator(skelData, mover) {
mover.updateMatrixWorld(true); mover.updateMatrixWorld(true);
// ---- the stick, last --------------------------------------------------- // ---- the stick, last ---------------------------------------------------
// Socket first, because it hangs off the right hand and the arm has only // Socket first: it hangs off the top hand for this shot side. Then the
// just been posed. Then the lower hand is pulled onto the shaft, which // lower hand is pulled onto the shaft, which needs the stick already
// needs the stick already placed — hence the second matrix refresh. // placed — hence the second matrix refresh.
if (anim.stick) { if (anim.stick) {
const top = anim.topHand;
const lower = anim.lowerHand;
const clipTime = shot1Time();
const referenceStick = clipTime === null ? null : sampleTiltStick(shot1, clipTime, _shot1Stick);
let referenceHandsAligned = false;
// The applied studio guide carries a real 3D constraint: the shaft must
// cross both saved hand sockets. Use that instead of reconstructing
// camera depth from the 2D line, and keep the saved arm pose untouched.
if (referenceStick?.alignHands && typeof anim.stick.aimThroughHands === 'function') {
B[`hand${top}`].getWorldPosition(_handPos);
B[`hand${lower}`].getWorldPosition(_lowerHandPos);
B[`hand${top}`].getWorldQuaternion(_handQuat).invert();
referenceHandsAligned = anim.stick.aimThroughHands(
_handPos,
_lowerHandPos,
_handQuat,
referenceStick.roll,
);
mover.updateMatrixWorld(true);
} else {
const [from, to, t] = gripFor(); const [from, to, t] = gripFor();
const roll = anim.stick.stanceTarget(from, to, t, _stickTarget); let roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
// GRIP targets are authored for a right shot (forehand at X). Flip the
// blade across the body for a left shot, and the roll with it so the
// face stays open the same way relative to the forehand.
if (anim.shotSign < 0) {
_stickTarget.x *= -1;
roll = -roll;
}
// Stickhandling moves the *target*, not just the arm pose. Nudging only // Stickhandling moves the *target*, not just the arm pose. Nudging only
// the shoulders moved the blade by centimetres; the puck follows the // the shoulders moved the blade by centimetres; the puck follows the
// blade now, so the Skill Stick has to move the blade to mean anything. // blade now, so the Skill Stick has to move the blade to mean anything.
// //
// Lateral is *subtracted*: skater local +X is the left side, but the Skill // Lateral is *subtracted*: skater local +X is the left side, but the Skill
// Stick's +X is "push right". Adding them lined the deke up mirrored — // Stick's +X is "push right". Adding them lined the deke up mirrored —
// stick right sent the puck to the skater's left. // stick right sent the puck to the skater's left. Screen-right stays
// skater-right for both shot sides.
if (anim.hasPuck) { if (anim.hasPuck) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side; _stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd; _stickTarget.z += anim.handling.y * STICK_REACH.fwd;
} }
_stickTarget.applyMatrix4(mover.matrixWorld); _stickTarget.applyMatrix4(mover.matrixWorld);
B.handR.getWorldPosition(_handPos); B[`hand${top}`].getWorldPosition(_handPos);
B.handR.getWorldQuaternion(_handQuat); B[`hand${top}`].getWorldQuaternion(_handQuat);
_handQuat.invert(); _handQuat.invert();
anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll); anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll);
mover.updateMatrixWorld(true); mover.updateMatrixWorld(true);
}
// Two hands on it whenever the stick is being used for something, and // Two hands on it whenever the stick is being used for something, and
// not while it is being dangled out on one. // not while it is being dangled out on one.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1); //
if (twoHanded > 0.05) { // Skip the lower-hand IK while a pose crossfade is still running (get-up
// from a knockdown, state change). IK overwrites the bone fully, so
// applying it on top of a blend from a limp pose yanks the lower hand
// onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a
// left shot, where the lower hand *is* the right.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1)
* (anim.blend >= 1 ? 1 : 0);
if (twoHanded > 0.05 && !referenceHandsAligned) {
// Preferred lower-hand grip is a bit down the shaft (hands apart, the // Preferred lower-hand grip is a bit down the shaft (hands apart, the
// way the reference draws a carry). If that point is past the arm's // way the reference draws a carry). If that point is past the arm's
// reach, slide up toward the butt until it is — never leave the hand // reach, slide up toward the butt until it is — never leave the hand
// waving short of the stick, and never stack both hands on the butt. // waving short of the stick, and never stack both hands on the butt.
anim.stick.shaftSegment(_shaftA, _shaftB); anim.stick.shaftSegment(_shaftA, _shaftB);
B.upperArmL.getWorldPosition(_H); B[`upperArm${lower}`].getWorldPosition(_H);
_shaftDir.subVectors(_shaftB, _shaftA); _shaftDir.subVectors(_shaftB, _shaftA);
const len = _shaftDir.length() || 1; const len = _shaftDir.length() || 1;
const armReach = ARM.upper + ARM.fore - 0.03; const armReach = ARM.upper + ARM.fore - 0.03;
@@ -661,7 +774,7 @@ export function buildAnimator(skelData, mover) {
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT); _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
} }
} }
solveArm('L', _shaftPoint); solveArm(lower, _shaftPoint);
mover.updateMatrixWorld(true); mover.updateMatrixWorld(true);
} }
} }
+15 -4
View File
@@ -11,6 +11,7 @@ import { REACTION_ATTACK, createRagdoll } from '../physics/ragdoll.js';
import { createBodyProxy } from '../physics/bodyProxy.js'; import { createBodyProxy } from '../physics/bodyProxy.js';
import { buildStick } from './stick.js'; import { buildStick } from './stick.js';
import { HIT } from '../game/hits.js'; import { HIT } from '../game/hits.js';
import { lowerHandFor, normalizeShotSide, topHandFor } from '../../shared/player.js';
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
@@ -34,10 +35,14 @@ export function createSkater({
position = { x: 0, z: 0 }, position = { x: 0, z: 0 },
facing = 0, facing = 0,
bodyStyle = null, bodyStyle = null,
/** `'left' | 'right'` — which side they shoot from. See `shared/player.js`. */
shotSide = 'right',
}) { }) {
const rng = makeRng(seed); const rng = makeRng(seed);
const materials = buildMaterials(rng, team); const materials = buildMaterials(rng, team);
const skelData = buildSkeleton(); const skelData = buildSkeleton();
const side = normalizeShotSide(shotSide);
const top = topHandFor(side);
const mover = new THREE.Group(); const mover = new THREE.Group();
mover.name = 'skater:' + index; mover.name = 'skater:' + index;
@@ -63,12 +68,12 @@ export function createSkater({
const animator = buildAnimator(skelData, mover); const animator = buildAnimator(skelData, mover);
animator.setTransform(mover.position, facing); animator.setTransform(mover.position, facing);
animator.setShotSide(side);
// Socketed to the right hand, not to the mover: the arm pose decides where // Socketed to the *top* hand for this shot side, not to the mover: the arm
// the stick is, which is the correct dependency order and the only way the // pose decides where the stick is. Right shot → handR, left shot → handL.
// hands can actually be on it.
const stick = buildStick(materials, physics, index); const stick = buildStick(materials, physics, index);
stick.attachTo(skelData.bones.handR); stick.attachTo(skelData.bones[`hand${top}`]);
stick.setGrip('carry'); stick.setGrip('carry');
animator.stick = stick; animator.stick = stick;
@@ -122,6 +127,12 @@ export function createSkater({
index, index,
seed, seed,
team, team,
/** `'left' | 'right'` — stick hand, shoot side, mirrored pose set. */
shotSide: side,
/** Top hand on the stick for this shot side. */
topHand: top,
/** Lower hand, pinned to the shaft by IK. */
lowerHand: lowerHandFor(side),
rng, rng,
materials, materials,
skelData, skelData,
+41 -5
View File
@@ -55,7 +55,9 @@ export const STICK = {
* angle as the free variable, which is what a wrist is for. `roll` is the blade * angle as the free variable, which is what a wrist is for. `roll` is the blade
* face angle about the shaft, which is the part that genuinely is authored. * face angle about the shaft, which is the part that genuinely is authored.
* *
* +X is the skater's left, +Z is forward, so a right-hander carries at X. * +X is the skater's left, +Z is forward. Targets below are for a **right**
* shot (forehand at X); the animator mirrors X (and roll) for a left shot.
* See `shared/player.js` `shotSide`.
*/ */
export const GRIP = { export const GRIP = {
/** /**
@@ -67,12 +69,21 @@ export const GRIP = {
/** Hustling: stick dangles out in front on one hand. */ /** Hustling: stick dangles out in front on one hand. */
hustle: { target: [-0.14, 0.03, 1.05], roll: 0.14 }, hustle: { target: [-0.14, 0.03, 1.05], roll: 0.14 },
/** /**
* Wind-up: blade high and back behind the head, not hanging down from the * Wind-up: stick drawn back and *up* from the carry with both hands still
* hands. y well above the shoulders, z behind the body. * square on the shaft. Blade lifts to about waist height and comes back
* toward the body loaded, not a horizontal golf swing over the head.
* Geometry of a 1.1 m stick means a high behind-the-head target forces the
* shaft flat at ear height; keep the target lower so the angle stays real.
*/ */
windup: { target: [-0.28, 1.55, -0.48], roll: -0.2 }, windup: { target: [-0.42, 0.52, 0.18], roll: -0.08 },
/**
* Shot contact: bottom of the blade flush with the ice, stick squared up
* through the puck. Same height as carry so the lie sits the sole on the
* ice rather than the toe or the heel.
*/
contact: { target: [-0.18, 0.03, 0.52], roll: 0.06 },
/** Follow-through: swept across the body and finishing high. */ /** Follow-through: swept across the body and finishing high. */
follow: { target: [0.34, 0.95, 0.85], roll: 0.55 }, follow: { target: [0.28, 0.72, 0.88], roll: 0.42 },
/** Poke: thrust out flat, as far ahead as the arm reaches. */ /** Poke: thrust out flat, as far ahead as the arm reaches. */
poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 }, poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
}; };
@@ -153,6 +164,7 @@ export function buildStick(materials, physics, index) {
const _aimLocal = new THREE.Vector3(); const _aimLocal = new THREE.Vector3();
const _aimQuat = new THREE.Quaternion(); const _aimQuat = new THREE.Quaternion();
const _rollQuat = new THREE.Quaternion(); const _rollQuat = new THREE.Quaternion();
const _shaftAxis = new THREE.Vector3(0, -1, 0);
// The axis that must end up pointing at the target is the grip-to-*blade* // The axis that must end up pointing at the target is the grip-to-*blade*
// direction, not the shaft's Y. The blade sits forward of the shaft end by // direction, not the shaft's Y. The blade sits forward of the shaft end by
// the toe offset, which puts it ~6° off axis — aiming Y instead left the // the toe offset, which puts it ~6° off axis — aiming Y instead left the
@@ -233,6 +245,30 @@ export function buildStick(materials, physics, index) {
group.quaternion.copy(_aimQuat); group.quaternion.copy(_aimQuat);
}, },
/**
* Put the shaft itself through both hand sockets.
*
* Reference video gives us a reliable 2D shaft line, but not reliable
* camera depth. Once the animator has placed both hands, those two 3D
* sockets are the better depth constraint. The group origin is snapped to
* the top hand and local -Y is aimed at the lower hand, so every camera
* angle sees the same two-hand contact instead of a camera-plane illusion.
*/
aimThroughHands(topHandWorld, lowerHandWorld, handQuatInverse, roll = 0) {
group.position.set(0, 0, 0);
_aimDir.subVectors(lowerHandWorld, topHandWorld);
if (_aimDir.lengthSq() < 1e-10) return false;
_aimDir.normalize();
_aimLocal.copy(_aimDir).applyQuaternion(handQuatInverse).normalize();
_aimQuat.setFromUnitVectors(_shaftAxis, _aimLocal);
if (roll) {
_rollQuat.setFromAxisAngle(_aimLocal, roll);
_aimQuat.premultiply(_rollQuat);
}
group.quaternion.copy(_aimQuat);
return true;
},
/** Static placement, for a rig with no animator driving it. */ /** Static placement, for a rig with no animator driving it. */
setGrip(name = 'carry') { setGrip(name = 'carry') {
const g = GRIP[name] ?? GRIP.carry; const g = GRIP[name] ?? GRIP.carry;
+6
View File
@@ -10,6 +10,7 @@ import { createPossession } from './possession.js';
import { makeRng } from '../core/rng.js'; import { makeRng } from '../core/rng.js';
import { clamp, wrapAngle } from '../../shared/scalar.js'; import { clamp, wrapAngle } from '../../shared/scalar.js';
import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js'; import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js';
import { rollShotSide } from '../../shared/player.js';
/** /**
* The match loop. * The match loop.
@@ -46,10 +47,14 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const spawn = spawns[i]; const spawn = spawns[i];
const team = spawn.team; const team = spawn.team;
// Shot side is a player trait, not a spawn — roll it once so a lineup is a
// mix of left and right shots rather than six mirrored clones.
const shotSide = rollShotSide(rng.f());
const s = createSkaterState(i, spawn, { const s = createSkaterState(i, spawn, {
seed: seed + i * 977, seed: seed + i * 977,
team, team,
name: `${team === 0 ? 'Home' : 'Away'} ${(i % perTeam) + 1}`, name: `${team === 0 ? 'Home' : 'Away'} ${(i % perTeam) + 1}`,
shotSide,
}); });
states.push(s); states.push(s);
brains.push(createBrain(rng.f, {})); brains.push(createBrain(rng.f, {}));
@@ -62,6 +67,7 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
team, team,
position: { x: spawn.x, z: spawn.z }, position: { x: spawn.x, z: spawn.z },
facing: spawn.yaw, facing: spawn.yaw,
shotSide,
// A little variety in build so three placeholder bodies are not clones. // A little variety in build so three placeholder bodies are not clones.
bodyStyle: { bodyStyle: {
mass: rng.range(-0.35, 0.5), mass: rng.range(-0.35, 0.5),
+930
View File
@@ -0,0 +1,930 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { createSkater } from '../character/skater.js';
import { BONEDEF } from '../character/skeleton.js';
import {
applyTiltAnimation,
applyTiltStickPose,
captureSkeletonKeyframe,
clipAsJson,
clipAsModule,
createTiltClip,
deleteClipKeyframe,
sanitizeTiltClip,
sampleTiltStick,
setClipKeyframe,
smoothTiltClip,
} from '../anim/clip.js';
import {
bakeStickLandmarks,
detectVideoPose,
drawPoseOverlay,
drawStickOverlay,
loadPoseLandmarker,
retargetPoseToSkeleton,
} from './mediapipePose.js';
import { createSeededStickTracker, stickGripFromWrists } from './stickTracker.js';
import { parseFreeMoCapCsv } from './freemocapImport.js';
const $ = (id) => document.getElementById(id);
const el = {
stage: $('stage'), video: $('referenceVideo'), overlay: $('poseOverlay'), videoEmpty: $('videoEmpty'),
fileInput: $('fileInput'), clipInput: $('clipInput'), chooseVideo: $('chooseVideo'), clipName: $('clipName'),
mocapSource: $('mocapSource'), browserMocapControls: $('browserMocapControls'),
freeMocapControls: $('freeMocapControls'), freeMocapFps: $('freeMocapFps'),
importFreeMocap: $('importFreeMocap'), importFreeMocapCsv: $('importFreeMocapCsv'),
freeMocapInput: $('freeMocapInput'), freeMocapState: $('freeMocapState'),
captureFps: $('captureFps'), trimIn: $('trimIn'), trimOut: $('trimOut'), mirror: $('mirrorPose'),
extract: $('extract'), progress: $('progress'), status: $('status'), confidence: $('confidence'),
scrubber: $('scrubber'), markers: $('markers'), timelineEnd: $('timelineEnd'), timecode: $('timecode'),
playPause: $('playPause'), prevKey: $('prevKey'), nextKey: $('nextKey'), timelineSource: $('timelineSource'),
boneSelect: $('boneSelect'), setKey: $('setKey'), deleteKey: $('deleteKey'), resetBone: $('resetBone'),
smoothKeys: $('smoothKeys'), keyCount: $('keyCount'), durationLabel: $('durationLabel'), loop: $('loopClip'),
trackStick: $('trackStick'), seedStick: $('seedStick'), correctStick: $('correctStick'), clearStick: $('clearStick'),
applyStick: $('applyStick'), flipStick: $('flipStick'), shotSide: $('shotSide'), stickStatus: $('stickStatus'),
saveState: $('saveState'), saveProject: $('saveProject'), loadProject: $('loadProject'), importClip: $('importClip'),
exportJson: $('exportJson'), exportModule: $('exportModule'), newClip: $('newClip'),
};
let clip = createTiltClip();
let currentTime = 0;
let videoUrl = null;
let videoFile = null;
let videoIn = 0;
let playing = false;
let localPlayStarted = 0;
let localPlayOffset = 0;
let selectedBone = 'pelvis';
let lastLandmarks = null;
let dirty = false;
let extracting = false;
let markersSignature = '';
let activeMarker = null;
let stickSeed = null;
let stickTracker = null;
let currentStick = null;
let pickingStick = null;
let stickPickPurpose = null;
const sampledStick = {};
// ---- Three.js rig preview -------------------------------------------------
const renderer = new THREE.WebGLRenderer({ canvas: el.stage, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0f16);
scene.fog = new THREE.Fog(0x0a0f16, 7, 18);
scene.add(new THREE.HemisphereLight(0xd9ecff, 0x111827, 1.8));
const key = new THREE.DirectionalLight(0xffffff, 2.4);
key.position.set(3, 7, 5);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
scene.add(key);
const rim = new THREE.DirectionalLight(0x68e0c2, 1.1);
rim.position.set(-4, 3, -4);
scene.add(rim);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(3.4, 64),
new THREE.MeshStandardMaterial({ color: 0x141f2c, roughness: 0.94, metalness: 0.03 }),
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(6, 24, 0x35506a, 0x1b2a3a);
grid.position.y = 0.002;
scene.add(grid);
const camera = new THREE.PerspectiveCamera(36, 1, 0.03, 30);
camera.position.set(2.8, 1.7, 3.8);
const orbit = new OrbitControls(camera, el.stage);
orbit.target.set(0, 0.92, 0);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.minDistance = 1.6;
orbit.maxDistance = 9;
orbit.update();
const skater = createSkater({ seed: 2201, scene, physics: null, index: 0, team: 0 });
skater.animator.moveSpeed = 0;
skater.animator.bladeSpeed = 0;
skater.animator.effort = 0;
for (let i = 0; i < 30; i++) skater.animator.update(1 / 60);
skater.mover.updateMatrixWorld(true);
function setStudioShotSide(side, { updateClip = true } = {}) {
const normalized = side === 'left' ? 'left' : 'right';
const topHand = normalized === 'left' ? 'L' : 'R';
skater.skelData.bones[`hand${topHand}`].add(skater.stick.group);
skater.shotSide = normalized;
skater.topHand = topHand;
skater.lowerHand = topHand === 'L' ? 'R' : 'L';
skater.animator.setShotSide(normalized);
el.shotSide.value = normalized;
if (updateClip) clip.shotSide = normalized;
skater.mover.updateMatrixWorld(true);
}
function stickBakeOptions() {
return {
mirror: el.mirror.checked,
socketHand: skater.stick.group.parent === skater.skelData.bones.handL ? 'L' : 'R',
};
}
const skeletonHelper = new THREE.SkeletonHelper(skater.skelData.rootBone);
skeletonHelper.material.color.set(0x68e0c2);
skeletonHelper.material.transparent = true;
skeletonHelper.material.opacity = 0.48;
skeletonHelper.material.depthTest = false;
scene.add(skeletonHelper);
const transform = new TransformControls(camera, el.stage);
transform.setMode('rotate');
transform.setSpace('local');
transform.setSize(0.62);
scene.add(transform.getHelper());
transform.addEventListener('dragging-changed', (event) => {
orbit.enabled = !event.value;
if (event.value) stopPlayback();
});
transform.addEventListener('objectChange', () => {
skater.mover.updateMatrixWorld(true);
updateRotationInputs();
markDirty('pose changed — set key to keep it');
});
const handleGeo = new THREE.SphereGeometry(0.025, 10, 8);
const handleMat = new THREE.MeshBasicMaterial({ color: 0xf6c85f, depthTest: false, transparent: true, opacity: 0.82 });
const selectedMat = new THREE.MeshBasicMaterial({ color: 0x68e0c2, depthTest: false });
const handles = BONEDEF.filter(([name]) => name !== 'root').map(([name]) => {
const mesh = new THREE.Mesh(handleGeo, handleMat);
mesh.userData.boneName = name;
mesh.renderOrder = 20;
scene.add(mesh);
return mesh;
});
function updateHandles() {
for (const handle of handles) {
skater.skelData.bones[handle.userData.boneName].getWorldPosition(handle.position);
handle.material = handle.userData.boneName === selectedBone ? selectedMat : handleMat;
}
}
function resize() {
const rect = el.stage.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(el.stage);
resize();
function layoutVideoOverlay() {
const wrap = el.video.parentElement.getBoundingClientRect();
if (!wrap.width || !wrap.height || !el.video.videoWidth || !el.video.videoHeight) return;
const scale = Math.min(wrap.width / el.video.videoWidth, wrap.height / el.video.videoHeight);
const width = el.video.videoWidth * scale;
const height = el.video.videoHeight * scale;
Object.assign(el.overlay.style, {
width: `${width}px`, height: `${height}px`,
left: `${(wrap.width - width) * 0.5}px`, top: `${(wrap.height - height) * 0.5}px`,
});
}
function renderReferenceOverlay() {
drawPoseOverlay(el.overlay, el.video, lastLandmarks, { mirror: el.mirror.checked });
drawStickOverlay(el.overlay, currentStick, { picking: pickingStick });
}
new ResizeObserver(() => { layoutVideoOverlay(); renderReferenceOverlay(); }).observe(el.video.parentElement);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
el.stage.addEventListener('pointerup', (event) => {
if (transform.dragging) return;
const rect = el.stage.getBoundingClientRect();
pointer.set(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(handles, false)[0];
if (hit) selectBone(hit.object.userData.boneName);
});
// ---- bone inspector -------------------------------------------------------
const boneGroups = [
['Core', ['root', 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']],
['Left arm', ['clavicleL', 'upperArmL', 'forearmL', 'handL']],
['Right arm', ['clavicleR', 'upperArmR', 'forearmR', 'handR']],
['Left leg', ['thighL', 'shinL', 'footL', 'toeL']],
['Right leg', ['thighR', 'shinR', 'footR', 'toeR']],
];
for (const [label, names] of boneGroups) {
const group = document.createElement('optgroup');
group.label = label;
for (const name of names) group.append(new Option(name, name));
el.boneSelect.append(group);
}
const euler = new THREE.Euler(0, 0, 0, 'XYZ');
const deg = THREE.MathUtils.radToDeg;
const rad = THREE.MathUtils.degToRad;
function selectBone(name) {
selectedBone = name;
el.boneSelect.value = name;
transform.attach(skater.skelData.bones[name]);
updateRotationInputs();
updateHandles();
}
function updateRotationInputs() {
euler.setFromQuaternion(skater.skelData.bones[selectedBone].quaternion, 'XYZ');
for (const input of document.querySelectorAll('.rot,.rot-num')) {
input.value = Math.round(deg(euler[input.dataset.axis]));
}
}
function setRotationAxis(axis, degrees) {
stopPlayback();
euler.setFromQuaternion(skater.skelData.bones[selectedBone].quaternion, 'XYZ');
euler[axis] = rad(Number(degrees) || 0);
skater.skelData.bones[selectedBone].quaternion.setFromEuler(euler);
skater.mover.updateMatrixWorld(true);
updateRotationInputs();
markDirty('pose changed — set key to keep it');
}
for (const input of document.querySelectorAll('.rot,.rot-num')) {
input.addEventListener('input', () => setRotationAxis(input.dataset.axis, input.value));
}
el.boneSelect.addEventListener('change', () => selectBone(el.boneSelect.value));
el.resetBone.addEventListener('click', () => {
stopPlayback();
skater.skelData.bones[selectedBone].quaternion.identity();
skater.mover.updateMatrixWorld(true);
updateRotationInputs();
markDirty('bone reset — set key to keep it');
});
window.addEventListener('keydown', (event) => {
if (event.target.matches('input,select')) return;
if (event.key.toLowerCase() === 'q' || event.key.toLowerCase() === 'w') transform.setMode('rotate');
});
// ---- timeline + clips -----------------------------------------------------
function timelineDuration() {
const videoRange = el.video.readyState >= 1 ? Math.max(0, Number(el.trimOut.value) - Number(el.trimIn.value)) : 0;
return Math.max(clip.duration || 0, videoRange, 0.1);
}
function formatTime(time) {
const mins = Math.floor(time / 60).toString().padStart(2, '0');
const secs = Math.floor(time % 60).toString().padStart(2, '0');
const ms = Math.floor((time % 1) * 1000).toString().padStart(3, '0');
return `${mins}:${secs}.${ms}`;
}
function markDirty(message = 'unsaved') {
dirty = true;
el.saveState.textContent = message;
}
function editKeyTime() {
const tolerance = 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5;
let nearest = null;
let distance = Infinity;
for (const frame of clip.keyframes) {
const nextDistance = Math.abs(frame.time - currentTime);
if (nextDistance < distance) { nearest = frame; distance = nextDistance; }
}
return nearest && distance <= tolerance ? nearest.time : currentTime;
}
function updateTimeline() {
const duration = timelineDuration();
el.scrubber.max = duration;
el.scrubber.value = Math.min(currentTime, duration);
el.timelineEnd.textContent = `${duration.toFixed(2)} s`;
el.timecode.textContent = formatTime(currentTime);
el.keyCount.textContent = clip.keyframes.length;
el.durationLabel.textContent = `${(clip.duration || 0).toFixed(2)} s`;
const signature = `${duration.toFixed(4)}|${clip.keyframes.map((frame) => frame.time.toFixed(4)).join(',')}`;
if (signature !== markersSignature) {
markersSignature = signature;
activeMarker = null;
el.markers.replaceChildren();
for (const frame of clip.keyframes) {
const marker = document.createElement('button');
marker.className = 'key-marker';
marker.dataset.time = frame.time;
marker.style.left = `${(frame.time / duration) * 100}%`;
marker.title = `key ${frame.time.toFixed(3)}s · ${Math.round(frame.confidence * 100)}% body${frame.stick ? ` · ${Math.round(frame.stick.confidence * 100)}% stick` : ''}`;
marker.addEventListener('click', () => setCurrentTime(frame.time, { seekVideo: true }));
el.markers.append(marker);
}
}
const exact = [...el.markers.children].find((marker) => Math.abs(Number(marker.dataset.time) - currentTime) < 1 / 240) ?? null;
if (exact !== activeMarker) {
activeMarker?.classList.remove('current');
exact?.classList.add('current');
activeMarker = exact;
}
}
function setCurrentTime(time, { seekVideo = false } = {}) {
currentTime = Math.max(0, Math.min(timelineDuration(), Number(time) || 0));
if (clip.keyframes.length) applyTiltAnimation(skater, clip, currentTime);
const stick = sampleTiltStick(clip, currentTime, sampledStick);
currentStick = stick ? structuredClone(stick)
: stickSeed ? { ...stickSeed, grip: stickSeed.grip ?? stickSeed.butt, confidence: 1 } : null;
if (stick && !pickingStick) el.stickStatus.textContent = `Baked stick key · ${Math.round(stick.confidence * 100)}% tracking confidence.`;
skater.mover.updateMatrixWorld(true);
updateRotationInputs();
updateHandles();
if (seekVideo && el.video.readyState >= 1) el.video.currentTime = Math.min(el.video.duration, videoIn + currentTime);
renderReferenceOverlay();
updateTimeline();
}
el.scrubber.addEventListener('input', () => {
if (playing) stopPlayback();
setCurrentTime(el.scrubber.value, { seekVideo: true });
});
el.setKey.addEventListener('click', () => {
stopPlayback();
const keyTime = editKeyTime();
const frame = captureSkeletonKeyframe(skater.skelData, keyTime);
const stick = sampleTiltStick(clip, currentTime, {});
if (stick) frame.stick = structuredClone(stick);
setClipKeyframe(clip, frame, 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5);
markDirty('key saved in clip · project unsaved');
setCurrentTime(keyTime);
});
el.deleteKey.addEventListener('click', () => {
if (deleteClipKeyframe(clip, currentTime, 1 / Math.max(30, clip.fps * 2))) {
markDirty();
setCurrentTime(currentTime);
}
});
el.smoothKeys.addEventListener('click', () => {
smoothTiltClip(clip, 0.35);
markDirty('smoothed');
setCurrentTime(currentTime);
});
el.loop.addEventListener('change', () => { clip.loop = el.loop.value === 'loop'; markDirty(); });
el.clipName.addEventListener('input', () => { clip.name = el.clipName.value.trim() || 'reference-motion'; markDirty(); });
el.prevKey.addEventListener('click', () => {
const frame = [...clip.keyframes].reverse().find((item) => item.time < currentTime - 1e-4) ?? clip.keyframes.at(-1);
if (frame) setCurrentTime(frame.time, { seekVideo: true });
});
el.nextKey.addEventListener('click', () => {
const frame = clip.keyframes.find((item) => item.time > currentTime + 1e-4) ?? clip.keyframes[0];
if (frame) setCurrentTime(frame.time, { seekVideo: true });
});
function stopPlayback() {
playing = false;
el.video.pause();
el.playPause.textContent = '▶ Play';
}
function startPlayback() {
if (timelineDuration() <= 0) return;
playing = true;
el.playPause.textContent = '❚❚ Pause';
if (el.video.readyState >= 2) {
if (currentTime >= timelineDuration() - 0.01) setCurrentTime(0, { seekVideo: true });
el.video.currentTime = Math.min(el.video.duration, videoIn + currentTime);
el.video.play().catch(() => stopPlayback());
el.timelineSource.textContent = 'video + clip';
} else {
localPlayStarted = performance.now() / 1000;
localPlayOffset = currentTime;
el.timelineSource.textContent = 'clip';
}
}
el.playPause.addEventListener('click', () => playing ? stopPlayback() : startPlayback());
el.video.addEventListener('ended', () => {
if (clip.loop) { setCurrentTime(0, { seekVideo: true }); startPlayback(); }
else stopPlayback();
});
// ---- video + MediaPipe extraction ----------------------------------------
function clearStickTracking() {
stickSeed = null;
stickTracker = null;
currentStick = null;
pickingStick = null;
stickPickPurpose = null;
el.trackStick.checked = false;
el.overlay.classList.remove('picking');
el.stickStatus.textContent = 'Choose the top-hand socket, then mark both shaft ends in either order.';
renderReferenceOverlay();
}
el.seedStick.addEventListener('click', async () => {
if (el.video.readyState < 1) { el.stickStatus.textContent = 'Choose a video first.'; return; }
stopPlayback();
videoIn = Math.max(0, Number(el.trimIn.value) || 0);
await seekVideo(videoIn);
currentTime = 0;
pickingStick = {};
stickPickPurpose = 'seed';
currentStick = null;
el.overlay.classList.add('picking');
el.stickStatus.textContent = 'Click either end of the stick.';
renderReferenceOverlay();
});
el.correctStick.addEventListener('click', async () => {
if (el.video.readyState < 1) { el.stickStatus.textContent = 'Choose a video first.'; return; }
stopPlayback();
await seekVideo(Math.min(el.video.duration, videoIn + currentTime));
pickingStick = {};
stickPickPurpose = 'correct';
el.overlay.classList.add('picking');
el.stickStatus.textContent = 'Correction: click either end of the stick.';
renderReferenceOverlay();
});
el.overlay.addEventListener('pointerdown', async (event) => {
if (!pickingStick) return;
event.preventDefault();
const rect = el.overlay.getBoundingClientRect();
const point = [
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
];
if (!pickingStick.butt) {
pickingStick.butt = point;
el.stickStatus.textContent = 'Now click the other end.';
} else {
pickingStick.blade = point;
const picked = { butt: [...pickingStick.butt], blade: [...point], confidence: 1 };
if (stickPickPurpose === 'seed') {
const dx = point[0] - pickingStick.butt[0];
const dy = point[1] - pickingStick.butt[1];
stickSeed = {
butt: picked.butt, blade: picked.blade,
grip: [pickingStick.butt[0] + dx * 0.3, pickingStick.butt[1] + dy * 0.3],
};
currentStick = { ...stickSeed, confidence: 1 };
stickTracker = createSeededStickTracker(el.video, stickSeed);
el.trackStick.checked = true;
el.stickStatus.textContent = 'Seeded. Generate will track butt, grip, and blade on every sampled frame.';
} else {
el.stickStatus.textContent = 'Re-detecting the body frame and baking the correction…';
const pose = await detectVideoPose(el.video, performance.now());
if (pose) {
picked.grip = stickGripFromWrists(picked, pose.normalized);
const baked = bakeStickLandmarks(
skater.skelData, skater.mover, pose.normalized, picked, stickBakeOptions(),
);
if (baked) {
const frame = captureSkeletonKeyframe(skater.skelData, currentTime);
frame.stick = baked;
setClipKeyframe(clip, frame);
currentStick = baked;
markDirty('stick key corrected · unsaved');
updateTimeline();
el.stickStatus.textContent = `Corrected stick landmarks at ${currentTime.toFixed(3)} s.`;
}
} else el.stickStatus.textContent = 'Could not detect the body in this correction frame.';
}
pickingStick = null;
stickPickPurpose = null;
el.overlay.classList.remove('picking');
}
renderReferenceOverlay();
});
el.clearStick.addEventListener('click', clearStickTracking);
el.applyStick.addEventListener('click', () => {
stopPlayback();
if (!currentStick?.butt || !currentStick?.blade) {
el.stickStatus.textContent = 'Draw or correct the stick guide first.';
return;
}
const keyTime = editKeyTime();
const dx = currentStick.blade[0] - currentStick.butt[0];
const dy = currentStick.blade[1] - currentStick.butt[1];
const applied = {
butt: [...currentStick.butt],
grip: [...(currentStick.grip ?? currentStick.butt)],
blade: [...currentStick.blade],
target: currentStick.target ? [...currentStick.target] : [0, 0, 0],
angle: Number.isFinite(currentStick.angle) ? currentStick.angle : Math.atan2(-dy, dx),
roll: Number(currentStick.roll) || 0,
alignHands: true,
confidence: Number.isFinite(currentStick.confidence) ? currentStick.confidence : 1,
};
const frame = captureSkeletonKeyframe(skater.skelData, keyTime);
frame.stick = applied;
setClipKeyframe(clip, frame, 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5);
currentTime = keyTime;
currentStick = structuredClone(applied);
applyTiltStickPose(skater, applied);
skater.mover.updateMatrixWorld(true);
updateHandles();
updateRotationInputs();
markDirty('stick + pose saved in clip · project unsaved');
updateTimeline();
renderReferenceOverlay();
el.stickStatus.textContent = `Applied at ${keyTime.toFixed(3)} s · shaft constrained through both hands.`;
});
el.flipStick.addEventListener('click', () => {
let flipped = 0;
for (const frame of clip.keyframes) {
if (!frame.stick) continue;
const angle = Number.isFinite(frame.stick.angle)
? frame.stick.angle
: Math.atan2(
-(frame.stick.blade[1] - frame.stick.butt[1]),
frame.stick.blade[0] - frame.stick.butt[0],
);
[frame.stick.butt, frame.stick.blade] = [frame.stick.blade, frame.stick.butt];
frame.stick.angle = Math.atan2(Math.sin(angle + Math.PI), Math.cos(angle + Math.PI));
flipped++;
}
if (!flipped) {
el.stickStatus.textContent = 'No baked stick keys to flip.';
return;
}
markDirty(`flipped ${flipped} stick keys · unsaved`);
setCurrentTime(currentTime);
el.stickStatus.textContent = `Flipped the shaft direction on ${flipped} baked keys.`;
});
el.shotSide.addEventListener('change', () => {
setStudioShotSide(el.shotSide.value);
markDirty(`${el.shotSide.value} shot · unsaved`);
setCurrentTime(currentTime);
el.stickStatus.textContent = `${el.shotSide.value === 'left' ? 'Left' : 'Right'} hand is now the stick socket.`;
});
function useVideo(file) {
stopPlayback();
clearStickTracking();
lastLandmarks = null;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
videoFile = file;
el.video.src = videoUrl;
el.video.load();
el.videoEmpty.hidden = true;
el.status.textContent = `${file.name} loaded. Set the capture range, then generate.`;
}
el.chooseVideo.addEventListener('click', () => el.fileInput.click());
el.fileInput.addEventListener('change', () => { if (el.fileInput.files[0]) useVideo(el.fileInput.files[0]); });
async function refreshFreeMoCapStatus() {
el.freeMocapState.textContent = 'Checking local worker…';
try {
const response = await fetch('/api/freemocap/status');
const status = await response.json();
if (!response.ok || !status.available) throw new Error(status.message || 'worker unavailable');
el.freeMocapState.textContent = `Ready · FreeMoCap ${status.version}`;
el.importFreeMocap.disabled = false;
} catch (error) {
el.freeMocapState.textContent = `Worker unavailable · run npm run freemocap:setup (${error.message})`;
el.importFreeMocap.disabled = true;
}
}
el.mocapSource.addEventListener('change', () => {
const useFreeMoCap = el.mocapSource.value === 'freemocap';
el.browserMocapControls.hidden = useFreeMoCap;
el.freeMocapControls.hidden = !useFreeMoCap;
el.progress.value = 0;
el.status.textContent = useFreeMoCap
? 'Choose a video, then process it through the local FreeMoCap worker.'
: 'Choose a video, set a short in/out range, then generate.';
if (useFreeMoCap) refreshFreeMoCapStatus();
});
async function loadFreeMoCapResult(text, filename, sourceFps) {
const imported = parseFreeMoCapCsv(text);
const stride = Math.max(1, Math.ceil(imported.frames.length / 3000));
const frames = imported.frames.filter((_frame, index) => index % stride === 0);
const firstFrame = frames[0].frame;
const generatedName = filename.replace(/(?:_freemocap_data_by_frame|_body_3d_xyz)?\.(?:csv|mp4|mov|webm|m4v)$/i, '') || 'freemocap-motion';
if (!el.clipName.value.trim() || el.clipName.value === 'reference-motion') el.clipName.value = generatedName;
const next = createTiltClip({
name: el.clipName.value.trim() || generatedName,
fps: sourceFps / stride,
loop: el.loop.value === 'loop',
shotSide: el.shotSide.value,
});
for (let i = 0; i < frames.length; i++) {
const source = frames[i];
const time = (source.frame - firstFrame) / sourceFps;
const confidence = retargetPoseToSkeleton(skater.skelData, source.landmarks, { mirror: el.mirror.checked });
setClipKeyframe(next, captureSkeletonKeyframe(skater.skelData, time, { confidence }));
el.progress.value = (i + 1) / frames.length;
if (i % 60 === 0) await new Promise(requestAnimationFrame);
}
next.duration = next.keyframes.at(-1)?.time ?? 0;
smoothTiltClip(next, 0.16);
clip = next;
lastLandmarks = null;
currentStick = null;
markersSignature = '';
markDirty(`${next.keyframes.length} FreeMoCap keys · unsaved`);
el.timelineSource.textContent = `FreeMoCap ${imported.tracker}`;
el.confidence.textContent = '3D IMPORT';
el.status.textContent = `Imported ${next.keyframes.length} ${imported.trajectory} keys from ${imported.tracker} at ${sourceFps} fps${stride > 1 ? ` (sampled every ${stride} frames)` : ''}.`;
setCurrentTime(0);
}
el.importFreeMocap.addEventListener('click', async () => {
if (!videoFile || extracting) {
if (!videoFile) el.status.textContent = 'Choose or drop a reference video first.';
return;
}
extracting = true;
stopPlayback();
el.importFreeMocap.disabled = true;
el.progress.removeAttribute('value');
const sourceFps = Math.max(1, Math.min(60, Number(el.freeMocapFps.value) || 30));
try {
el.status.textContent = `Uploading ${videoFile.name}; FreeMoCap processing can take several minutes…`;
const query = new URLSearchParams({ filename: videoFile.name, fps: String(sourceFps) });
const response = await fetch(`/api/freemocap/process?${query}`, {
method: 'POST',
headers: { 'Content-Type': videoFile.type || 'application/octet-stream' },
body: videoFile,
});
const result = await response.text();
if (!response.ok) throw new Error(result || `worker returned ${response.status}`);
const detectedFps = Number(response.headers.get('X-Tilt-Source-Fps')) || sourceFps;
el.freeMocapFps.value = String(Number(detectedFps.toFixed(3)));
el.progress.value = 0.85;
await loadFreeMoCapResult(result, videoFile.name, detectedFps);
} catch (error) {
console.error(error);
el.status.textContent = `FreeMoCap processing failed: ${error.message}`;
el.confidence.textContent = 'IMPORT ERROR';
} finally {
extracting = false;
el.progress.value = Number(el.progress.value) || 0;
refreshFreeMoCapStatus();
}
});
el.importFreeMocapCsv.addEventListener('click', () => el.freeMocapInput.click());
el.freeMocapInput.addEventListener('change', async () => {
const file = el.freeMocapInput.files[0];
if (!file || extracting) return;
const sourceFps = Math.max(1, Math.min(60, Number(el.freeMocapFps.value) || 30));
try {
el.status.textContent = `Reading ${file.name}`;
await loadFreeMoCapResult(await file.text(), file.name, sourceFps);
} catch (error) {
console.error(error);
el.status.textContent = `FreeMoCap CSV import failed: ${error.message}`;
el.confidence.textContent = 'IMPORT ERROR';
} finally {
el.freeMocapInput.value = '';
}
});
document.addEventListener('dragover', (event) => event.preventDefault());
document.addEventListener('drop', (event) => {
event.preventDefault();
const file = [...event.dataTransfer.files].find((item) => item.type.startsWith('video/'));
if (file) useVideo(file);
});
el.video.addEventListener('loadedmetadata', () => {
el.trimIn.max = el.video.duration;
el.trimOut.max = el.video.duration;
el.trimOut.value = el.video.duration.toFixed(3);
videoIn = 0;
currentTime = 0;
layoutVideoOverlay();
updateTimeline();
});
for (const input of [el.trimIn, el.trimOut]) input.addEventListener('change', () => {
videoIn = Math.max(0, Number(el.trimIn.value) || 0);
if (Number(el.trimOut.value) <= videoIn) el.trimOut.value = Math.min(el.video.duration || videoIn + 1, videoIn + 1).toFixed(3);
setCurrentTime(0, { seekVideo: true });
});
function seekVideo(time) {
return new Promise((resolve, reject) => {
const done = () => { cleanup(); resolve(); };
const fail = () => { cleanup(); reject(new Error('Could not seek reference video')); };
const cleanup = () => {
el.video.removeEventListener('seeked', done);
el.video.removeEventListener('canplay', done);
el.video.removeEventListener('error', fail);
};
el.video.addEventListener('seeked', done, { once: true });
el.video.addEventListener('canplay', done, { once: true });
el.video.addEventListener('error', fail, { once: true });
if (Math.abs(el.video.currentTime - time) < 1e-4 && !el.video.seeking && el.video.readyState >= 2) {
cleanup();
requestAnimationFrame(resolve);
} else el.video.currentTime = time;
});
}
el.extract.addEventListener('click', async () => {
if (extracting || el.video.readyState < 1) {
if (el.video.readyState < 1) el.status.textContent = 'Choose a video first.';
return;
}
extracting = true;
stopPlayback();
el.extract.disabled = true;
el.progress.value = 0;
try {
const fps = Math.max(1, Math.min(30, Number(el.captureFps.value) || 12));
const start = Math.max(0, Number(el.trimIn.value) || 0);
const end = Math.min(el.video.duration, Math.max(start + 1 / fps, Number(el.trimOut.value) || el.video.duration));
const count = Math.max(2, Math.floor((end - start) * fps) + 1);
if (count > 3000) throw new Error('Capture range is too long; trim it below 3,000 sampled frames');
el.status.textContent = 'Loading MediaPipe pose model…';
await loadPoseLandmarker();
const next = createTiltClip({
name: el.clipName.value.trim() || 'reference-motion',
fps,
loop: el.loop.value === 'loop',
shotSide: el.shotSide.value,
});
next.duration = end - start;
let found = 0;
let sticksFound = 0;
if (el.trackStick.checked && !stickTracker) throw new Error('Mark the stick butt and blade before enabling stick tracking');
stickTracker?.reset();
for (let i = 0; i < count; i++) {
const sourceTime = Math.min(end, start + i / fps);
const clipTime = sourceTime - start;
el.status.textContent = `Tracking frame ${i + 1} / ${count}`;
await seekVideo(sourceTime);
const pose = await detectVideoPose(el.video, performance.now());
if (pose) {
const confidence = retargetPoseToSkeleton(skater.skelData, pose.world, { mirror: el.mirror.checked });
const frame = captureSkeletonKeyframe(skater.skelData, clipTime, { confidence });
if (el.trackStick.checked && stickTracker) {
const tracked = stickTracker.track();
tracked.grip = stickGripFromWrists(tracked, pose.normalized);
const baked = bakeStickLandmarks(
skater.skelData, skater.mover, pose.normalized, tracked, stickBakeOptions(),
);
if (baked) {
frame.stick = baked;
currentStick = baked;
sticksFound++;
}
}
setClipKeyframe(next, frame);
lastLandmarks = pose.normalized;
renderReferenceOverlay();
el.confidence.textContent = `${Math.round(confidence * 100)}% TRACK`;
found++;
}
el.progress.value = (i + 1) / count;
if (i % 3 === 0) await new Promise(requestAnimationFrame);
}
if (!found) throw new Error('No full-body pose was detected in the selected range');
clip = next;
videoIn = start;
smoothTiltClip(clip, 0.22);
markDirty(`${found} tracked keys · unsaved`);
el.status.textContent = `Generated ${found} editable keys at ${fps} fps${sticksFound ? ` with ${sticksFound} baked stick tracks` : ''}. Low-confidence frames can be corrected in the studio.`;
setCurrentTime(0, { seekVideo: true });
} catch (error) {
console.error(error);
el.status.textContent = `Capture failed: ${error.message}`;
el.confidence.textContent = 'TRACK ERROR';
} finally {
extracting = false;
el.extract.disabled = false;
}
});
el.mirror.addEventListener('change', () => {
renderReferenceOverlay();
});
// ---- save/import/export ---------------------------------------------------
const STORAGE_KEY = 'tilt-animation-projects-v1';
function projects() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
catch { return {}; }
}
function storeProjects(value) { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); }
el.saveProject.addEventListener('click', () => {
clip.name = el.clipName.value.trim() || 'reference-motion';
try {
const all = projects();
all[clip.name] = sanitizeTiltClip(clip);
storeProjects(all);
dirty = false;
el.saveState.textContent = 'saved locally';
} catch (error) {
el.status.textContent = `Local save failed (${error.message}). Export JSON to keep this clip.`;
}
});
el.loadProject.addEventListener('click', () => {
const all = projects();
const names = Object.keys(all).sort();
if (!names.length) { el.status.textContent = 'No locally saved animation projects yet.'; return; }
const name = prompt(`Open saved project:\n${names.join('\n')}`, names[0]);
if (name && all[name]) loadClip(all[name], 'saved project');
});
function loadClip(data, source = 'file') {
stopPlayback();
clip = sanitizeTiltClip(data);
setStudioShotSide(clip.shotSide, { updateClip: false });
el.clipName.value = clip.name;
el.loop.value = clip.loop ? 'loop' : 'once';
currentTime = 0;
dirty = false;
el.saveState.textContent = source;
const stickKeys = clip.keyframes.filter((frame) => frame.stick).length;
el.status.textContent = `Loaded ${clip.keyframes.length} keys${stickKeys ? ` including ${stickKeys} stick tracks` : ''} from ${source}.`;
setCurrentTime(0);
}
el.importClip.addEventListener('click', () => el.clipInput.click());
el.clipInput.addEventListener('change', async () => {
const file = el.clipInput.files[0];
if (!file) return;
try { loadClip(JSON.parse(await file.text()), file.name); }
catch (error) { el.status.textContent = `Import failed: ${error.message}`; }
el.clipInput.value = '';
});
function safeFilename(name) {
return String(name || 'reference-motion').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'reference-motion';
}
function download(contents, filename, type) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
el.exportJson.addEventListener('click', () => {
clip.name = el.clipName.value.trim() || 'reference-motion';
download(clipAsJson(clip), `${safeFilename(clip.name)}.tiltanim.json`, 'application/json');
el.status.textContent = 'Exported a reloadable Tilt animation JSON clip.';
});
el.exportModule.addEventListener('click', () => {
clip.name = el.clipName.value.trim() || 'reference-motion';
download(clipAsModule(clip), `${safeFilename(clip.name)}.js`, 'text/javascript');
el.status.textContent = `Exported a pose module. Put it in src/anim/poses/generated/ and import it where the runtime action is driven.`;
});
el.newClip.addEventListener('click', () => {
if (dirty && !confirm('Discard the unsaved animation and start a new clip?')) return;
clip = createTiltClip({ name: 'reference-motion', shotSide: el.shotSide.value });
el.clipName.value = clip.name;
dirty = false;
el.saveState.textContent = 'new clip';
currentTime = 0;
updateTimeline();
});
window.addEventListener('beforeunload', (event) => {
if (!dirty) return;
event.preventDefault();
});
// ---- render loop ----------------------------------------------------------
function frame() {
requestAnimationFrame(frame);
if (playing) {
const duration = timelineDuration();
if (el.video.readyState >= 2 && !el.video.paused) {
const time = el.video.currentTime - videoIn;
if (time >= duration - 1e-3) {
if (clip.loop) { el.video.currentTime = videoIn; currentTime = 0; }
else stopPlayback();
} else setCurrentTime(time);
} else if (el.video.readyState < 2) {
let time = localPlayOffset + performance.now() / 1000 - localPlayStarted;
if (time >= duration) {
if (clip.loop) { localPlayStarted = performance.now() / 1000; localPlayOffset = 0; time %= duration; }
else { time = duration; stopPlayback(); }
}
setCurrentTime(time);
}
}
orbit.update();
skater.mover.updateMatrixWorld(true);
updateHandles();
renderer.render(scene, camera);
}
selectBone(selectedBone);
updateTimeline();
frame();
window.tiltAnimationStudio = {
get clip() { return clip; },
get skater() { return skater; },
setTime: setCurrentTime,
loadClip,
};
+115
View File
@@ -0,0 +1,115 @@
/**
* FreeMoCap CSV adapter.
*
* Supports the current tidy `freemocap_data_by_frame.csv` output and the
* per-trajectory `*_body_3d_xyz.csv` output. FreeMoCap uses millimetres with Z
* up; the returned pose uses MediaPipe's index order and camera-style axes so
* it can pass through Tilt's existing direction-only retargeter.
*/
const LANDMARK_INDEX = new Map([
['nose', 0],
['left_eye_inner', 1], ['left_eye', 2], ['left_eye_outer', 3],
['right_eye_inner', 4], ['right_eye', 5], ['right_eye_outer', 6],
['left_ear', 7], ['right_ear', 8],
['mouth_left', 9], ['mouth_right', 10],
['left_shoulder', 11], ['right_shoulder', 12],
['left_elbow', 13], ['right_elbow', 14],
['left_wrist', 15], ['right_wrist', 16],
['left_pinky', 17], ['right_pinky', 18],
['left_index', 19], ['right_index', 20],
['left_thumb', 21], ['right_thumb', 22],
['left_hip', 23], ['right_hip', 24],
['left_knee', 25], ['right_knee', 26],
['left_ankle', 27], ['right_ankle', 28],
['left_heel', 29], ['right_heel', 30],
['left_foot_index', 31], ['right_foot_index', 32],
// RTMPose names use big toe rather than MediaPipe's foot index.
['left_big_toe', 31], ['right_big_toe', 32],
]);
const REQUIRED = [7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
function csvRows(text) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
if (quoted && text[i + 1] === '"') { field += '"'; i++; }
else quoted = !quoted;
} else if (char === ',' && !quoted) {
row.push(field);
field = '';
} else if ((char === '\n' || char === '\r') && !quoted) {
if (char === '\r' && text[i + 1] === '\n') i++;
row.push(field);
if (row.some((value) => value.length)) rows.push(row);
row = [];
field = '';
} else field += char;
}
row.push(field);
if (row.some((value) => value.length)) rows.push(row);
return rows;
}
function normalizedName(value) {
return String(value ?? '').trim().toLowerCase().replace(/[ -]+/g, '_');
}
function blankPose() {
return Array.from({ length: 33 }, () => ({ x: 0, y: 0, z: 0, visibility: 0 }));
}
/** Parse a FreeMoCap body XYZ CSV into ordered frames. */
export function parseFreeMoCapCsv(text) {
const rows = csvRows(String(text).replace(/^\uFEFF/, ''));
if (rows.length < 2) throw new Error('FreeMoCap CSV is empty');
const headers = rows[0].map(normalizedName);
const column = (name) => headers.indexOf(name);
const frameCol = column('frame');
const keypointCol = column('keypoint');
const xCol = column('x');
const yCol = column('y');
const zCol = column('z');
const modelCol = column('model');
const trajectoryCol = column('trajectory');
if ([frameCol, keypointCol, xCol, yCol, zCol].some((index) => index < 0)) {
throw new Error('Expected FreeMoCap columns: frame, keypoint, x, y, z');
}
const availableTrajectories = new Set(
trajectoryCol < 0 ? [] : rows.slice(1).map((row) => normalizedName(row[trajectoryCol])),
);
const preferredTrajectory = availableTrajectories.has('rigid_3d_xyz') ? 'rigid_3d_xyz' : '3d_xyz';
const frames = new Map();
let tracker = 'unknown';
for (const row of rows.slice(1)) {
const model = modelCol < 0 ? '' : normalizedName(row[modelCol]);
const trajectory = trajectoryCol < 0 ? '3d_xyz' : normalizedName(row[trajectoryCol]);
if (model && !model.endsWith('.body') && !model.endsWith('_body') && model !== 'body') continue;
if (trajectory !== preferredTrajectory) continue;
const index = LANDMARK_INDEX.get(normalizedName(row[keypointCol]));
const frameNumber = Number(row[frameCol]);
const x = Number(row[xCol]);
const y = Number(row[yCol]);
const z = Number(row[zCol]);
if (index === undefined || !Number.isFinite(frameNumber) || ![x, y, z].every(Number.isFinite)) continue;
if (model.startsWith('mediapipe')) tracker = 'MediaPipe';
else if (model.startsWith('rtmpose')) tracker = 'RTMPose';
if (!frames.has(frameNumber)) frames.set(frameNumber, blankPose());
// FreeMoCap is X/Y ground plane, Z up. This conversion makes the existing
// retargeter produce Tilt coordinates (X, Z-up, Y-depth).
frames.get(frameNumber)[index] = { x, y: -z, z: -y, visibility: 1 };
}
const parsed = [...frames].sort((a, b) => a[0] - b[0]).map(([frame, landmarks]) => ({ frame, landmarks }));
const complete = parsed.filter(({ landmarks }) => REQUIRED.every((index) => landmarks[index].visibility > 0));
if (!complete.length) {
throw new Error('No complete FreeMoCap body frames found; export body 3d_xyz or rigid_3d_xyz data');
}
return { frames: complete, tracker, trajectory: preferredTrajectory };
}
+262
View File
@@ -0,0 +1,262 @@
import * as THREE from 'three';
// Pose-only paths. The model is loaded lazily after the user presses Extract,
// so opening the studio does not pay a network/model cost.
const DEFAULT_PATHS = {
wasm: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.32/wasm',
model: 'https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/pose_landmarker_full.task',
};
export const POSE_CONNECTIONS = [
[0, 2], [2, 5], [5, 0], [7, 8],
[11, 12], [11, 13], [13, 15], [15, 17], [15, 19], [15, 21],
[12, 14], [14, 16], [16, 18], [16, 20], [16, 22],
[11, 23], [12, 24], [23, 24],
[23, 25], [25, 27], [27, 29], [29, 31], [27, 31],
[24, 26], [26, 28], [28, 30], [30, 32], [28, 32],
];
let landmarkerPromise = null;
let lastVideoTimestamp = -1;
export async function loadPoseLandmarker({ wasm = DEFAULT_PATHS.wasm, model = DEFAULT_PATHS.model } = {}) {
if (!landmarkerPromise) {
landmarkerPromise = import('@mediapipe/tasks-vision').then(async ({ FilesetResolver, PoseLandmarker }) => {
const vision = await FilesetResolver.forVisionTasks(wasm);
return PoseLandmarker.createFromOptions(vision, {
baseOptions: { modelAssetPath: model, delegate: 'GPU' },
runningMode: 'VIDEO',
numPoses: 1,
minPoseDetectionConfidence: 0.5,
minPosePresenceConfidence: 0.5,
minTrackingConfidence: 0.5,
});
}).catch((error) => {
landmarkerPromise = null;
throw error;
});
}
return landmarkerPromise;
}
export async function detectVideoPose(video, timestampMs) {
const landmarker = await loadPoseLandmarker();
lastVideoTimestamp = Math.max(lastVideoTimestamp + 1, timestampMs);
const result = landmarker.detectForVideo(video, lastVideoTimestamp);
if (!result.worldLandmarks?.[0] || !result.landmarks?.[0]) return null;
return {
world: result.worldLandmarks[0],
normalized: result.landmarks[0],
};
}
export function drawPoseOverlay(canvas, video, landmarks, { mirror = false } = {}) {
const ctx = canvas.getContext('2d');
const width = Math.max(1, video.videoWidth || canvas.clientWidth || 1);
const height = Math.max(1, video.videoHeight || canvas.clientHeight || 1);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.clearRect(0, 0, width, height);
if (!landmarks) return;
const point = (landmark) => ({
x: (mirror ? 1 - landmark.x : landmark.x) * width,
y: landmark.y * height,
});
ctx.strokeStyle = '#68e0c2';
ctx.lineWidth = Math.max(2, width / 420);
ctx.globalAlpha = 0.82;
for (const [a, b] of POSE_CONNECTIONS) {
if ((landmarks[a]?.visibility ?? 1) < 0.35 || (landmarks[b]?.visibility ?? 1) < 0.35) continue;
const pa = point(landmarks[a]);
const pb = point(landmarks[b]);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
ctx.fillStyle = '#f6c85f';
ctx.globalAlpha = 0.95;
for (const landmark of landmarks) {
if ((landmark.visibility ?? 1) < 0.35) continue;
const p = point(landmark);
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(2.5, width / 240), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
export function drawStickOverlay(canvas, stick, { picking = null } = {}) {
if (!stick && !picking) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const butt = stick?.butt ?? picking?.butt;
const blade = stick?.blade ?? picking?.blade;
const grip = stick?.grip;
ctx.save();
ctx.lineCap = 'round';
ctx.lineWidth = Math.max(4, width / 180);
ctx.strokeStyle = '#ff5f72';
if (butt && blade) {
ctx.beginPath();
ctx.moveTo(butt[0] * width, butt[1] * height);
ctx.lineTo(blade[0] * width, blade[1] * height);
ctx.stroke();
}
for (const [point, color, radius] of [[butt, '#ffffff', 7], [grip, '#68e0c2', 6], [blade, '#ffcf5c', 8]]) {
if (!point) continue;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(point[0] * width, point[1] * height, Math.max(4, width / 300 * radius), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
const _v = Array.from({ length: 33 }, () => new THREE.Vector3());
const _a = new THREE.Vector3();
const _x = new THREE.Vector3();
const _y = new THREE.Vector3();
const _z = new THREE.Vector3();
const _parentQ = new THREE.Quaternion();
const _targetQ = new THREE.Quaternion();
const _matrix = new THREE.Matrix4();
const _hipMid = new THREE.Vector3();
const _shoulderMid = new THREE.Vector3();
const _earMid = new THREE.Vector3();
const _side = new THREE.Vector3();
const _up = new THREE.Vector3();
const _mapped = new THREE.Vector3();
const _anchorWorld = new THREE.Vector3();
function midpoint(a, b, out) {
return out.copy(a).add(b).multiplyScalar(0.5);
}
function setWorldFrame(bone, xAxis, yAxis) {
_y.copy(yAxis).normalize();
if (_y.lengthSq() < 1e-8) return;
_x.copy(xAxis).addScaledVector(_y, -xAxis.dot(_y));
if (_x.lengthSq() < 1e-8) {
_x.set(1, 0, 0).addScaledVector(_y, -_y.x);
if (_x.lengthSq() < 1e-8) _x.set(0, 0, 1).addScaledVector(_y, -_y.z);
}
else _x.normalize();
_x.normalize();
_z.crossVectors(_x, _y).normalize();
_x.crossVectors(_y, _z).normalize();
_matrix.makeBasis(_x, _y, _z);
_targetQ.setFromRotationMatrix(_matrix);
bone.parent?.getWorldQuaternion(_parentQ);
bone.quaternion.copy(_parentQ.invert()).multiply(_targetQ).normalize();
bone.updateWorldMatrix(false, true);
}
function aimBone(bone, targetWorldDirection, restDirection) {
bone.parent?.getWorldQuaternion(_parentQ);
_a.copy(targetWorldDirection).normalize().applyQuaternion(_parentQ.invert());
if (_a.lengthSq() < 1e-8) return;
bone.quaternion.setFromUnitVectors(restDirection, _a).normalize();
bone.updateWorldMatrix(false, true);
}
/**
* Retarget one MediaPipe world pose onto Tilt's native local-quaternion rig.
* No source limb lengths are copied; directions alone drive the skeleton.
*/
export function retargetPoseToSkeleton(skelData, landmarks, { mirror = false } = {}) {
if (!landmarks || landmarks.length < 33) return 0;
const sx = mirror ? -1 : 1;
for (let i = 0; i < 33; i++) {
const p = landmarks[i];
// MediaPipe is camera X / image-down Y. Tilt is left-positive X / up Y.
_v[i].set(p.x * sx, -p.y, -p.z);
}
const B = skelData.bones;
for (const bone of skelData.list) bone.quaternion.identity();
B.root.position.set(0, 0, 0);
skelData.rootBone.updateMatrixWorld(true);
midpoint(_v[23], _v[24], _hipMid);
midpoint(_v[11], _v[12], _shoulderMid);
_side.subVectors(_v[11], _v[12]);
_up.subVectors(_shoulderMid, _hipMid);
setWorldFrame(B.pelvis, _side, _up);
// The torso frame lives on the pelvis. Keeping the small spine chain neutral
// avoids multiplying the same source rotation four times.
for (const name of ['spine1', 'spine2', 'spine3']) B[name].quaternion.identity();
B.spine3.updateWorldMatrix(true, true);
midpoint(_v[7], _v[8], _earMid);
_side.subVectors(_v[7], _v[8]);
_up.subVectors(_earMid, _shoulderMid);
setWorldFrame(B.neck, _side, _up);
B.head.quaternion.identity();
const rest = (childName) => B[childName].position.clone().normalize();
aimBone(B.upperArmL, _a.subVectors(_v[13], _v[11]), rest('forearmL'));
aimBone(B.forearmL, _a.subVectors(_v[15], _v[13]), rest('handL'));
aimBone(B.upperArmR, _a.subVectors(_v[14], _v[12]), rest('forearmR'));
aimBone(B.forearmR, _a.subVectors(_v[16], _v[14]), rest('handR'));
aimBone(B.thighL, _a.subVectors(_v[25], _v[23]), rest('shinL'));
aimBone(B.shinL, _a.subVectors(_v[27], _v[25]), rest('footL'));
aimBone(B.footL, _a.subVectors(_v[31], _v[27]), rest('toeL'));
aimBone(B.thighR, _a.subVectors(_v[26], _v[24]), rest('shinR'));
aimBone(B.shinR, _a.subVectors(_v[28], _v[26]), rest('footR'));
aimBone(B.footR, _a.subVectors(_v[32], _v[28]), rest('toeR'));
skelData.rootBone.updateMatrixWorld(true);
const important = [11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28];
return important.reduce((sum, index) => sum + (landmarks[index].visibility ?? 1), 0) / important.length;
}
/**
* Bake the marked shaft line into Tilt's mover-local camera plane.
*
* Monocular video does not contain reliable stick depth. The old affine body
* fit invented depth independently for the blade and made the aim flip as the
* body fit changed. Buttblade is the measured direction, so preserve it and
* leave depth neutral. This is stable, faithful to the reference silhouette,
* and can be replaced by a real keypoint/depth model later without changing
* the clip shape.
*/
export function bakeStickLandmarks(
skelData,
mover,
normalizedPose,
trackedStick,
{ mirror = false, socketHand = 'R' } = {},
) {
const hand = socketHand === 'L' ? 'L' : 'R';
let butt = trackedStick.butt;
let blade = trackedStick.blade;
// The seed UI accepts the ends in either order. The shaft end closest to the
// selected top-hand wrist is the butt; this also prevents a 180° aim error.
const wrist = normalizedPose?.[hand === 'L' ? 15 : 16];
if (wrist) {
const buttDistance = Math.hypot(butt[0] - wrist.x, butt[1] - wrist.y);
const bladeDistance = Math.hypot(blade[0] - wrist.x, blade[1] - wrist.y);
if (bladeDistance < buttDistance) [butt, blade] = [blade, butt];
}
mover.updateMatrixWorld(true);
skelData.bones[`hand${hand}`].getWorldPosition(_anchorWorld);
mover.worldToLocal(_anchorWorld);
const dx = (blade[0] - butt[0]) * (mirror ? -1 : 1);
const dy = blade[1] - butt[1];
const angle = Math.atan2(-dy, dx);
_mapped.copy(_anchorWorld).addScaledVector(_x.set(Math.cos(angle), Math.sin(angle), 0), 1.12);
return {
butt: [...butt],
grip: [...trackedStick.grip],
blade: [...blade],
target: _mapped.toArray(),
angle,
roll: Number(trackedStick.roll) || 0,
confidence: Math.max(0, Math.min(1, Number(trackedStick.confidence) || 0)),
};
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Tiny seeded image tracker for a hockey stick's two useful endpoints.
*
* A hockey stick is not one of MediaPipe Pose's semantic landmarks. Rather
* than ship a second large generic detector (which would only return a box),
* the editor asks for one butt/blade seed and follows the appearance around
* those points through the already-sampled frames.
*/
const MAX_IMAGE_SIDE = 480;
const PATCH_RADIUS = 4;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function pointPatch(data, width, height, point, radius = PATCH_RADIUS) {
const cx = clamp(Math.round(point[0] * width), radius, width - radius - 1);
const cy = clamp(Math.round(point[1] * height), radius, height - radius - 1);
const values = new Float32Array((radius * 2 + 1) ** 2 * 3);
let k = 0;
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
const i = ((cy + y) * width + cx + x) * 4;
values[k++] = data[i];
values[k++] = data[i + 1];
values[k++] = data[i + 2];
}
}
return values;
}
function patchError(data, width, height, x, y, template, radius = PATCH_RADIUS) {
if (x < radius || y < radius || x >= width - radius || y >= height - radius) return Infinity;
let error = 0;
let k = 0;
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const i = ((y + py) * width + x + px) * 4;
for (let c = 0; c < 3; c++) {
const delta = data[i + c] - template[k++];
error += delta * delta;
}
}
}
return error / (template.length * 255 * 255);
}
function findPatch(data, width, height, previous, template) {
const px = previous[0] * width;
const py = previous[1] * height;
const radius = Math.round(Math.min(width, height) * 0.085);
let bestX = Math.round(px);
let bestY = Math.round(py);
let bestError = Infinity;
// Coarse search, then one-pixel refinement. The motion prior keeps a patch
// with similar colours elsewhere on the jersey from winning too easily.
for (let y = Math.round(py - radius); y <= py + radius; y += 3) {
for (let x = Math.round(px - radius); x <= px + radius; x += 3) {
const appearance = patchError(data, width, height, x, y, template);
const motion = ((x - px) ** 2 + (y - py) ** 2) / Math.max(1, radius * radius) * 0.012;
const score = appearance + motion;
if (score < bestError) { bestError = score; bestX = x; bestY = y; }
}
}
const coarseX = bestX;
const coarseY = bestY;
for (let y = coarseY - 3; y <= coarseY + 3; y++) {
for (let x = coarseX - 3; x <= coarseX + 3; x++) {
const score = patchError(data, width, height, x, y, template);
if (score < bestError) { bestError = score; bestX = x; bestY = y; }
}
}
return {
point: [clamp(bestX / width, 0, 1), clamp(bestY / height, 0, 1)],
confidence: clamp(1 - bestError * 5, 0, 1),
};
}
function framePixels(video, canvas, context) {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
}
export function createSeededStickTracker(video, seed) {
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(video.videoWidth, video.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.max(32, Math.round(video.videoWidth * scale));
canvas.height = Math.max(32, Math.round(video.videoHeight * scale));
const context = canvas.getContext('2d', { willReadFrequently: true });
const pixels = framePixels(video, canvas, context);
const templates = {
butt: pointPatch(pixels, canvas.width, canvas.height, seed.butt),
blade: pointPatch(pixels, canvas.width, canvas.height, seed.blade),
};
let previous = { butt: [...seed.butt], blade: [...seed.blade] };
return {
seed: structuredClone(seed),
track() {
const frame = framePixels(video, canvas, context);
const butt = findPatch(frame, canvas.width, canvas.height, previous.butt, templates.butt);
const blade = findPatch(frame, canvas.width, canvas.height, previous.blade, templates.blade);
previous = { butt: butt.point, blade: blade.point };
const initialLength = Math.hypot(seed.blade[0] - seed.butt[0], seed.blade[1] - seed.butt[1]);
const length = Math.hypot(blade.point[0] - butt.point[0], blade.point[1] - butt.point[1]);
const lengthRatio = initialLength > 1e-4 ? length / initialLength : 1;
const geometryConfidence = clamp(1 - Math.abs(Math.log(Math.max(0.01, lengthRatio))) * 1.2, 0, 1);
return {
butt: butt.point,
blade: blade.point,
confidence: Math.min(butt.confidence, blade.confidence, geometryConfidence),
};
},
reset() { previous = { butt: [...seed.butt], blade: [...seed.blade] }; },
};
}
export function stickGripFromWrists(stick, normalizedPose) {
const wrist = [
(normalizedPose[15].x + normalizedPose[16].x) * 0.5,
(normalizedPose[15].y + normalizedPose[16].y) * 0.5,
];
const ax = stick.butt[0];
const ay = stick.butt[1];
const dx = stick.blade[0] - ax;
const dy = stick.blade[1] - ay;
const lengthSq = dx * dx + dy * dy || 1;
const t = clamp(((wrist[0] - ax) * dx + (wrist[1] - ay) * dy) / lengthSq, 0, 1);
return [ax + dx * t, ay + dy * t];
}
+144
View File
@@ -0,0 +1,144 @@
import * as THREE from 'three';
import { buildSkeleton } from '../src/character/skeleton.js';
import { buildStick } from '../src/character/stick.js';
import {
applyTiltAnimation,
applyTiltClip,
captureSkeletonKeyframe,
clipAsModule,
createTiltClip,
deleteClipKeyframe,
frameSpan,
sanitizeTiltClip,
sampleTiltStick,
setClipKeyframe,
smoothTiltClip,
} from '../src/anim/clip.js';
import { done, ok, section } from './harness.mjs';
section('Tilt clips capture and interpolate the native skeleton');
{
const skel = buildSkeleton();
const clip = createTiltClip({ name: 'test-motion', loop: false, shotSide: 'left' });
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0);
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
skel.bones.upperArmL.quaternion.identity();
ok(applyTiltClip(skel, clip, 0.5), 'a populated clip applies');
const angle = 2 * Math.acos(skel.bones.upperArmL.quaternion.w);
ok(Math.abs(angle - Math.PI / 4) < 1e-5, 'quaternions slerp halfway');
ok(frameSpan(clip, 2).a.time === 1, 'non-looping clips clamp at the end');
ok(sanitizeTiltClip(clip).shotSide === 'left', 'clip preserves its authored stick socket side');
}
section('clip validation, replacement, smoothing, and export');
{
const skel = buildSkeleton();
const clip = createTiltClip({ name: 'hip check' });
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
ok(clip.keyframes.length === 1, 'setting the same time replaces a key');
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0.5));
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
smoothTiltClip(clip);
const copy = sanitizeTiltClip(JSON.parse(JSON.stringify(clip)));
ok(copy.keyframes.length === 3 && copy.rig === 'tilt-23', 'serialized clips validate');
const module = clipAsModule(copy);
ok(module.includes('export const hip_check') && module.includes('applyTiltAnimation'), 'module export is drop-in runtime JavaScript');
ok(deleteClipKeyframe(copy, 0.5) && copy.keyframes.length === 2, 'keys can be deleted');
}
section('stick landmarks survive storage and interpolate with the pose');
{
const skel = buildSkeleton();
const clip = createTiltClip({ loop: false });
const a = captureSkeletonKeyframe(skel, 0);
a.stick = { butt: [0.4, 0.2], grip: [0.45, 0.4], blade: [0.5, 0.8], target: [-0.2, 0.03, 0.6], roll: 0, confidence: 0.8 };
const b = captureSkeletonKeyframe(skel, 1);
b.stick = { butt: [0.6, 0.2], grip: [0.6, 0.4], blade: [0.8, 0.8], target: [0.4, 0.2, 1.0], roll: 0.4, confidence: 1 };
setClipKeyframe(clip, a);
setClipKeyframe(clip, b);
const stick = sampleTiltStick(clip, 0.5);
ok(Math.abs(stick.blade[0] - 0.65) < 1e-6, 'blade landmark interpolates');
ok(Math.abs(stick.target[0] - 0.1) < 1e-6, 'rig-local blade target interpolates');
ok(Number.isFinite(stick.angle), 'legacy butt/blade marks derive a stable shaft angle');
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.confidence === 0.8, 'stick metadata serializes');
}
section('applied stick guides constrain the 3D shaft through both hands');
{
const skel = buildSkeleton();
const mover = new THREE.Group();
mover.add(skel.rootBone);
const stick = buildStick(null, null, 0);
stick.attachTo(skel.bones.handR);
const clip = createTiltClip({ loop: false });
const frame = captureSkeletonKeyframe(skel, 0);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle: 0.5, roll: 0.2, alignHands: true, confidence: 1,
};
setClipKeyframe(clip, frame);
const skater = { skelData: skel, mover, stick };
ok(applyTiltAnimation(skater, clip, 0), 'two-hand guide applies');
const shaftA = new THREE.Vector3();
const shaftB = new THREE.Vector3();
const lowerHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
stick.shaftSegment(shaftA, shaftB);
const shaft = shaftB.clone().sub(shaftA);
const t = THREE.MathUtils.clamp(lowerHand.clone().sub(shaftA).dot(shaft) / shaft.lengthSq(), 0, 1);
const closest = shaftA.clone().addScaledVector(shaft, t);
ok(shaftA.distanceTo(skel.bones.handR.getWorldPosition(new THREE.Vector3())) < 1e-6, 'shaft starts at the socket hand');
ok(closest.distanceTo(lowerHand) < 1e-6, 'shaft crosses the lower hand in depth as well as screen space');
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.alignHands, 'two-hand constraint survives save/load');
}
section('equivalent stick-line directions do not bake a false half-turn');
{
const skel = buildSkeleton();
const clip = createTiltClip({ loop: false });
for (const [time, angle] of [[0, 0.2], [0.5, Math.PI - 0.1], [1, 0.3]]) {
const frame = captureSkeletonKeyframe(skel, time);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle, roll: 0, confidence: 1,
};
setClipKeyframe(clip, frame);
}
const clean = sanitizeTiltClip(clip);
const delta = Math.abs(clean.keyframes[1].stick.angle - clean.keyframes[0].stick.angle);
ok(delta < Math.PI / 2, 'a PI-flipped detector result stays on the nearest shaft-line branch');
}
section('stick playback follows the actual socket bone');
{
const skel = buildSkeleton();
const mover = new THREE.Group();
mover.add(skel.rootBone);
const stickGroup = new THREE.Group();
skel.bones.handL.add(stickGroup);
const clip = createTiltClip({ loop: false, shotSide: 'left' });
const frame = captureSkeletonKeyframe(skel, 0);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle: -0.4, roll: 0, confidence: 1,
};
setClipKeyframe(clip, frame);
let actualHand = null;
const skater = {
skelData: skel,
mover,
stick: {
group: stickGroup,
aimAt(_target, hand) { actualHand = hand.clone(); },
},
};
applyTiltAnimation(skater, clip, 0);
const expectedHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
ok(actualHand?.distanceTo(expectedHand) < 1e-6, 'left socket playback anchors at handL instead of handR');
}
done('animation clip');
+43
View File
@@ -0,0 +1,43 @@
import { parseFreeMoCapCsv } from '../src/studio/freemocapImport.js';
import { done, ok, section } from './harness.mjs';
const points = [
'left_ear', 'right_ear', 'left_shoulder', 'right_shoulder',
'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist',
'left_hip', 'right_hip', 'left_knee', 'right_knee',
'left_ankle', 'right_ankle', 'left_heel', 'right_heel',
'left_foot_index', 'right_foot_index',
];
section('FreeMoCap tidy XYZ data imports into MediaPipe landmark order');
{
const lines = ['frame,keypoint,x,y,z,model,trajectory,reprojection_error'];
for (const trajectory of ['3d_xyz', 'rigid_3d_xyz']) {
for (const frame of [0, 1]) {
for (const [index, point] of points.entries()) {
const base = trajectory === 'rigid_3d_xyz' ? 200 : 100;
lines.push(`${frame},${point},${base + index},20,300,mediapipe_body,${trajectory},0.5`);
}
}
}
// Non-body rows in the tidy all-data file must not contaminate the pose.
lines.push('0,left_wrist,999,999,999,mediapipe_left_hand,rigid_3d_xyz,0.5');
const result = parseFreeMoCapCsv(lines.join('\n'));
ok(result.frames.length === 2, 'all complete body frames import');
ok(result.trajectory === 'rigid_3d_xyz', 'rigid body trajectory is preferred when available');
ok(result.tracker === 'MediaPipe', 'tracker metadata is reported');
const leftEar = result.frames[0].landmarks[7];
ok(leftEar.x === 200 && leftEar.y === -300 && leftEar.z === -20, 'FreeMoCap Z-up axes convert for the Tilt retargeter');
}
section('FreeMoCap per-trajectory CSV and RTMPose toe names are accepted');
{
const rtmposePoints = points.map((point) => point.replace('foot_index', 'big_toe'));
const lines = ['frame,keypoint,x,y,z'];
for (const [index, point] of rtmposePoints.entries()) lines.push(`4,${point},${index},10,20`);
const result = parseFreeMoCapCsv(lines.join('\r\n'));
ok(result.frames[0].frame === 4, 'source frame numbers survive import');
ok(result.frames[0].landmarks[31].visibility === 1, 'RTMPose big toe maps to the Tilt foot-index landmark');
}
done('FreeMoCap import');
+110 -22
View File
@@ -1,8 +1,11 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { buildSkeleton } from '../src/character/skeleton.js'; import { buildSkeleton } from '../src/character/skeleton.js';
import { buildAnimator } from '../src/anim/skateAnimator.js'; import { buildAnimator } from '../src/anim/skateAnimator.js';
import { shot1 } from '../src/anim/clips/shot1.js';
import { frameSpan } from '../src/anim/clip.js';
import { buildStick } from '../src/character/stick.js'; import { buildStick } from '../src/character/stick.js';
import { segDist } from '../src/core/math.js'; import { segDist } from '../src/core/math.js';
import { topHandFor } from '../shared/player.js';
import { done, ok, section } from './harness.mjs'; import { done, ok, section } from './harness.mjs';
/** /**
@@ -16,7 +19,7 @@ import { done, ok, section } from './harness.mjs';
const DT = 1 / 60; const DT = 1 / 60;
function rig() { function rig(shotSide = 'right') {
const skelData = buildSkeleton(); const skelData = buildSkeleton();
const mover = new THREE.Group(); const mover = new THREE.Group();
// Same hierarchy as createSkater: skeleton rides on the mover so body yaw // Same hierarchy as createSkater: skeleton rides on the mover so body yaw
@@ -24,10 +27,12 @@ function rig() {
// the stick target orbited in world space while the hand sat still. // the stick target orbited in world space while the hand sat still.
mover.add(skelData.rootBone); mover.add(skelData.rootBone);
const anim = buildAnimator(skelData, mover); const anim = buildAnimator(skelData, mover);
// The stick is part of the pose now — it hangs off the hand and the animator anim.setShotSide(shotSide);
// aims it, so a rig without one is not the rig the game runs. // The stick is part of the pose now — it hangs off the top hand for this
// shot side and the animator aims it, so a rig without one is not the rig
// the game runs.
const stick = buildStick(null, null, 0); const stick = buildStick(null, null, 0);
stick.attachTo(skelData.bones.handR); stick.attachTo(skelData.bones[`hand${topHandFor(shotSide)}`]);
anim.stick = stick; anim.stick = stick;
return { skelData, mover, anim, stick }; return { skelData, mover, anim, stick };
} }
@@ -262,26 +267,28 @@ section('the stick is held, not floating');
// Puck carry must be two-handed: top hand on the butt, lower hand on the // Puck carry must be two-handed: top hand on the butt, lower hand on the
// shaft. The old pose parked the stick on the hip and left the off-hand // shaft. The old pose parked the stick on the hip and left the off-hand
// ~25 cm short — the failure the motion-reference carry frame calls out. // ~25 cm short — the failure the motion-reference carry frame calls out.
const r = drive(rig(), 4, { ...GLIDE, effort: 0.2, hasPuck: true }); for (const side of ['right', 'left']) {
const r = drive(rig(side), 4, { ...GLIDE, effort: 0.2, hasPuck: true });
r.mover.updateMatrixWorld(true); r.mover.updateMatrixWorld(true);
const butt = new THREE.Vector3(); const butt = new THREE.Vector3();
const heel = new THREE.Vector3(); const heel = new THREE.Vector3();
r.stick.shaftSegment(butt, heel); r.stick.shaftSegment(butt, heel);
const handR = new THREE.Vector3(); const top = new THREE.Vector3();
r.skelData.bones.handR.getWorldPosition(handR); r.skelData.bones[`hand${r.anim.topHand}`].getWorldPosition(top);
ok(handR.distanceTo(butt) < 0.12, `the top hand is on the butt of the stick (${handR.distanceTo(butt).toFixed(3)}m)`); ok(top.distanceTo(butt) < 0.12, `${side}: top hand is on the butt (${top.distanceTo(butt).toFixed(3)}m)`);
const handL = new THREE.Vector3(); const lower = new THREE.Vector3();
const closest = new THREE.Vector3(); const closest = new THREE.Vector3();
r.skelData.bones.handL.getWorldPosition(handL); r.skelData.bones[`hand${r.anim.lowerHand}`].getWorldPosition(lower);
const gap = segDist(handL, butt, heel, closest); const gap = segDist(lower, butt, heel, closest);
ok(gap < 0.08, `the lower hand is on the shaft (${gap.toFixed(3)}m)`); ok(gap < 0.08, `${side}: lower hand is on the shaft (${gap.toFixed(3)}m)`);
// Stick sits in front of the body, not parked out on the hip. // Stick sits in front of the body, not parked out on the hip.
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert(); const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
const handLocal = handR.clone().applyMatrix4(inv); const handLocal = top.clone().applyMatrix4(inv);
ok(Math.abs(handLocal.x) < 0.28, `top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`); ok(Math.abs(handLocal.x) < 0.28, `${side}: top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`);
ok(handLocal.z > 0.25, `top hand is out in front (z=${handLocal.z.toFixed(2)})`); ok(handLocal.z > 0.25, `${side}: top hand is out in front (z=${handLocal.z.toFixed(2)})`);
}
} }
section('the stick stays in the socket when the body turns'); section('the stick stays in the socket when the body turns');
@@ -297,13 +304,14 @@ section('the stick stays in the socket when the body turns');
const handQ = new THREE.Quaternion(); const handQ = new THREE.Quaternion();
const stickQ = new THREE.Quaternion(); const stickQ = new THREE.Quaternion();
let maxDelta = 0; let maxDelta = 0;
const topBone = r.skelData.bones[`hand${r.anim.topHand}`];
for (let i = 0; i < 48; i++) { for (let i = 0; i < 48; i++) {
const yaw = (i / 48) * Math.PI * 2; const yaw = (i / 48) * Math.PI * 2;
r.anim.setTransform(r.mover.position, yaw); r.anim.setTransform(r.mover.position, yaw);
Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 }); Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 });
r.anim.update(DT); r.anim.update(DT);
r.skelData.bones.handR.getWorldQuaternion(handQ); topBone.getWorldQuaternion(handQ);
r.stick.group.getWorldQuaternion(stickQ); r.stick.group.getWorldQuaternion(stickQ);
local.copy(handQ).invert().multiply(stickQ); local.copy(handQ).invert().multiply(stickQ);
if (i === 0) local0.copy(local); if (i === 0) local0.copy(local);
@@ -313,6 +321,26 @@ section('the stick stays in the socket when the body turns');
ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`); ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`);
} }
section('shot side puts the blade on the matching forehand');
{
// Right shot: blade on the skater's right (X). Left shot: mirrored to +X.
const right = drive(rig('right'), 3, { ...GLIDE, hasPuck: true });
const left = drive(rig('left'), 3, { ...GLIDE, hasPuck: true });
const invR = new THREE.Matrix4().copy(right.mover.matrixWorld).invert();
const invL = new THREE.Matrix4().copy(left.mover.matrixWorld).invert();
const br = new THREE.Vector3();
const bl = new THREE.Vector3();
right.stick.bladeWorld(br);
left.stick.bladeWorld(bl);
br.applyMatrix4(invR);
bl.applyMatrix4(invL);
ok(br.x < -0.05, `right shot carries on the right (x=${br.x.toFixed(2)})`);
ok(bl.x > 0.05, `left shot carries on the left (x=${bl.x.toFixed(2)})`);
ok(right.anim.topHand === 'R' && right.anim.lowerHand === 'L', 'right shot: top R, lower L');
ok(left.anim.topHand === 'L' && left.anim.lowerHand === 'R', 'left shot: top L, lower R');
ok(right.anim.shotSign === 1 && left.anim.shotSign === -1, 'shotSign tracks the side');
}
section('stick actions run and finish'); section('stick actions run and finish');
{ {
for (const action of ['shoot', 'pass', 'poke']) { for (const action of ['shoot', 'pass', 'poke']) {
@@ -334,9 +362,9 @@ section('stick actions run and finish');
} }
} }
section('a wind-up lifts the blade off the ice and holds'); section('a wind-up uses the first half of saved shot1 and holds');
{ {
const r = rig(); const r = rig('left');
drive(r, 1, { ...GLIDE, hasPuck: true }); drive(r, 1, { ...GLIDE, hasPuck: true });
const flat = new THREE.Vector3(); const flat = new THREE.Vector3();
r.stick.bladeWorld(flat); r.stick.bladeWorld(flat);
@@ -353,11 +381,71 @@ section('a wind-up lifts the blade off the ice and holds');
const back = new THREE.Vector3(); const back = new THREE.Vector3();
r.stick.bladeWorld(back); r.stick.bladeWorld(back);
const backLocal = back.clone().applyMatrix4(inv); const backLocal = back.clone().applyMatrix4(inv);
// High and back behind the head — not hanging blade-down at hip height. // The saved midpoint is the held load pose.
ok(backLocal.y > 1.1, `the blade is up high (y=${backLocal.y.toFixed(2)})`); ok(backLocal.y > 0.28, `the blade is lifted (y=${backLocal.y.toFixed(2)})`);
ok(backLocal.y > flatLocal.y + 0.8, `well above the carry (${flatLocal.y.toFixed(2)}${backLocal.y.toFixed(2)})`); ok(backLocal.y > flatLocal.y + 0.2, `well above the carry (${flatLocal.y.toFixed(2)}${backLocal.y.toFixed(2)})`);
ok(backLocal.z < -0.15, `and back behind the body (z=${backLocal.z.toFixed(2)})`); ok(backLocal.z < flatLocal.z - 0.2, `and drawn back from the carry (z ${flatLocal.z.toFixed(2)}${backLocal.z.toFixed(2)})`);
const savedMidpoint = shot1.keyframes.find((frame) => frame.time === shot1.duration * 0.5);
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(
new THREE.Quaternion().fromArray(savedMidpoint.rotations.upperArmL),
);
ok(poseDelta < 1e-6, `held wind-up lands exactly on shot1's midpoint (Δ=${poseDelta.toFixed(6)})`);
ok(r.anim.action === 'windup', 'and the wind-up is held, not played once'); ok(r.anim.action === 'windup', 'and the wind-up is held, not played once');
// Both hands stay on the shaft through the load.
const butt = new THREE.Vector3();
const heel = new THREE.Vector3();
r.stick.shaftSegment(butt, heel);
const handR = new THREE.Vector3();
r.skelData.bones.handR.getWorldPosition(handR);
const onShaft = segDist(handR, butt, heel, new THREE.Vector3());
ok(onShaft < 0.1, `lower hand stays on the stick (dist ${onShaft.toFixed(3)})`);
}
section('a regular shot plays the whole saved shot1 clip');
{
const r = rig('left');
drive(r, 1, { ...GLIDE, hasPuck: true });
r.anim.playAction('shoot', { power: 1 });
// The 6-second reference is compressed into the 0.33-second motion window;
// the remaining 0.09 seconds are the existing blend back to skating.
r.anim.actionTime = 0.33 - DT;
Object.assign(r.anim, { ...GLIDE, hasPuck: true });
r.anim.update(DT);
const savedEnd = shot1.keyframes.at(-1);
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(
new THREE.Quaternion().fromArray(savedEnd.rotations.upperArmL),
);
ok(poseDelta < 1e-5, `regular shot reaches shot1's final key before fading (Δ=${poseDelta.toFixed(6)})`);
const butt = new THREE.Vector3();
const heel = new THREE.Vector3();
const lowerHand = r.skelData.bones.handR.getWorldPosition(new THREE.Vector3());
r.stick.shaftSegment(butt, heel);
const onShaft = segDist(lowerHand, butt, heel, new THREE.Vector3());
ok(onShaft < 1e-5, `saved shot keeps the guide through both hands (dist ${onShaft.toFixed(6)})`);
}
section('releasing a held wind-up continues through shot1 second half');
{
const r = rig('left');
drive(r, 1, { ...GLIDE, hasPuck: true });
r.anim.action = 'windup';
drive(r, 0.2, { ...GLIDE, hasPuck: true, charge: 1 });
r.anim.playAction('shoot', { power: 1 });
Object.assign(r.anim, { ...GLIDE, hasPuck: true });
r.anim.update(DT);
const referenceTime = shot1.duration * (0.5 + 0.5 * DT / 0.33);
const span = frameSpan(shot1, referenceTime);
const expected = new THREE.Quaternion().slerpQuaternions(
new THREE.Quaternion().fromArray(span.a.rotations.upperArmL),
new THREE.Quaternion().fromArray(span.b.rotations.upperArmL),
span.alpha,
);
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(expected);
ok(r.anim.actionFromWindup, 'release remembers that the first half was already held');
ok(poseDelta < 1e-5, `release continues from the midpoint instead of replaying the load (Δ=${poseDelta.toFixed(6)})`);
} }
section('Skill Stick right moves the blade to the skater\'s right'); section('Skill Stick right moves the blade to the skater\'s right');
+28
View File
@@ -1,5 +1,8 @@
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js'; import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.js'; import { RINK, insideRink } from '../shared/rink.js';
import {
normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer,
} from '../shared/player.js';
import { done, near, ok, section } from './harness.mjs'; import { done, near, ok, section } from './harness.mjs';
const DT = 1 / 120; const DT = 1 / 120;
@@ -234,6 +237,7 @@ section('intent from a controller is clamped before the sim sees it');
// The clamped state must still step without producing garbage. // The clamped state must still step without producing garbage.
stepSkater(s, DT); stepSkater(s, DT);
ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards'); ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards');
} }
@@ -243,4 +247,28 @@ section('rink dimensions are the ones we think they are');
near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide'); near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
} }
section('player definition carries shot side');
{
ok(normalizeShotSide('left') === 'left', 'left stays left');
ok(normalizeShotSide('L') === 'left', 'L is left');
ok(normalizeShotSide(-1) === 'left', '-1 is left');
ok(normalizeShotSide('right') === 'right', 'right stays right');
ok(normalizeShotSide('R') === 'right', 'R is right');
ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)');
ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1');
ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split');
const packed = packPlayer({ shotSide: 'left' });
ok(packed.ss === 'L', 'pack uses a short wire form');
ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side');
ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side');
const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' });
const righty = createSkaterState(1, START, { shotSide: 'right' });
const plain = createSkaterState(2, START);
ok(lefty.shotSide === 'left', 'state stores left shot');
ok(righty.shotSide === 'right', 'state stores right shot');
ok(plain.shotSide === 'right', 'state defaults to right shot');
}
done('skaterSim'); done('skaterSim');
+106
View File
@@ -0,0 +1,106 @@
import { spawn } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { extname, resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.m4v']);
function run(command, args, options = {}) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout = (stdout + chunk).slice(-16000); });
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-32000); });
child.once('error', reject);
child.once('close', (code) => {
if (code === 0) resolveRun({ stdout: stdout.trim(), stderr: stderr.trim() });
else reject(new Error((stderr || stdout || `${command} exited with ${code}`).trim()));
});
});
}
export function freeMocapBridge({ projectRoot }) {
const bundledPython = resolve(projectRoot, '.freemocap-venv/bin/python');
const worker = resolve(projectRoot, 'tools/freemocap_worker.py');
const python = process.env.TILT_FREEMOCAP_PYTHON || bundledPython;
async function workerStatus() {
try {
await access(python);
const result = await run(python, ['-c', 'import freemocap; print(freemocap.__version__)']);
const version = result.stdout.split('\n').at(-1)?.replace(/\x1b\[[0-9;]*m/g, '') || 'installed';
return { available: true, version };
} catch (error) {
return {
available: false,
message: `FreeMoCap worker is not installed (${error.message.split('\n').at(-1)})`,
};
}
}
return {
name: 'tilt-freemocap-bridge',
configureServer(server) {
server.middlewares.use('/api/freemocap/status', async (_request, response) => {
const status = await workerStatus();
response.statusCode = status.available ? 200 : 503;
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(status));
});
server.middlewares.use('/api/freemocap/process', async (request, response) => {
if (request.method !== 'POST') {
response.statusCode = 405;
response.end('POST a video file to this endpoint');
return;
}
const status = await workerStatus();
if (!status.available) {
response.statusCode = 503;
response.end(`${status.message}. Run npm run freemocap:setup.`);
return;
}
const url = new URL(request.url || '/', 'http://localhost');
const requestedName = url.searchParams.get('filename') || 'reference.mp4';
const requestedExtension = extname(requestedName).toLowerCase();
const extension = VIDEO_EXTENSIONS.has(requestedExtension) ? requestedExtension : '.mp4';
const fps = Math.max(1, Math.min(60, Number(url.searchParams.get('fps')) || 30));
const jobRoot = await mkdtemp(resolve(tmpdir(), 'tilt-freemocap-'));
const input = resolve(jobRoot, `upload${extension}`);
const recording = resolve(jobRoot, 'recording');
const output = resolve(jobRoot, 'result.csv');
const metadata = resolve(jobRoot, 'result.json');
try {
await pipeline(request, createWriteStream(input));
server.config.logger.info(`FreeMoCap processing ${requestedName}`);
await run(python, [
worker,
'--input', input,
'--recording', recording,
'--output', output,
'--metadata', metadata,
'--fps', String(fps),
], { cwd: projectRoot });
const csv = await readFile(output);
const resultMetadata = JSON.parse(await readFile(metadata, 'utf8'));
response.statusCode = 200;
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('X-Tilt-Mocap-Backend', `FreeMoCap ${status.version}`);
response.setHeader('X-Tilt-Source-Fps', String(resultMetadata.fps || fps));
response.end(csv);
} catch (error) {
server.config.logger.error(error.stack || error.message);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end(error.message);
} finally {
await rm(jobRoot, { recursive: true, force: true });
}
});
},
};
}
+83
View File
@@ -0,0 +1,83 @@
"""Run FreeMoCap's stable headless pipeline for one uploaded reference video."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--recording", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--metadata", required=True, type=Path)
parser.add_argument("--fps", required=True, type=float)
return parser.parse_args()
def prepare_mp4(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if source.suffix.lower() == ".mp4":
shutil.copyfile(source, destination)
return
subprocess.run(
[
"ffmpeg", "-y", "-i", str(source), "-an",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(destination),
],
check=True,
)
def main() -> None:
args = parse_args()
synchronized = args.recording / "synchronized_videos"
prepared_video = synchronized / "camera_0.mp4"
prepare_mp4(args.input, prepared_video)
# FreeMoCap 1.8.x exposes this stable headless entry point. Blender and the
# notebook are deliberately disabled: Tilt only needs its filtered body XYZ.
from freemocap.core_processes.process_motion_capture_videos.process_recording_headless import (
process_recording_headless,
)
from freemocap.data_layer.recording_models.post_processing_parameter_models import (
ProcessingParameterModel,
)
import cv2
capture = cv2.VideoCapture(str(prepared_video))
detected_fps = float(capture.get(cv2.CAP_PROP_FPS))
capture.release()
source_fps = detected_fps if detected_fps > 0 else args.fps
parameters = ProcessingParameterModel()
parameters.post_processing_parameters_model.framerate = source_fps
parameters.post_processing_parameters_model.butterworth_filter_parameters.sampling_rate = source_fps
# Preserve MediaPipe's monocular depth estimate; the default flattened mode
# throws that axis away before FreeMoCap's interpolation and rigid-bone pass.
parameters.anipose_triangulate_3d_parameters_model.flatten_single_camera_data = False
process_recording_headless(
recording_path=args.recording,
recording_processing_parameter_model=parameters,
run_blender=False,
make_jupyter_notebook=False,
use_tqdm=False,
)
result = args.recording / f"{args.recording.name}_by_frame.csv"
if not result.is_file():
matches = sorted(args.recording.glob("*_by_frame.csv"))
if not matches:
raise FileNotFoundError("FreeMoCap completed without producing a by-frame CSV")
result = matches[0]
args.output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(result, args.output)
args.metadata.write_text(json.dumps({"fps": source_fps}), encoding="utf-8")
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
uv venv --python 3.12 .freemocap-venv
uv pip install --python .freemocap-venv/bin/python "freemocap==1.8.2"
.freemocap-venv/bin/python -c "import freemocap; print('FreeMoCap', freemocap.__version__, 'ready')"
+3
View File
@@ -1,7 +1,9 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { freeMocapBridge } from './tools/freemocap-vite-plugin.mjs';
export default defineConfig({ export default defineConfig({
plugins: [freeMocapBridge({ projectRoot: import.meta.dirname })],
server: { port: 5174, open: true }, server: { port: 5174, open: true },
// Multi-page: main game + character studio both ship as real HTML entries. // Multi-page: main game + character studio both ship as real HTML entries.
build: { build: {
@@ -13,6 +15,7 @@ export default defineConfig({
input: { input: {
main: resolve(import.meta.dirname, 'index.html'), main: resolve(import.meta.dirname, 'index.html'),
character: resolve(import.meta.dirname, 'character.html'), character: resolve(import.meta.dirname, 'character.html'),
animation: resolve(import.meta.dirname, 'animation.html'),
}, },
}, },
}, },