From d511538cb3b30018f99c4796bae3587f492bbd61 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Tue, 14 Jul 2026 17:39:00 +0300 Subject: [PATCH] Improve procedural animation quality and review labels --- packages/posecode-render/src/contacts.ts | 23 +-- packages/posecode-render/src/index.ts | 15 +- packages/posecode-render/src/timeline.ts | 152 ++++++++++++++----- packages/posecode-render/test/render.test.ts | 81 ++++++++-- playground/src/main.ts | 15 +- playground/src/presets.ts | 59 +++---- 6 files changed, 253 insertions(+), 92 deletions(-) diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index e8f206d..35b4f9a 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -15,24 +15,28 @@ const DEG = Math.PI / 180; */ export function alignFloorPalms( m: Mannequin, - reaches: readonly ReachTarget[], + reaches: readonly (ReachTarget & { weight?: number })[], pins: readonly PinTarget[], groundLock: readonly string[] = [], ): void { - const sides = new Set<"left" | "right">(); - const collect = (effector: string, target: string) => { + const sides = new Map<"left" | "right", number>(); + const collect = (effector: string, target: string, weight = 1) => { if (target !== "floor") return; - if (effector === "hands" || effector === "hand_left") sides.add("left"); - if (effector === "hands" || effector === "hand_right") sides.add("right"); + if (effector === "hands" || effector === "hand_left") { + sides.set("left", Math.max(sides.get("left") ?? 0, weight)); + } + if (effector === "hands" || effector === "hand_right") { + sides.set("right", Math.max(sides.get("right") ?? 0, weight)); + } }; - reaches.forEach((r) => collect(r.effector, r.target)); + reaches.forEach((r) => collect(r.effector, r.target, r.weight)); pins.forEach((p) => collect(p.effector, p.anchor)); if (groundLock.includes("hands")) { - sides.add("left"); - sides.add("right"); + sides.set("left", 1); + sides.set("right", 1); } - for (const side of sides) { + for (const [side, weight] of sides) { const wrist = m.bones.get(`wrist_${side}`); if (!wrist?.parent) continue; const world = wrist.getWorldQuaternion(new THREE.Quaternion()); @@ -41,6 +45,7 @@ export function alignFloorPalms( : new THREE.Vector3(-1, 0, 0); const current = localNormal.applyQuaternion(world).normalize(); const correction = new THREE.Quaternion().setFromUnitVectors(current, DOWN); + if (weight < 1) correction.slerp(new THREE.Quaternion(), 1 - weight); const desiredWorld = correction.multiply(world); const parentWorld = wrist.parent.getWorldQuaternion(new THREE.Quaternion()); wrist.quaternion.copy(parentWorld.invert().multiply(desiredWorld)); diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index be62925..4da7206 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -15,7 +15,12 @@ import { eulerRomFor } from "posecode-parser"; import type { PosecodeIR, ReachTarget, PinTarget, GripTarget } from "posecode-parser"; import { buildMannequin, type Mannequin } from "./mannequin.js"; import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } from "./groundlock.js"; -import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js"; +import { + buildTimeline, + type BuiltTimeline, + type PhaseSegment, + type WeightedReachTarget, +} from "./timeline.js"; import { solveCCD, type JointLimits } from "./ik.js"; import { buildProps, type PropScene } from "./props.js"; import { loadCharacter, type Character } from "./character.js"; @@ -480,7 +485,7 @@ export function createViewer( * final root placement. The chain is the arm (hand) or the leg (foot); other * joints keep their authored FK pose. */ - function applyReaches(reaches: ReachTarget[]): void { + function applyReaches(reaches: WeightedReachTarget[]): void { for (const r of reaches) { const effectorBone = EFFECTOR_BONE[r.effector] ?? r.effector; const effector = mannequin.bones.get(effectorBone); @@ -489,7 +494,13 @@ export function createViewer( if (!target) continue; const { joints, limits } = reachChain(effectorBone); if (joints.length === 0) continue; + const before = joints.map((joint) => joint.quaternion.clone()); solveCCD({ joints, limits, effector, target }, 12); + for (let i = 0; i < joints.length; i++) { + const solved = joints[i]!.quaternion.clone(); + joints[i]!.quaternion.slerpQuaternions(before[i]!, solved, r.weight); + } + mannequin.root.updateMatrixWorld(true); } } diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts index ba1d8a2..fd3cad2 100644 --- a/packages/posecode-render/src/timeline.ts +++ b/packages/posecode-render/src/timeline.ts @@ -2,15 +2,14 @@ * Turn a PosecodeIR into a looping, eased keyframe timeline. * * Each phase is a keyframe: we accumulate joint overrides forward (a movement - * holds prior joint state unless a later phase changes it), then slerp bone - * quaternions between consecutive keyframes with the destination phase's easing. - * A final wrap segment returns to the base pose so the loop is seamless. + * holds prior joint state unless a later phase changes it), then interpolate + * the DSL's bounded anatomical Euler channels with monotone cubic Hermite + * curves. A final wrap segment returns to the base pose only when necessary. */ import * as THREE from "three"; import type { PosecodeIR, ReachTarget, PinTarget, GripTarget, TimingMode } from "posecode-parser"; import { poseFor, type PoseSpec } from "./poses.js"; -import { squad, squadControl } from "./squad.js"; const DEG = Math.PI / 180; @@ -23,15 +22,12 @@ interface Keyframe { easing: TimingMode; /** * The figure is at rest here (zero boundary velocity), so the spline uses this - * keyframe's own value as its control (no velocity carried across it). True for - * settle/snap phases AND for the two structural anchors — the start pose and - * the loop-reset — which represent the figure standing still at the base pose. - * Those anchors have no real predecessor/successor, so deriving a squad tangent - * from a clamped neighbor yields a backward-biased control that overshoots - * (the "snap to fully-curled" biceps bug); a rest tangent slerps cleanly. + * keyframe receives zero velocity. True for settle/snap phases AND for the + * structural start/reset anchors, which represent the figure at rest. */ rest: boolean; - quats: Map; + /** Authored semantic Euler channels, retained so interpolation follows the DSL. */ + eulers: Map; groundLock: string[]; reaches: ReachTarget[]; pins: PinTarget[]; @@ -50,6 +46,11 @@ export interface PhaseSegment { cue?: string; } +/** A reach constraint blended across a phase boundary. */ +export interface WeightedReachTarget extends ReachTarget { + weight: number; +} + export interface BuiltTimeline { duration: number; repeat: number; @@ -65,7 +66,7 @@ export interface BuiltTimeline { phaseName: string; cue?: string; groundLock: string[]; - reaches: ReachTarget[]; + reaches: WeightedReachTarget[]; pins: PinTarget[]; grips: GripTarget[]; /** Interpolated root facing (yaw about world Y, radians). */ @@ -133,6 +134,68 @@ function rootVelocity( return span > 1e-6 ? (read(next) - read(prev)) / span : 0; } +/** + * Shape-preserving velocity for an authored Euler channel at an interior + * keyframe. Quaternion splines cannot distinguish a deliberate reversal from + * continuing around the sphere: 0° → 160° → 0° was interpreted as a hidden + * full rotation, holding near neutral before flipping through 180°. The DSL is + * expressed as bounded anatomical Euler channels, so interpolate those scalar + * channels directly and stop at reversals. + */ +function jointVelocity( + prev: Keyframe, + current: Keyframe, + next: Keyframe, + read: (keyframe: Keyframe) => number, +): number { + if (current.rest) return 0; + const beforeSpan = current.time - prev.time; + const afterSpan = next.time - current.time; + if (beforeSpan <= 1e-6 || afterSpan <= 1e-6) return 0; + const before = (read(current) - read(prev)) / beforeSpan; + const after = (read(next) - read(current)) / afterSpan; + // A plateau or direction change is a real anatomical turnaround. + if (before * after <= 0) return 0; + const centered = (read(next) - read(prev)) / (next.time - prev.time); + // Monotone Hermite cap: never let a tangent create an inter-keyframe + // overshoot even when neighboring phase durations differ greatly. + const limit = 3 * Math.min(Math.abs(before), Math.abs(after)); + return Math.sign(centered) * Math.min(Math.abs(centered), limit); +} + +function posesEqual( + a: Map, + b: Map, +): boolean { + const bones = new Set([...a.keys(), ...b.keys()]); + for (const bone of bones) { + const av = a.get(bone) ?? [0, 0, 0]; + const bv = b.get(bone) ?? [0, 0, 0]; + if (av.some((value, axis) => Math.abs(value - bv[axis]!) > 1e-6)) return false; + } + return true; +} + +function blendReaches( + from: readonly ReachTarget[], + to: readonly ReachTarget[], + t: number, +): WeightedReachTarget[] { + const key = (reach: ReachTarget): string => `${reach.effector}\u0000${reach.target}`; + const previous = new Map(from.map((reach) => [key(reach), reach])); + const next = new Map(to.map((reach) => [key(reach), reach])); + const blended: WeightedReachTarget[] = []; + for (const [id, reach] of previous) { + const weight = next.has(id) ? 1 : 1 - t; + if (weight > 1e-6) blended.push({ ...reach, weight }); + } + for (const [id, reach] of next) { + if (previous.has(id)) continue; + if (t > 1e-6) blended.push({ ...reach, weight: t }); + } + return blended; +} + export function buildTimeline(ir: PosecodeIR): BuiltTimeline { const basePose = poseFor(ir.startPose); const baseJoints = new Map( @@ -153,7 +216,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { name: ir.startPose ?? "start", easing: "flow", rest: true, - quats: snapshot(curr), + eulers: snapshot(curr), groundLock: [], reaches: [], pins: [], @@ -177,7 +240,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { ...(phase.cue ? { cue: phase.cue } : {}), easing: phase.easing, rest: REST_MODE[phase.easing], - quats: snapshot(curr), + eulers: snapshot(curr), groundLock: phase.groundLock, reaches: phase.reaches, pins: phase.pins, @@ -187,12 +250,26 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { }); } - // Wrap back to the base pose (and home position) for a seamless loop. Facing + // Wrap back to the base pose (and home position) for a seamless loop only + // when the author did not already return there. Always adding a first-phase- + // length reset made common out-and-back movements sit idle for roughly a + // third of every repetition. A zero-duration structural reset still gives + // the final real keyframe a rest neighbor without extending the loop. + // Facing // wraps to the NEAREST FULL TURN to the final yaw, not to 0: a completed 360° // pirouette then holds its facing through the reset and the loop boundary // (360°≡0°) is seamless, instead of visibly un-spinning backward. A partial // turn (e.g. 90°) rounds to 0 and rotates back to front during the reset. - const wrap = ir.phases[0]?.durationSec ?? 1; + const finalPose = snapshot(curr); + const baseSnapshot = snapshot(new Map(baseJoints)); + const yawAtHome = Math.abs(currYaw - Math.round(currYaw / 360) * 360) < 1e-6; + const positionAtHome = Math.abs(currPos.x) < 1e-6 && Math.abs(currPos.z) < 1e-6; + const needsWrap = !posesEqual(finalPose, baseSnapshot) || !yawAtHome || !positionAtHome; + const wrap = needsWrap ? (ir.phases[0]?.durationSec ?? 1) : 0; + // With no pose wrap, the structural start is also the cyclic successor of + // the final phase. Seed its reach state from that final phase so a constraint + // shared across the boundary (e.g. cobra palms on the floor) stays planted. + if (!needsWrap) keyframes[0]!.reaches = [...(ir.phases.at(-1)?.reaches ?? [])]; const wrapYaw = Math.round(currYaw / 360) * 360 * DEG; t += wrap; keyframes.push({ @@ -200,7 +277,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { name: "reset", easing: "flow", rest: true, - quats: snapshot(new Map(baseJoints)), + eulers: baseSnapshot, groundLock: [], reaches: [], pins: [], @@ -209,11 +286,11 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { pos: { x: 0, z: 0 }, }); - // Fill every keyframe with the full bone set (missing → identity). - const bonesUsed = [...new Set(keyframes.flatMap((k) => [...k.quats.keys()]))]; + // Fill every keyframe with the full bone set (missing → neutral Euler). + const bonesUsed = [...new Set(keyframes.flatMap((k) => [...k.eulers.keys()]))]; for (const kf of keyframes) { for (const bone of bonesUsed) { - if (!kf.quats.has(bone)) kf.quats.set(bone, new THREE.Quaternion()); + if (!kf.eulers.has(bone)) kf.eulers.set(bone, [0, 0, 0]); } } @@ -255,25 +332,22 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { const local = THREE.MathUtils.clamp((tt - a.time) / span, 0, 1); const eased = MODE_EASE[b.easing](local); - // Neighbors for the squad control quaternions (clamp at the ends → the - // segment endpoint itself, giving a one-sided tangent). + // Neighbors for the time-aware semantic-channel tangents. const kPrev = keyframes[Math.max(0, i - 1)]!; const kNext = keyframes[Math.min(keyframes.length - 1, i + 2)]!; for (const bone of bonesUsed) { const node = bones.get(bone); if (!node) continue; - const q0 = a.quats.get(bone)!; - const q1 = b.quats.get(bone)!; - // A rest-point keyframe uses its own value as the control (zero tangent - // → the spline comes to / leaves from rest there); otherwise the - // Shoemake control from the neighboring keyframe carries velocity. - const s0 = a.rest - ? q0.clone() - : squadControl(kPrev.quats.get(bone)!, q0, q1); - const s1 = b.rest - ? q1.clone() - : squadControl(q0, q1, kNext.quats.get(bone)!); - squad(q0, s0, s1, q1, eased, node.quaternion); + const from = a.eulers.get(bone)!; + const to = b.eulers.get(bone)!; + const value = ([0, 1, 2] as const).map((axis) => { + if (b.easing === "linear") return from[axis] + (to[axis] - from[axis]) * eased; + const read = (kf: Keyframe): number => kf.eulers.get(bone)![axis]; + const va = jointVelocity(kPrev, a, b, read); + const vb = jointVelocity(a, b, kNext, read); + return hermite(from[axis], to[axis], va, vb, span, eased); + }) as EulerDegTuple; + node.quaternion.copy(eulerToQuat(value)); } // Root facing/position use the scalar analogue of the joint squad spline: // cubic Hermite with time-aware centered tangents. This carries velocity @@ -306,7 +380,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { phaseName: b.name, ...(b.cue ? { cue: b.cue } : {}), groundLock: b.groundLock, - reaches: b.reaches, + reaches: blendReaches(a.reaches, b.reaches, eased), pins: b.pins, grips: b.grips, rootYaw, @@ -316,9 +390,9 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { }; } -function snapshot(curr: Map): Map { - const out = new Map(); - for (const [bone, euler] of curr) out.set(bone, eulerToQuat(euler)); +function snapshot(curr: Map): Map { + const out = new Map(); + for (const [bone, euler] of curr) out.set(bone, [...euler]); // Hip-hinge coupling. The `pelvis` is the shared parent of both the torso and // the legs, so a pelvis X-rotation tips the WHOLE figure forward: torso and @@ -330,7 +404,7 @@ function snapshot(curr: Map): Map { " repeat 5", ].join("\n"); - it("builds a looping timeline whose duration covers all phases + wrap", () => { + it("omits a redundant wrap when the final phase returns to the base pose", () => { const { ir } = parse(PUSHUP); const tl = buildTimeline(ir!); - // 2s + 1s phases + 2s wrap (first phase duration) - expect(tl.duration).toBeCloseTo(5, 5); + expect(tl.duration).toBeCloseTo(3, 5); expect(tl.bonesUsed).toContain("elbow_left"); }); + it("keeps a wrap segment when the final phase differs from the base pose", () => { + const src = [ + 'posecode exercise "Hold curl"', + " rig humanoid", + " pose start = standing", + ' step "Curl" 1s settle:', + " elbows: flex 90", + " repeat 1", + ].join("\n"); + const { ir } = parse(src); + expect(buildTimeline(ir!).duration).toBeCloseTo(2, 5); + }); + it("bends the elbow as the Lower phase progresses", () => { const { ir } = parse(PUSHUP); const tl = buildTimeline(ir!); @@ -76,12 +88,9 @@ describe("timeline", () => { }); // Regression: a large rest-to-rest move (biceps curl: elbow flex 135 + - // supinate 80, near-antipodal endpoints) must sweep MONOTONICALLY. The squad - // spline used to derive a backward-biased control at the clamped start - // keyframe, flinging the forearm past +50deg then snapping ~135deg in a single - // step — the "curl happens suddenly" bug. The start/reset anchors are now rest - // points, so their segments slerp cleanly. - it("curls the forearm monotonically from rest (no squad overshoot snap)", () => { + // supinate 80, near-antipodal endpoints) must sweep monotonically instead of + // lingering near rest and snapping to the target near the phase boundary. + it("curls the forearm monotonically from rest (no interpolation snap)", () => { const CURL = [ 'posecode exercise "Curl"', " rig humanoid", @@ -115,6 +124,60 @@ describe("timeline", () => { prev = dir.clone(); } }); + + it("follows a large reversing joint arc without hiding a full rotation", () => { + const REVERSAL = [ + 'posecode stretch "Shoulder abduction"', + " rig humanoid", + " pose start = standing", + ' step "Raise" 2.5s flow:', + " shoulders: abduct 160", + ' step "Lower" 2.5s settle:', + " shoulders: abduct 0", + " repeat 1", + ].join("\n"); + const { ir } = parse(REVERSAL); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + const euler = new THREE.Euler(); + let previous = 0; + + for (let t = 0; t <= 2.5 + 1e-9; t += 0.125) { + tl.sample(t, m.bones); + euler.setFromQuaternion(m.bones.get("shoulder_right")!.quaternion, "XYZ"); + const angle = -euler.z / DEG; + expect(angle).toBeGreaterThanOrEqual(previous - 1e-4); + expect(angle - previous).toBeLessThan(15); + previous = angle; + } + expect(previous).toBeCloseTo(160, 4); + + tl.sample(1.25, m.bones); + euler.setFromQuaternion(m.bones.get("shoulder_right")!.quaternion, "XYZ"); + expect(-euler.z / DEG).toBeCloseTo(80, 1); + }); + + it("blends reach constraints across phase boundaries", () => { + const src = [ + 'posecode stretch "Cross-body reach"', + " rig humanoid", + " pose start = standing", + ' step "Reach" 1s flow:', + " reach: hand_right shoulder_left", + ' step "Return" 1s settle:', + " shoulder_right: flex 0", + " repeat 1", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + + expect(tl.sample(0, m.bones).reaches).toEqual([]); + expect(tl.sample(0.5, m.bones).reaches[0]?.weight).toBeCloseTo(0.5, 5); + expect(tl.sample(1, m.bones).reaches[0]?.weight).toBeCloseTo(1, 5); + expect(tl.sample(1.5, m.bones).reaches[0]?.weight).toBeCloseTo(0.25, 5); + expect(tl.sample(2 - 1e-5, m.bones).reaches).toEqual([]); + }); }); describe("hip-hinge coupling", () => { diff --git a/playground/src/main.ts b/playground/src/main.ts index 1ab5aba..47f79b9 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -17,7 +17,7 @@ import { resolveSharedSource, } from "./nice-share.js"; import type { PosecodeEditor } from "./editor.js"; -import { PRESETS } from "./presets.js"; +import { ANIMATION_PROGRESS_MESSAGE, PRESETS } from "./presets.js"; import { SHOWCASE_CLIPS } from "./clips.js"; // The movement shown on first open (no shared link). Jumping jacks plays the @@ -340,15 +340,22 @@ function renderLibraryList(): void { const label = document.createElement("span"); label.textContent = p.label; name.append(label); - if (p.status === "development") { + if (p.status === "animation-progress") { const status = document.createElement("span"); status.className = "li-status"; - status.textContent = "In development"; + status.textContent = "Animation in Progress"; + status.title = ANIMATION_PROGRESS_MESSAGE; + status.setAttribute( + "aria-label", + `Animation in Progress: ${ANIMATION_PROGRESS_MESSAGE}`, + ); name.append(status); } const meta = document.createElement("span"); meta.className = "li-meta"; - meta.textContent = p.developmentNote ?? `${p.target} · ${p.equipment} · ${p.difficulty}`; + meta.textContent = p.status === "animation-progress" + ? ANIMATION_PROGRESS_MESSAGE + : `${p.target} · ${p.equipment} · ${p.difficulty}`; item.append(name, meta); item.addEventListener("click", () => { loadPreset(p.id); diff --git a/playground/src/presets.ts b/playground/src/presets.ts index 5a9fb5d..13c841d 100644 --- a/playground/src/presets.ts +++ b/playground/src/presets.ts @@ -100,6 +100,9 @@ import quarterTurns from "../../spec/examples/quarter-turns.posecode?raw"; export type Difficulty = "Beginner" | "Intermediate" | "Advanced"; +export const ANIMATION_PROGRESS_MESSAGE = + "This move is available, but its animation is still being refined."; + export interface Preset { id: string; label: string; @@ -112,10 +115,8 @@ export interface Preset { /** What it renders with: Body weight, Chair, Wall, or Bar. */ equipment: string; difficulty: Difficulty; - /** Optional product-readiness marker shown in the movement library. */ - status?: "development"; - /** Short user-facing explanation of the unfinished animation area. */ - developmentNote?: string; + /** Optional animation-readiness marker shown in the movement library. */ + status?: "animation-progress"; source: string; } @@ -124,92 +125,92 @@ export interface Preset { // follows as the flagship "communicate the movement in your head" demo. export const PRESETS: Preset[] = [ { id: "squat", label: "Body-weight squat", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Beginner", source: squat }, - { id: "dance-phrase", label: "Dance phrase (8-count)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Intermediate", source: dancePhrase }, + { id: "dance-phrase", label: "Dance phrase (8-count)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Intermediate", status: "animation-progress", source: dancePhrase }, { id: "deadlift", label: "Deadlift (hip hinge)", domain: "Fitness", bodyPart: "Back", target: "Hamstrings & glutes", equipment: "Body weight", difficulty: "Intermediate", source: deadlift }, - { id: "shoulder-abduction", label: "Shoulder abduction (ROM)", domain: "Education", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: shoulderAbduction }, + { id: "shoulder-abduction", label: "Shoulder abduction (ROM)", domain: "Education", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: shoulderAbduction }, { id: "front-kick", label: "Front kick", domain: "Martial arts", bodyPart: "Upper legs", target: "Hip flexors", equipment: "Body weight", difficulty: "Intermediate", source: frontKick }, { id: "good-morning", label: "Good morning (hinge)", domain: "Physiotherapy", bodyPart: "Back", target: "Hamstrings", equipment: "Body weight", difficulty: "Intermediate", source: goodMorning }, { id: "chest-opener", label: "Chest opener", domain: "Desk & posture", bodyPart: "Chest", target: "Pectorals", equipment: "Body weight", difficulty: "Beginner", source: chestOpener }, // --- Strength & core (coverage-gap batch) --- { id: "plank-hold", label: "Plank hold", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Beginner", source: plankHold }, - { id: "mountain-climber", label: "Mountain climber", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Intermediate", source: mountainClimber }, + { id: "mountain-climber", label: "Mountain climber", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Intermediate", status: "animation-progress", source: mountainClimber }, { id: "crunch", label: "Crunch", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Beginner", source: crunch }, - { id: "bicycle-crunch", label: "Bicycle crunch", domain: "Fitness", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Intermediate", source: bicycleCrunch }, + { id: "bicycle-crunch", label: "Bicycle crunch", domain: "Fitness", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Intermediate", status: "animation-progress", source: bicycleCrunch }, { id: "supine-leg-raise", label: "Lying leg raise", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Beginner", source: supineLegRaise }, - { id: "superman", label: "Superman", domain: "Fitness", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", source: superman }, + { id: "superman", label: "Superman", domain: "Fitness", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: superman }, { id: "forward-lunge", label: "Forward lunge", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Intermediate", source: forwardLunge }, { id: "calf-raise", label: "Single-leg calf raise", domain: "Fitness", bodyPart: "Lower legs", target: "Calves", equipment: "Body weight", difficulty: "Intermediate", source: calfRaise }, { id: "jumping-jacks", label: "Jumping jacks", domain: "Warm-up", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: jumpingJacks }, { id: "box-step-taps", label: "Box step taps", domain: "Warm-up", bodyPart: "Upper legs", target: "Hip flexors", equipment: "Box", difficulty: "Beginner", source: boxStepTaps }, - { id: "pull-up", label: "Pull-up", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Advanced", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: pullUp }, + { id: "pull-up", label: "Pull-up", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Advanced", status: "animation-progress", source: pullUp }, { id: "step-up", label: "Step-up (box)", domain: "Functional", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Box", difficulty: "Intermediate", source: stepUp }, - { id: "triceps-dips", label: "Triceps dips (bars)", domain: "Fitness", bodyPart: "Upper arms", target: "Triceps", equipment: "Bars", difficulty: "Intermediate", status: "development", developmentNote: "Hand support and wrist contact are still being refined", source: tricepsDips }, + { id: "triceps-dips", label: "Triceps dips (bars)", domain: "Fitness", bodyPart: "Upper arms", target: "Triceps", equipment: "Bars", difficulty: "Intermediate", status: "animation-progress", source: tricepsDips }, { id: "quad-stretch", label: "Standing quad stretch", domain: "Mobility", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Beginner", source: quadStretch }, // --- Education / anatomy: single-joint ROM demos --- { id: "hip-flexion", label: "Hip flexion (ROM)", domain: "Education", bodyPart: "Upper legs", target: "Hip flexors", equipment: "Body weight", difficulty: "Beginner", source: hipFlexion }, - { id: "knee-flexion", label: "Knee flexion (ROM)", domain: "Education", bodyPart: "Upper legs", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", source: kneeFlexion }, + { id: "knee-flexion", label: "Knee flexion (ROM)", domain: "Education", bodyPart: "Upper legs", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: kneeFlexion }, { id: "spine-rotation", label: "Spine rotation (ROM)", domain: "Education", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Beginner", source: spineRotation }, - { id: "elbow-forearm", label: "Elbow flexion & forearm rotation", domain: "Education", bodyPart: "Upper arms", target: "Biceps", equipment: "Body weight", difficulty: "Beginner", source: elbowForearm }, + { id: "elbow-forearm", label: "Elbow flexion & forearm rotation", domain: "Education", bodyPart: "Upper arms", target: "Biceps", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: elbowForearm }, // --- Physiotherapy / rehab --- { id: "heel-raises", label: "Heel raises", domain: "Physiotherapy", bodyPart: "Lower legs", target: "Calves", equipment: "Body weight", difficulty: "Beginner", source: heelRaises }, { id: "hamstring-curl", label: "Standing hamstring curl", domain: "Physiotherapy", bodyPart: "Upper legs", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", source: hamstringCurl }, { id: "hip-abduction", label: "Standing hip abduction", domain: "Physiotherapy", bodyPart: "Upper legs", target: "Glutes", equipment: "Body weight", difficulty: "Beginner", source: hipAbduction }, - { id: "shoulder", label: "Shoulder flexion (ROM)", domain: "Physiotherapy", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: shoulder }, + { id: "shoulder", label: "Shoulder flexion (ROM)", domain: "Physiotherapy", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: shoulder }, { id: "neck", label: "Neck rotation", domain: "Physiotherapy", bodyPart: "Neck", target: "Neck", equipment: "Body weight", difficulty: "Beginner", source: neck }, // --- Desk / workplace wellness --- { id: "posture", label: "Desk posture reset", domain: "Desk & posture", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", source: posture }, { id: "twist", label: "Standing spinal twist", domain: "Desk & posture", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Beginner", source: twist }, - { id: "shoulder-rolls", label: "Shoulder rolls", domain: "Desk & posture", bodyPart: "Shoulders", target: "Trapezius", equipment: "Body weight", difficulty: "Beginner", source: shoulderRolls }, + { id: "shoulder-rolls", label: "Shoulder rolls", domain: "Desk & posture", bodyPart: "Shoulders", target: "Trapezius", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: shoulderRolls }, { id: "neck-side-stretch", label: "Neck side stretch", domain: "Desk & posture", bodyPart: "Neck", target: "Neck", equipment: "Body weight", difficulty: "Beginner", source: neckSideStretch }, - { id: "overhead-reach", label: "Overhead reach reset", domain: "Desk & posture", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: overheadReach }, + { id: "overhead-reach", label: "Overhead reach reset", domain: "Desk & posture", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: overheadReach }, // --- Sports / martial arts / warm-up --- { id: "jab-cross", label: "Jab-cross", domain: "Martial arts", bodyPart: "Full body", target: "Shoulders", equipment: "Body weight", difficulty: "Intermediate", source: jabCross }, { id: "horse-stance", label: "Horse stance", domain: "Martial arts", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Intermediate", source: horseStance }, { id: "bow", label: "Standing bow (hinge)", domain: "Martial arts", bodyPart: "Back", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", source: bow }, - { id: "arm-circles", label: "Arm circles", domain: "Warm-up", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: armCircles }, + { id: "arm-circles", label: "Arm circles", domain: "Warm-up", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: armCircles }, { id: "high-knee-march", label: "High-knee march", domain: "Warm-up", bodyPart: "Full body", target: "Hip flexors", equipment: "Body weight", difficulty: "Beginner", source: highKneeMarch }, // --- Dance / choreography (flagship) --- { id: "demi-plie", label: "Demi-plié", domain: "Dance", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Beginner", source: demiPlie }, { id: "releve", label: "Relevé", domain: "Dance", bodyPart: "Lower legs", target: "Calves", equipment: "Body weight", difficulty: "Beginner", source: releve }, { id: "tendu", label: "Tendu", domain: "Dance", bodyPart: "Upper legs", target: "Hip flexors", equipment: "Body weight", difficulty: "Intermediate", source: tendu }, - { id: "port-de-bras", label: "Port de bras", domain: "Dance", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: portDeBras }, + { id: "port-de-bras", label: "Port de bras", domain: "Dance", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: portDeBras }, // --- More fitness / mobility / yoga --- { id: "bent-over-row", label: "Bent-over row (hinge)", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Body weight", difficulty: "Intermediate", source: bentOverRow }, - { id: "biceps", label: "Biceps curl", domain: "Fitness", bodyPart: "Upper arms", target: "Biceps", equipment: "Body weight", difficulty: "Beginner", source: biceps }, + { id: "biceps", label: "Biceps curl", domain: "Fitness", bodyPart: "Upper arms", target: "Biceps", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: biceps }, { id: "lateral", label: "Lateral raise", domain: "Fitness", bodyPart: "Shoulders", target: "Deltoids", equipment: "Body weight", difficulty: "Beginner", source: lateral }, { id: "fold", label: "Standing roll-down", domain: "Mobility", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", source: fold }, { id: "chair", label: "Chair pose", domain: "Yoga", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Intermediate", source: chair }, { id: "sidebend", label: "Standing side bend", domain: "Yoga", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Beginner", source: sideBend }, // --- Reach-to-target IK --- - { id: "touch-toes", label: "Touch your toes", domain: "Mobility", bodyPart: "Back", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", source: touchToes }, - { id: "cross-body-reach", label: "Cross-body reach", domain: "Physiotherapy", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Beginner", source: crossBodyReach }, + { id: "touch-toes", label: "Touch your toes", domain: "Mobility", bodyPart: "Back", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: touchToes }, + { id: "cross-body-reach", label: "Cross-body reach", domain: "Physiotherapy", bodyPart: "Core", target: "Obliques", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: crossBodyReach }, // --- Lying & seated poses --- - { id: "glute-bridge", label: "Glute bridge", domain: "Physiotherapy", bodyPart: "Upper legs", target: "Glutes", equipment: "Body weight", difficulty: "Beginner", source: gluteBridge }, + { id: "glute-bridge", label: "Glute bridge", domain: "Physiotherapy", bodyPart: "Upper legs", target: "Glutes", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: gluteBridge }, { id: "dead-bug", label: "Dead bug", domain: "Physiotherapy", bodyPart: "Core", target: "Abdominals", equipment: "Body weight", difficulty: "Beginner", source: deadBug }, - { id: "cobra", label: "Cobra", domain: "Yoga", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", source: cobra }, + { id: "cobra", label: "Cobra", domain: "Yoga", bodyPart: "Back", target: "Spinal erectors", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: cobra }, { id: "seated-forward-fold", label: "Seated forward fold", domain: "Yoga", bodyPart: "Back", target: "Hamstrings", equipment: "Body weight", difficulty: "Beginner", source: seatedForwardFold }, // --- Props: chair / wall / bar --- { id: "sit-to-stand", label: "Sit to stand (chair)", domain: "Functional", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Chair", difficulty: "Beginner", source: sitToStand }, { id: "box-squat", label: "Box squat (chair)", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Chair", difficulty: "Beginner", source: boxSquat }, { id: "wall-sit", label: "Wall sit (wall)", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Wall", difficulty: "Beginner", source: wallSit }, - { id: "dead-hang", label: "Dead hang (bar)", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Beginner", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: deadHang }, - { id: "hanging-knee-raise", label: "Hanging knee raise (bar)", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Bar", difficulty: "Intermediate", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: hangingKneeRaise }, + { id: "dead-hang", label: "Dead hang (bar)", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Beginner", status: "animation-progress", source: deadHang }, + { id: "hanging-knee-raise", label: "Hanging knee raise (bar)", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Bar", difficulty: "Intermediate", status: "animation-progress", source: hangingKneeRaise }, // --- Hand / finger rig --- - { id: "make-a-fist", label: "Make a fist", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: makeAFist }, - { id: "pinch-grip", label: "Pinch grip", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: pinchGrip }, - { id: "finger-spell", label: "Finger-spelling (approx.)", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: fingerSpell }, - { id: "hand-wave", label: "Hand wave", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Wrist and finger articulation are still being refined", source: handWave }, + { id: "make-a-fist", label: "Make a fist", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: makeAFist }, + { id: "pinch-grip", label: "Pinch grip", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: pinchGrip }, + { id: "finger-spell", label: "Finger-spelling (approx.)", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: fingerSpell }, + { id: "hand-wave", label: "Hand wave", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "animation-progress", source: handWave }, { id: "pirouette", label: "Pirouette (full turn)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Intermediate", source: pirouette }, { id: "box-step", label: "Box step (travels)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: boxStep }, { id: "grapevine", label: "Grapevine (travels)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: grapevine },