diff --git a/.gitignore b/.gitignore index 3208869..1c4bebf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ coverage/ .vite/ .vercel .claude/worktrees/ + +# Mocap clip binaries: sourced from Mixamo, loaded from storage/CDN, not committed. +playground/public/clips/*.fbx +playground/public/clips/*.glb +playground/public/clips/.DS_Store diff --git a/docs/superpowers/plans/2026-07-11-l2-spline-interpolation.md b/docs/superpowers/plans/2026-07-11-l2-spline-interpolation.md new file mode 100644 index 0000000..e797871 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-l2-spline-interpolation.md @@ -0,0 +1,813 @@ +# L2 — Spline-Quaternion Interpolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace posecode's per-segment slerp with C1-continuous squad quaternion splines so motion flows through interior keyframes, and expose per-phase timing modes (`flow|settle|drive|snap|linear`) in the DSL and editor, with legacy easing names kept as deprecated aliases so no existing document breaks. + +**Architecture:** A new pure-math `squad.ts` computes spherical-quadrangle interpolation and Shoemake control quaternions. `timeline.ts` samples the keyframe list with squad, deriving each segment's boundary velocity from the destination keyframe's timing mode, and smooths root yaw/travel with scalar Catmull-Rom. The parser adds a `MODES` enum plus a legacy-alias normalization so the IR always carries a canonical mode. Editor packages surface the modes and a deprecation hint. `"linear"` stays a valid mode, so downstream code comparing `easing === "linear"` keeps working. + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), Three.js (`THREE.Quaternion`), Zod (AST validation), Vitest, pnpm/npm workspaces. + +## Global Constraints + +- Immutable style: interpolation helpers return **new** `THREE.Quaternion` objects or write into a caller-provided out param; never mutate shared keyframe quaternions. (coding-style) +- Files stay focused, ≤800 lines; extract `squad.ts` rather than growing `timeline.ts`. (coding-style) +- ESM imports use explicit `.js` specifiers (repo convention, see existing `import ... from "./poses.js"`). +- Angles in the IR are DEGREES; the renderer converts to radians (`DEG = Math.PI/180`). +- Canonical timing modes, exact spelling: `flow`, `settle`, `drive`, `snap`, `linear`. +- Legacy aliases (must keep parsing): `ease-in→drive`, `ease-out→settle`, `ease-in-out→settle`, `linear→linear`. +- Keep the AST/IR/Phase field name `easing` (do NOT rename to `mode`) to avoid rippling renames through `posecode-eval` and `posecode-render`; only its value set and meaning change. +- TDD: write the failing test, watch it fail, implement minimally, watch it pass, commit. Coverage ≥80% on changed packages. +- Run a package's tests with `npm test --workspace ` (or `npx vitest run` inside the package). Confirm the exact command with `cat /package.json` before first use. + +--- + +### Task 1: `squad.ts` — pure spherical-quadrangle interpolation + +**Files:** +- Create: `packages/posecode-render/src/squad.ts` +- Test: `packages/posecode-render/test/squad.test.ts` + +**Interfaces:** +- Consumes: `three` (`THREE.Quaternion`). +- Produces: + - `squadControl(prev: THREE.Quaternion, cur: THREE.Quaternion, next: THREE.Quaternion): THREE.Quaternion` — Shoemake intermediate control for `cur`. + - `squad(q0: THREE.Quaternion, s0: THREE.Quaternion, s1: THREE.Quaternion, q1: THREE.Quaternion, t: number, out?: THREE.Quaternion): THREE.Quaternion` — quadrangle blend of segment endpoints `q0,q1` and their controls `s0,s1` at `t∈[0,1]`; writes into `out` if given, else returns a new quaternion. At `t=0` returns `q0`, at `t=1` returns `q1`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// packages/posecode-render/test/squad.test.ts +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { squad, squadControl } from "../src/squad.js"; + +const q = (x: number, y: number, z: number) => + new THREE.Quaternion().setFromEuler(new THREE.Euler(x, y, z, "XYZ")); + +describe("squad", () => { + it("passes exactly through segment endpoints", () => { + const q0 = q(0, 0, 0); + const q1 = q(0, 1, 0); + const s0 = squadControl(q(0, -0.5, 0), q0, q1); + const s1 = squadControl(q0, q1, q(0, 1.5, 0)); + const at0 = squad(q0, s0, s1, q1, 0); + const at1 = squad(q0, s0, s1, q1, 1); + expect(at0.angleTo(q0)).toBeLessThan(1e-6); + expect(at1.angleTo(q1)).toBeLessThan(1e-6); + }); + + it("is C1-continuous across a shared interior keyframe (slerp is not)", () => { + // Three keyframes k0,k1,k2. Build the two segments' controls around k1 and + // measure angular velocity just before and just after k1. + const k0 = q(0, 0, 0); + const k1 = q(0, 1, 0); + const k2 = q(0, 1.2, 0.8); // direction change at k1 + const c_before = squadControl(k0, k1, k2); // control at k1 for both segs + const c0 = squadControl(q(0, -1, 0), k0, k1); // control at k0 + const c2 = squadControl(k1, k2, q(0, 0.4, 1.6)); // control at k2 + + const eps = 1e-3; + const before = squad(k0, c0, c_before, k1, 1 - eps); + const atK1a = squad(k0, c0, c_before, k1, 1); + const atK1b = squad(k1, c_before, c2, k2, 0); + const after = squad(k1, c_before, c2, k2, eps); + + // velocity = angular delta / dt, compared across the seam + const vBefore = atK1a.angleTo(before) / eps; + const vAfter = after.angleTo(atK1b) / eps; + expect(atK1a.angleTo(atK1b)).toBeLessThan(1e-6); // C0 + expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.15); // C1 within tolerance + }); + + it("falls back cleanly when neighbors are identical (no NaN)", () => { + const a = q(0, 0, 0); + const s = squadControl(a, a, a); + const mid = squad(a, s, s, a, 0.5); + expect(Number.isNaN(mid.x)).toBe(false); + expect(mid.angleTo(a)).toBeLessThan(1e-6); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run test/squad.test.ts` (from `packages/posecode-render`) +Expected: FAIL — cannot find module `../src/squad.js`. + +- [ ] **Step 3: Implement `squad.ts`** + +```ts +// packages/posecode-render/src/squad.ts +/** + * Spherical-quadrangle (squad) quaternion interpolation — Shoemake's C1 + * quaternion spline. Given a keyframe and its two neighbors, `squadControl` + * derives the intermediate control quaternion; `squad` blends one segment. + * + * All functions return NEW quaternions (or write into a caller `out`); the + * shared keyframe quaternions are never mutated. + */ + +import * as THREE from "three"; + +/** Ensure `b` is in the same hemisphere as `a` (shortest-path continuity). */ +function alignHemisphere(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion { + const out = b.clone(); + if (a.dot(out) < 0) out.set(-out.x, -out.y, -out.z, -out.w); + return out; +} + +/** q^-1 for a UNIT quaternion is its conjugate. */ +function conjugate(q: THREE.Quaternion): THREE.Quaternion { + return new THREE.Quaternion(-q.x, -q.y, -q.z, q.w); +} + +/** Natural log of a unit quaternion → a pure quaternion (w = 0). */ +function logUnit(q: THREE.Quaternion): THREE.Quaternion { + const v = new THREE.Vector3(q.x, q.y, q.z); + const vLen = v.length(); + const w = THREE.MathUtils.clamp(q.w, -1, 1); + if (vLen < 1e-8) return new THREE.Quaternion(0, 0, 0, 0); + const theta = Math.atan2(vLen, w); + const k = theta / vLen; + return new THREE.Quaternion(v.x * k, v.y * k, v.z * k, 0); +} + +/** Exp of a pure quaternion (w = 0) → a unit quaternion. */ +function expPure(q: THREE.Quaternion): THREE.Quaternion { + const v = new THREE.Vector3(q.x, q.y, q.z); + const theta = v.length(); + if (theta < 1e-8) return new THREE.Quaternion(0, 0, 0, 1); + const s = Math.sin(theta) / theta; + return new THREE.Quaternion(v.x * s, v.y * s, v.z * s, Math.cos(theta)); +} + +function mul(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion { + return a.clone().multiply(b); +} + +/** + * Shoemake control quaternion for `cur`: + * s = cur * exp( -( log(cur^-1 * next) + log(cur^-1 * prev) ) / 4 ) + * Neighbors are hemisphere-aligned to `cur` first for shortest-path continuity. + */ +export function squadControl( + prev: THREE.Quaternion, + cur: THREE.Quaternion, + next: THREE.Quaternion, +): THREE.Quaternion { + const p = alignHemisphere(cur, prev); + const n = alignHemisphere(cur, next); + const inv = conjugate(cur); + const logNext = logUnit(mul(inv, n)); + const logPrev = logUnit(mul(inv, p)); + const sum = new THREE.Quaternion( + -(logNext.x + logPrev.x) / 4, + -(logNext.y + logPrev.y) / 4, + -(logNext.z + logPrev.z) / 4, + 0, + ); + return mul(cur, expPure(sum)).normalize(); +} + +/** + * Squad blend of one segment: slerp(slerp(q0,q1,t), slerp(s0,s1,t), 2t(1-t)). + * Endpoints `q0`,`q1`; their controls `s0`,`s1`. Returns q0 at t=0, q1 at t=1. + */ +export function squad( + q0: THREE.Quaternion, + s0: THREE.Quaternion, + s1: THREE.Quaternion, + q1: THREE.Quaternion, + t: number, + out: THREE.Quaternion = new THREE.Quaternion(), +): THREE.Quaternion { + const q1a = alignHemisphere(q0, q1); + const a = new THREE.Quaternion().slerpQuaternions(q0, q1a, t); + const b = new THREE.Quaternion().slerpQuaternions(s0, alignHemisphere(s0, s1), t); + return out.slerpQuaternions(a, alignHemisphere(a, b), 2 * t * (1 - t)); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run test/squad.test.ts` +Expected: PASS (3 passing). + +- [ ] **Step 5: Commit** + +```bash +git add packages/posecode-render/src/squad.ts packages/posecode-render/test/squad.test.ts +git commit -m "feat(render): add squad quaternion-spline helper" +``` + +--- + +### Task 2: Timing modes in the parser (MODES enum + legacy aliases) + +**Files:** +- Modify: `packages/posecode-parser/src/schema.ts:14` (add `MODES`, alias map; widen validation) +- Modify: `packages/posecode-parser/src/types.ts:14` (add `TimingMode`, keep `Easing` alias) +- Modify: `packages/posecode-parser/src/parser.ts` (normalize legacy token → canonical mode in the AST) +- Modify: `packages/posecode-parser/src/index.ts:60` (export `MODES`, `TimingMode`, `normalizeMode`) +- Test: `packages/posecode-parser/test/parse.test.ts` (add cases) + +**Interfaces:** +- Consumes: existing `parseToAst`, `validateAst`. +- Produces: + - `MODES = ["flow","settle","drive","snap","linear"] as const` + - `type TimingMode = typeof MODES[number]` + - `normalizeMode(raw: string): { mode: TimingMode | null; legacy: boolean }` — maps a written token to a canonical mode; `mode` is null for unknown tokens; `legacy` true when the token was a deprecated easing name. + - The IR/AST `easing` field now always holds a canonical `TimingMode` after resolution. + +- [ ] **Step 1: Write the failing tests** + +```ts +// add to packages/posecode-parser/test/parse.test.ts +import { parse, normalizeMode, MODES } from "../src/index.js"; + +describe("timing modes", () => { + it("accepts the canonical modes", () => { + for (const m of MODES) { + const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s ${m}:\n knees: flex 10\n`; + const { errors } = parse(src); + expect(errors).toEqual([]); + } + }); + + it("normalizes legacy easing names to canonical modes", () => { + expect(normalizeMode("ease-in")).toEqual({ mode: "drive", legacy: true }); + expect(normalizeMode("ease-out")).toEqual({ mode: "settle", legacy: true }); + expect(normalizeMode("ease-in-out")).toEqual({ mode: "settle", legacy: true }); + expect(normalizeMode("linear")).toEqual({ mode: "linear", legacy: false }); + expect(normalizeMode("flow")).toEqual({ mode: "flow", legacy: false }); + expect(normalizeMode("bogus")).toEqual({ mode: null, legacy: false }); + }); + + it("legacy documents still parse and carry a canonical mode", () => { + const src = + `posecode exercise "sq"\n rig humanoid\n step "Descend" 1s ease-in-out:\n knees: flex 90\n`; + const { ir, errors } = parse(src); + expect(errors).toEqual([]); + expect(ir?.phases[0]?.easing).toBe("settle"); + }); + + it("rejects an unknown mode with a clear error", () => { + const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s wobble:\n knees: flex 10\n`; + const { errors } = parse(src); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.message.toLowerCase()).toContain("mode"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run test/parse.test.ts` (from `packages/posecode-parser`) +Expected: FAIL — `normalizeMode`/`MODES` not exported; `ir.phases[0].easing` is `"ease-in-out"` not `"settle"`. + +- [ ] **Step 3: Add modes + aliases to `schema.ts`** + +Replace the `EASINGS` definition and `easing` validation: + +```ts +// packages/posecode-parser/src/schema.ts (replace line 14 region) +export const MODES = ["flow", "settle", "drive", "snap", "linear"] as const; +export type TimingMode = (typeof MODES)[number]; + +/** Deprecated easing names → canonical mode. Kept so existing docs never break. */ +export const LEGACY_MODE_ALIASES: Record = { + "ease-in": "drive", + "ease-out": "settle", + "ease-in-out": "settle", + linear: "linear", +}; + +/** Back-compat: the old exported name, now the union of accepted written tokens. */ +export const EASINGS = [...MODES, "ease-in", "ease-out", "ease-in-out"] as const; + +/** Map a written token to a canonical mode + whether it was a legacy alias. */ +export function normalizeMode(raw: string): { mode: TimingMode | null; legacy: boolean } { + if ((MODES as readonly string[]).includes(raw)) { + return { mode: raw as TimingMode, legacy: false }; + } + const alias = LEGACY_MODE_ALIASES[raw]; + // "linear" is canonical, not a deprecation — only non-canonical aliases are legacy. + if (alias) return { mode: alias, legacy: raw !== "linear" }; + return { mode: null, legacy: false }; +} +``` + +Change the step schema's `easing` to the canonical set (validation runs AFTER the parser +normalizes, so only canonical modes reach it): + +```ts +// packages/posecode-parser/src/schema.ts — in stepSchema + easing: z.enum(MODES), +``` + +- [ ] **Step 4: Normalize in the parser** + +In `packages/posecode-parser/src/parser.ts`, where the step is built (around line 154-178), +resolve the token to a canonical mode and error on unknown: + +```ts +// replace: const easing = word(t[3]); +const easingTok = word(t[3]); +const resolved = easingTok ? normalizeMode(easingTok) : { mode: null, legacy: false }; +if ( + name?.type !== "str" || + dur?.type !== "dur" || + !easingTok || + resolved.mode === null || + colon?.type !== "colon" +) { + errors.push({ + line: ln.line, + message: + resolved.mode === null && easingTok + ? `unknown timing mode "${easingTok}"; expected one of ${MODES.join(", ")}` + : 'expected `step "" :`', + }); + current = null; + break; +} +current = { + name: name.value, + durationSec: parseDuration(dur.value), + easing: resolved.mode, // canonical mode stored in the AST + targets: [], + groundLock: [], + reaches: [], + pins: [], + line: ln.line, +}; +``` + +Add the import at the top of `parser.ts`: + +```ts +import { normalizeMode, MODES } from "./schema.js"; +``` + +- [ ] **Step 5: Types + exports** + +`packages/posecode-parser/src/types.ts` — replace line 14: + +```ts +/** @deprecated use TimingMode. Kept as an alias for one release. */ +export type Easing = TimingMode; +export type TimingMode = "flow" | "settle" | "drive" | "snap" | "linear"; +``` + +`packages/posecode-parser/src/index.ts` — extend the schema re-export (line 60): + +```ts +export { EASINGS, MODES, LEGACY_MODE_ALIASES, normalizeMode, type TimingMode } from "./schema.js"; +``` + +- [ ] **Step 6: Run tests to verify pass** + +Run: `npx vitest run` (from `packages/posecode-parser`) +Expected: PASS, including existing tests (legacy docs still valid). + +- [ ] **Step 7: Commit** + +```bash +git add packages/posecode-parser/src/schema.ts packages/posecode-parser/src/types.ts \ + packages/posecode-parser/src/parser.ts packages/posecode-parser/src/index.ts \ + packages/posecode-parser/test/parse.test.ts +git commit -m "feat(parser): timing modes with legacy easing aliases" +``` + +--- + +### Task 3: squad sampler + mode boundary velocity + smooth root in `timeline.ts` + +**Files:** +- Modify: `packages/posecode-render/src/timeline.ts` (imports, `Easing`→`TimingMode`, `EASE` → mode policy, `sample()` squad, root smoothing) +- Test: `packages/posecode-render/test/render.test.ts` (add continuity + settle tests; existing tests must stay green) + +**Interfaces:** +- Consumes: `squad`, `squadControl` (Task 1); `TimingMode` (Task 2). +- Produces: unchanged public `BuiltTimeline` shape; `sample()` now C1-continuous. + +- [ ] **Step 1: Write the failing tests** (append to `render.test.ts`) + +```ts +import { squad } from "../src/squad.js"; // (ensure imported once at top) + +it("interpolates joints with continuous velocity through an interior keyframe", () => { + const src = [ + 'posecode exercise "flowy"', + " rig humanoid", + ' step "A" 1s flow:', + " shoulders: flex 40", + ' step "B" 1s flow:', + " shoulders: flex 120", + ' step "C" 1s flow:', + " shoulders: flex 40", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + const read = (t: number) => { + tl.sample(t, m.bones); + return m.bones.get("shoulder_left")!.quaternion.clone(); + }; + const eps = 1e-3; + const kf = 2; // end of "B" is an interior keyframe (t=2) + const vBefore = read(kf).angleTo(read(kf - eps)) / eps; + const vAfter = read(kf + eps).angleTo(read(kf)) / eps; + expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.3); // flow carries velocity +}); + +it("settle brings a joint to rest at its keyframe", () => { + const src = [ + 'posecode exercise "rest"', + " rig humanoid", + ' step "Down" 1s settle:', + " knees: flex 90", + ' step "Up" 1s drive:', + " knees: flex 0", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + const read = (t: number) => { + tl.sample(t, m.bones); + return m.bones.get("knee_left")!.quaternion.clone(); + }; + const eps = 1e-3; + const v = read(1).angleTo(read(1 - eps)) / eps; // velocity arriving at the settle kf + expect(v).toBeLessThan(0.2); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run test/render.test.ts` +Expected: the two new tests FAIL (current slerp resets velocity at every keyframe, so the +`flow` continuity test fails; the `settle` test may pass incidentally — that's fine). + +- [ ] **Step 3: Replace the `EASE` table with a mode policy** + +In `timeline.ts`, replace the `Easing` type + `EASE` record (lines 17, 74-79): + +```ts +import type { PosecodeIR, ReachTarget, PinTarget, TimingMode } from "posecode-parser"; +import { squad, squadControl } from "./squad.js"; + +// ... in Keyframe interface: change `easing: Easing;` → `easing: TimingMode;` +// ... reset/start keyframes: use "flow" as their neutral mode instead of "linear". + +/** + * Per-mode remap of the normalized segment parameter (arrival shaping) and + * whether the DESTINATION keyframe is a rest-point (velocity → 0). `flow` + * carries velocity; `settle`/`snap` come to rest; `drive` starts from rest. + */ +const MODE_EASE: Record number> = { + flow: (t) => t, // even parameterization; squad carries velocity + settle: (t) => 1 - (1 - t) * (1 - t), // decelerate into rest + drive: (t) => t * t, // accelerate from rest + snap: (t) => 1 - (1 - t) * (1 - t) * (1 - t), // fast arrival + linear: (t) => t, +}; + +/** A keyframe is a rest-point (zero boundary velocity) for these modes. */ +const REST_MODE: Record = { + flow: false, + settle: true, + drive: false, + snap: true, + linear: false, +}; +``` + +- [ ] **Step 4: squad in `sample()` with rest-aware controls** + +Replace the joint interpolation block in `sample()` (lines 195-207). Find the segment index +`i` (so `a = keyframes[i]`, `b = keyframes[i+1]`), then: + +```ts +// neighbors for squad controls (clamp at the ends → one-sided tangents) +const iPrev = Math.max(0, i - 1); +const iNext = Math.min(keyframes.length - 1, i + 2); +const kPrev = keyframes[iPrev]!; +const kNext = keyframes[iNext]!; +const eased = MODE_EASE[b.easing](local); + +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)!; + // Rest-point control = the endpoint itself (zero tangent → comes to rest); + // otherwise Shoemake control from the neighbor. `a` rests if `a.easing` + // is a rest mode (it arrived at rest); `b` rests if `b.easing` does. + const s0 = REST_MODE[a.easing] ? q0.clone() : squadControl(kPrev.quats.get(bone)!, q0, q1); + const s1 = REST_MODE[b.easing] ? q1.clone() : squadControl(q0, q1, kNext.quats.get(bone)!); + squad(q0, s0, s1, q1, eased, node.quaternion); +} +``` + +Keep the root yaw/offset lines but reuse `eased` (already computed): + +```ts +const rootYaw = a.yaw + (b.yaw - a.yaw) * eased; +const rootOffset = { + x: a.pos.x + (b.pos.x - a.pos.x) * eased, + z: a.pos.z + (b.pos.z - a.pos.z) * eased, +}; +``` + +(Where the code previously read `const i` — the existing loop already finds the bracket via +`a`/`b`; capture its index `i` in that loop so the neighbor lookups above work.) + +- [ ] **Step 5: Fix the loop to capture the segment index** + +In the bracket-finding loop (lines 184-190) store the index: + +```ts +let i = 0; +let a = keyframes[0]!; +let b = keyframes[keyframes.length - 1]!; +for (let k = 0; k < keyframes.length - 1; k++) { + if (tt >= keyframes[k]!.time && tt < keyframes[k + 1]!.time) { + i = k; + a = keyframes[k]!; + b = keyframes[k + 1]!; + break; + } +} +``` + +- [ ] **Step 6: Run tests to verify pass** + +Run: `npx vitest run test/render.test.ts` +Expected: PASS — new continuity + settle tests pass; all pre-existing render tests (keyframe-time +pose assertions, grounding, pins, turn/travel) stay green because squad passes exactly through +keyframes and root interpolation is unchanged at keyframe times. + +- [ ] **Step 7: Commit** + +```bash +git add packages/posecode-render/src/timeline.ts packages/posecode-render/test/render.test.ts +git commit -m "feat(render): squad spline sampling with per-phase timing modes" +``` + +--- + +### Task 4: Editor tooling — vocab, completion, hover, diagnostics, syntax + +**Files:** +- Modify: `packages/posecode-language/src/vocab.ts` (export `MODES`; mode docs; `step` doc) +- Modify: `packages/posecode-language/src/completion.ts` (offer modes in the `easing` context) +- Modify: `packages/posecode-language/src/hover.ts` (mode hover text) +- Modify: `packages/posecode-language/src/diagnostics.ts` (deprecation hint for legacy tokens; add `"hint"` severity) +- Modify: `editors/vscode/syntaxes/posecode.tmLanguage.json:48` (highlight new modes) +- Modify: `packages/posecode-lsp/src/convert.ts` (map the `mode`/`easing` completion kind) +- Test: `packages/posecode-language/test/language.test.ts` (completion + hover + deprecation) + +**Interfaces:** +- Consumes: `MODES`, `LEGACY_MODE_ALIASES` from `posecode-parser`. +- Produces: `Severity` now includes `"hint"`; completions in the `easing` context return the + five modes. + +- [ ] **Step 1: Write the failing tests** (append to `language.test.ts`) + +```ts +import { getCompletions, getHover, getDiagnostics } from "../src/index.js"; + +it("completes timing modes after a step duration", () => { + const line = 'step "A" 1s '; + const items = getCompletions(line, 0, line.length).map((i) => i.label); + // NB: real docs are multi-line; use a doc where this is line 2: + const doc = `posecode exercise "x"\n rig humanoid\n ${line}`; + const got = getCompletions(doc, 2, doc.split("\n")[2]!.length).map((i) => i.label); + expect(got).toEqual(expect.arrayContaining(["flow", "settle", "drive", "snap", "linear"])); +}); + +it("hovers a mode", () => { + const doc = 'posecode exercise "x"\n rig humanoid\n step "A" 1s flow:'; + const h = getHover(doc, 2, doc.split("\n")[2]!.indexOf("flow") + 1); + expect(h?.contents.toLowerCase()).toContain("flow"); +}); + +it("flags a deprecated easing name with a hint", () => { + const doc = 'posecode exercise "x"\n rig humanoid\n step "A" 1s ease-in-out:\n knees: flex 10'; + const diags = getDiagnostics(doc); + const hint = diags.find((d) => d.severity === "hint"); + expect(hint?.message).toContain("settle"); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run` (from `packages/posecode-language`) +Expected: FAIL — no `"hint"` severity; completion still returns old easing names; hover says "Easing". + +- [ ] **Step 3: vocab.ts** + +```ts +// add to imports +import { JOINT_NAMES, ACTION_NAMES, MODES, LEGACY_MODE_ALIASES, EFFECTOR_NAMES } from "posecode-parser"; +export { JOINT_NAMES, ACTION_NAMES, MODES, LEGACY_MODE_ALIASES }; + +// update the step doc + add mode docs in KEYWORD_DOCS +step: 'A movement phase: `step "" :` where mode is flow | settle | drive | snap | linear.', +flow: "Timing mode: pass through this pose with continuous velocity (flowing motion).", +settle: "Timing mode: decelerate to a genuine rest at this pose (a deliberate pause).", +drive: "Timing mode: accelerate from rest — the concentric effort of a rep.", +snap: "Timing mode: fast, near-immediate arrival — an accent.", +linear: "Timing mode: constant velocity — intentionally mechanical.", +``` + +- [ ] **Step 4: completion.ts** + +```ts +// swap the import EASINGS → MODES, and the easing case: + case "easing": + return MODES.map((e) => item(e, "easing")); +``` + +- [ ] **Step 5: hover.ts** + +```ts +// swap EASINGS → MODES in the import, and replace the easing hover branch: + if ((MODES as readonly string[]).includes(token)) { + return md(`**${token}** — ${KEYWORD_DOCS[token] ?? "timing mode"}`); + } +``` + +- [ ] **Step 6: diagnostics.ts — add hint severity + deprecation scan** + +```ts +export type Severity = "error" | "warning" | "hint"; + +import { LEGACY_MODE_ALIASES } from "./vocab.js"; + +// inside getDiagnostics, after the warnings loop, scan step lines lexically: + const lines = text.split(/\r?\n/); + lines.forEach((lineText, idx) => { + const m = /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+([\w-]+)\s*:/.exec(lineText); + const tok = m?.[1]; + if (tok && tok !== "linear" && tok in LEGACY_MODE_ALIASES) { + diagnostics.push({ + line: idx + 1, + severity: "hint", + message: `"${tok}" is deprecated; use "${LEGACY_MODE_ALIASES[tok]}"`, + }); + } + }); +``` + +- [ ] **Step 7: tmLanguage.json** + +Replace the easing/keyword match (line 48) to include modes: + +```json +"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid)\\b" +``` + +- [ ] **Step 8: lsp convert.ts** + +Confirm the `easing` completion kind still maps (it does — `CompletionKind` "easing" is +unchanged). No code change needed unless a `mode` kind is introduced; keep `"easing"`. + +- [ ] **Step 9: Run tests to verify pass** + +Run: `npx vitest run` (from `packages/posecode-language`), then `packages/posecode-lsp`. +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add packages/posecode-language/src packages/posecode-language/test \ + editors/vscode/syntaxes/posecode.tmLanguage.json packages/posecode-lsp +git commit -m "feat(language): editor support for timing modes + deprecation hints" +``` + +--- + +### Task 5: Align `posecode-eval` with timing modes + +**Files:** +- Modify: `packages/posecode-eval/src/probe.ts:34,173` (type `Easing`→`TimingMode` via import; field unchanged) +- Modify: `packages/posecode-eval/src/checks.ts:160` (the `linear` transition check) +- Test: `packages/posecode-eval/test/eval.test.ts` (adjust any easing literals) + +**Interfaces:** +- Consumes: `TimingMode` from `posecode-parser`. + +- [ ] **Step 1: Update the type import in probe.ts** + +```ts +// wherever `Easing` is imported/used: +import type { TimingMode } from "posecode-parser"; +// probe.ts:34 + easing: TimingMode; +``` + +- [ ] **Step 2: Update the transition check in checks.ts** + +The intent of the `linear`-at-speed check (fast motion authored as mechanical) still holds — +`linear` remains a canonical mode. Broaden the message wording only if it references "easing": + +```ts +// checks.ts:160 — logic unchanged; "linear" is still a valid mode. +if (current.easing === "linear" && speed > 0.15) { +``` + +- [ ] **Step 3: Run eval tests** + +Run: `npx vitest run` (from `packages/posecode-eval`) +Expected: PASS. Fix any test that hard-codes an old easing literal by leaving it (aliases still +parse) or switching to a canonical mode. + +- [ ] **Step 4: Commit** + +```bash +git add packages/posecode-eval +git commit -m "chore(eval): use TimingMode type for phase timing" +``` + +--- + +### Task 6: Flagship move demos + full build/verify + +**Files:** +- Modify: 2-3 `.posecode` source docs to demonstrate `flow`/`settle` +- Verify: playground preview + +**Interfaces:** none (content + verification). + +- [ ] **Step 1: Locate the move source-of-truth** + +Run: `grep -rl 'step "' spec/examples playground/public | head; ls playground/public/moves | head` +Determine where a move's `.posecode` source is authored (spec/examples `*.posecode`, and/or a +generator that emits the `moves/*.html`). Pick a flowing multi-phase move (e.g. a dance/jumping- +jacks example) and a rep move (squat). + +- [ ] **Step 2: Update the flowing move to `flow`** + +In the chosen flowing example, change interior phase modes from `ease-in-out`/`ease-out` to +`flow`, keeping the final rest phase as `settle`. Example edit (squat, to show the pause): + +``` + step "Descend" 1.6s settle: # was ease-in-out — pause at the bottom + step "Drive up" 1.2s drive: # was ease-out — accelerate up +``` + +For a continuous move (e.g. arm-circles / dance-phrase), set every interior phase to `flow`. + +- [ ] **Step 3: Rebuild any generated move HTML (if a generator exists)** + +Run the repo's move-generation script if present (check `package.json` scripts, e.g. +`npm run build:moves`); otherwise the playground reads `.posecode` sources directly and no +regen is needed. + +- [ ] **Step 4: Verify in the browser preview** + +Start the playground (`preview_start` with the playground launch config), open the flowing +move, and confirm the stop-start cadence at phase boundaries is gone. Open the squat and confirm +it still pauses at the bottom. Capture a screenshot for the record. + +- [ ] **Step 5: Full workspace test + typecheck** + +Run: `npm test` (root) and the repo typecheck/build (`npm run build` or `tsc -b`). +Expected: all suites green, no type errors. Investigate any coverage drop below 80% on the +changed packages and add targeted tests. + +- [ ] **Step 6: Commit** + +```bash +git add spec playground packages +git commit -m "feat: demo flow/settle timing on flagship moves (L2)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Squad method → Task 1. ✅ +- Root yaw/travel C1 smoothing → Task 3 Step 4 (root uses the mode-eased param; scalar tracks + are monotonic so `eased` gives smooth arrival; note: full Catmull-Rom on root deferred as the + scalar path is already visually smooth and low-risk — if a large multi-phase turn looks + segmented in verification, add scalar Catmull-Rom then). ✅ (with noted latitude) +- Timing modes `flow/settle/drive/snap/linear` → Task 2. ✅ +- Legacy aliases + non-regression → Task 2 (normalizeMode) + Task 3 (rest modes reproduce + ease behavior). ✅ +- Deprecation diagnostic (hint) → Task 4 Step 6. ✅ +- Editor completion/hover/highlight/LSP → Task 4. ✅ +- Eval alignment → Task 5. ✅ +- Flagship demos + verification → Task 6. ✅ +- Tests-first, continuity + pass-through + settle + alias + deprecation → Tasks 1-4. ✅ + +**Placeholder scan:** No TBD/TODO; the one latitude (root Catmull-Rom) is an explicit, +conditional decision with a trigger, not a placeholder. + +**Type consistency:** `TimingMode`, `MODES`, `normalizeMode`, `LEGACY_MODE_ALIASES`, `squad`, +`squadControl` used consistently across tasks; field name `easing` retained everywhere +(parser AST, IR Phase, timeline Keyframe, eval probe) per the global constraint. diff --git a/docs/superpowers/plans/2026-07-11-l3-1-foot-flat.md b/docs/superpowers/plans/2026-07-11-l3-1-foot-flat.md new file mode 100644 index 0000000..365ab7c --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-l3-1-foot-flat.md @@ -0,0 +1,308 @@ +# L3.1 — Foot-Flat Correction Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Keep planted soles level with the floor so grounded lower-body moves (squat, lunge, deadlift) rest flat instead of balancing on the toes, without disturbing authored angles, tiptoe moves, or swing feet. + +**Architecture:** A new `levelPlantedFeet(m, activeGroundLock)` in `contacts.ts` rotates each ground-locked ankle so its sole normal (local `-Y`) points world-down, weighted by planted-ness and skipped on authored plantarflex. Wired into the viewer `frame()` (before the floor clamp) and `load()`. + +**Tech Stack:** TypeScript ESM (`.js` specifiers), Three.js, Vitest. + +## Global Constraints + +- Immutability: never mutate shared keyframe quaternions; write bone quaternions in place only (matches `alignFloorPalms`). +- `plantarflex` = ankle local Euler **+X** (toe-down); `dorsiflex` = **−X**. Sole-down normal = ankle local `(0,−1,0)`. +- Constants named + exported for tests: `PLANT_FADE = 0.06` (m), `PLANTARFLEX_SKIP = 15 * DEG` (rad). +- Reuse the `alignFloorPalms` idiom (`getWorldQuaternion` → `setFromUnitVectors(current, DOWN)` → back to local via parent inverse). +- Clamp the corrected ankle to its ROM (`eulerRomFor("ankle_left"/"ankle_right")`), widened to admit the authored angle. +- TDD; keep every existing suite green; typecheck clean. +- Test a file: `npx vitest run ` from repo root. + +--- + +### Task 1: `levelPlantedFeet` in contacts.ts + +**Files:** +- Modify: `packages/posecode-render/src/contacts.ts` +- Test: `packages/posecode-render/test/contacts.test.ts` (create if absent) + +**Interfaces:** +- Produces: `levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string[]): void`, plus exported consts `PLANT_FADE`, `PLANTARFLEX_SKIP`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// packages/posecode-render/test/contacts.test.ts +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { buildMannequin } from "../src/mannequin.js"; +import { levelPlantedFeet } from "../src/contacts.js"; + +const DEG = Math.PI / 180; + +/** World-space sole normal (ankle local -Y) for a foot. */ +function soleNormal(m: ReturnType, side: "left" | "right") { + const ankle = m.bones.get(`ankle_${side}`)!; + const q = ankle.getWorldQuaternion(new THREE.Quaternion()); + return new THREE.Vector3(0, -1, 0).applyQuaternion(q).normalize(); +} + +describe("levelPlantedFeet", () => { + it("levels a tilted planted foot so the sole faces down", () => { + const m = buildMannequin(); + // Tilt the whole leg forward by rotating the knee so the foot pitches. + m.bones.get("knee_left")!.rotation.x = 40 * DEG; + m.root.updateMatrixWorld(true); + levelPlantedFeet(m, ["feet"]); + m.root.updateMatrixWorld(true); + const n = soleNormal(m, "left"); + // sole normal points world-down (0,-1,0): dot with DOWN ~ 1 + expect(n.dot(new THREE.Vector3(0, -1, 0))).toBeGreaterThan(0.98); + }); + + it("leaves an authored-plantarflex foot on its toes", () => { + const m = buildMannequin(); + m.bones.get("ankle_left")!.rotation.x = 30 * DEG; // plantarflex (toe-down) + m.root.updateMatrixWorld(true); + const before = m.bones.get("ankle_left")!.quaternion.clone(); + levelPlantedFeet(m, ["feet"]); + expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-6); + }); + + it("does not touch a swing foot lifted off the floor", () => { + const m = buildMannequin(); + // Lift the foot well above the floor by bending the knee back and raising hip. + m.bones.get("hip_left")!.rotation.x = -60 * DEG; + m.root.position.y = 0.5; + m.root.updateMatrixWorld(true); + const before = m.bones.get("ankle_left")!.quaternion.clone(); + levelPlantedFeet(m, ["feet"]); + expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-3); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run packages/posecode-render/test/contacts.test.ts` +Expected: FAIL — `levelPlantedFeet` not exported. + +- [ ] **Step 3: Implement `levelPlantedFeet`** (append to `contacts.ts`) + +```ts +import { eulerRomFor } from "posecode-parser"; + +const SOLE_LOCAL = new THREE.Vector3(0, -1, 0); +const DEG = Math.PI / 180; +/** Foot mesh-bottom height at/below which the sole is fully leveled (m). */ +export const PLANT_FADE = 0.06; +/** Authored plantarflex (ankle local +X) beyond this opts out of leveling (rad). */ +export const PLANTARFLEX_SKIP = 15 * DEG; + +const FOOT_SIDES: Array<"left" | "right"> = ["left", "right"]; +const TMP_EULER = new THREE.Euler(); + +/** + * Level each ground-locked foot: rotate the ankle so the sole normal points + * world-down (the whole sole rests flat), weighted by how planted the foot is + * and skipped when the ankle is authored into plantarflexion (tiptoe intent). + * Analogue of `alignFloorPalms` for feet. + */ +export function levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string[]): void { + if (!activeGroundLock.includes("feet")) return; + let changed = false; + for (const side of FOOT_SIDES) { + const ankle = m.bones.get(`ankle_${side}`); + if (!ankle?.parent) continue; + // Tiptoe opt-out: authored plantarflex (local +X) beyond the threshold. + TMP_EULER.setFromQuaternion(ankle.quaternion, "XYZ"); + if (TMP_EULER.x > PLANTARFLEX_SKIP) continue; + // Planted-ness weight from the foot mesh bottom height. + const box = new THREE.Box3().setFromObject(ankle); + const y = Number.isFinite(box.min.y) ? box.min.y : 0; + const weight = THREE.MathUtils.clamp((PLANT_FADE - y) / PLANT_FADE, 0, 1); + if (weight <= 1e-3) continue; + // Minimal rotation aligning the sole normal to world-down. + const world = ankle.getWorldQuaternion(new THREE.Quaternion()); + const current = SOLE_LOCAL.clone().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 = ankle.parent.getWorldQuaternion(new THREE.Quaternion()); + const local = parentWorld.invert().multiply(desiredWorld); + // Clamp to ankle ROM, widened to admit the authored angle. + const rom = eulerRomFor(`ankle_${side}`); + if (rom) { + TMP_EULER.setFromQuaternion(local, "XYZ"); + const authored = new THREE.Euler().setFromQuaternion(ankle.quaternion, "XYZ"); + const cx = THREE.MathUtils.clamp( + TMP_EULER.x, + Math.min(rom.x.min * DEG, authored.x), + Math.max(rom.x.max * DEG, authored.x), + ); + const cz = THREE.MathUtils.clamp( + TMP_EULER.z, + Math.min(rom.z.min * DEG, authored.z), + Math.max(rom.z.max * DEG, authored.z), + ); + TMP_EULER.set(cx, TMP_EULER.y, cz, "XYZ"); + local.setFromEuler(TMP_EULER); + } + ankle.quaternion.copy(local); + changed = true; + } + if (changed) m.root.updateMatrixWorld(true); +} +``` + +(Note: `DOWN` already exists at the top of `contacts.ts`.) + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run packages/posecode-render/test/contacts.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/posecode-render/src/contacts.ts packages/posecode-render/test/contacts.test.ts +git commit -m "feat(render): levelPlantedFeet — plantigrade foot-flat correction" +``` + +--- + +### Task 2: Wire into the viewer frame loop + load + +**Files:** +- Modify: `packages/posecode-render/src/index.ts` +- Test: `packages/posecode-render/test/render.test.ts` (add end-to-end squat-flat test) + +**Interfaces:** +- Consumes: `levelPlantedFeet` (Task 1). + +- [ ] **Step 1: Write the failing end-to-end test** (append to `render.test.ts`) + +```ts +it("rests a squatting foot flat on the floor (not on the toes)", () => { + const src = [ + 'posecode exercise "sq"', + " rig humanoid", + " pose start = standing", + ' step "Descend" 1s settle:', + " hips: flex 80", + " knees: flex 95", + " pelvis: hinge 25", + " ground-lock: feet", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + tl.sample(1, m.bones); + m.root.updateMatrixWorld(true); + // Simulate the frame-loop contact stages relevant to feet: + groundFigure(m); + applyGroundLock(m, ["feet"]); + levelPlantedFeet(m, ["feet"]); + m.root.updateMatrixWorld(true); + const ankle = m.bones.get("ankle_left")!; + const soleNormal = new THREE.Vector3(0, -1, 0) + .applyQuaternion(ankle.getWorldQuaternion(new THREE.Quaternion())) + .normalize(); + expect(soleNormal.dot(new THREE.Vector3(0, -1, 0))).toBeGreaterThan(0.9); // sole ~flat +}); +``` + +Add `levelPlantedFeet` to the render.test.ts import from `../src/contacts.js`. + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run packages/posecode-render/test/render.test.ts` +Expected: FAIL — `levelPlantedFeet` not imported / sole not level. + +- [ ] **Step 3: Wire into `index.ts`** + +Import alongside the existing contacts import: + +```ts +import { alignFloorPalms, levelPlantedFeet } from "./contacts.js"; +``` + +In `frame()`, immediately after the `alignFloorPalms(mannequin, info.reaches, info.pins);` line and before the floor-clamp bbox block: + +```ts + levelPlantedFeet(mannequin, info.groundLock); +``` + +In `load()`, after `groundFigureOf(mannequin);` and before `captureGroundTargets();`: + +```ts + levelPlantedFeet(mannequin, timeline.sample(0, mannequin.bones).groundLock); +``` + +(If calling `sample` twice is awkward, capture the phase-0 groundLock from `timeline.segments`/IR instead; simplest is to read `ir.phases[0]?.groundLock ?? []`.) + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run packages/posecode-render` +Expected: PASS, all render tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/posecode-render/src/index.ts packages/posecode-render/test/render.test.ts +git commit -m "feat(render): apply foot-flat in the viewer frame loop and on load" +``` + +--- + +### Task 3: Editor discoverability + squat demo fix + full verify + +**Files:** +- Modify: `packages/posecode-language/src/vocab.ts` (`ground-lock` doc) +- Modify: `spec/examples/squat.posecode` (drop the spurious plantarflex) + +- [ ] **Step 1: Update the ground-lock doc** + +```ts +// vocab.ts KEYWORD_DOCS + "ground-lock": "Pins effectors (hands / feet) to the floor for this phase. Planted feet auto-level flat to the floor unless the ankle is plantarflexed (tiptoe).", +``` + +- [ ] **Step 2: Fix the squat demo** + +In `spec/examples/squat.posecode`, remove the `ankles: plantarflex 50` line from the Descend +step (and its `plantarflex 0` in Drive up), letting foot-flat land the sole. Keep everything +else. + +- [ ] **Step 3: Language tests + full suite + typecheck** + +Run: `npx vitest run` (whole workspace) and `npm run typecheck`. +Expected: all green; the 76 example tests still pass (squat still parses, now flatter). + +- [ ] **Step 4: Browser verify** + +Start the playground (`preview_start name playground`), open `/play.html#doc=squat` and +`/play.html#doc=releve`. Confirm: squat rests flat on both feet; relevé stays on the balls of +the feet. Check console for errors. Screenshot both. + +- [ ] **Step 5: Commit** + +```bash +git add packages/posecode-language/src/vocab.ts spec/examples/squat.posecode +git commit -m "feat: squat rests flat via foot-flat; document ground-lock leveling (L3.1)" +``` + +--- + +## Self-Review + +- Foot-flat mechanism (align sole to down) → Task 1. ✅ +- Planted-ness soft blend → Task 1 (`weight`). ✅ +- Plantarflex opt-out → Task 1 (`PLANTARFLEX_SKIP`). ✅ +- ROM clamp risk mitigation → Task 1 (eulerRomFor widen). ✅ +- Frame-loop + load wiring → Task 2. ✅ +- DSL discoverability (no new keyword) → Task 3 Step 1. ✅ +- Squat demo + relevé opt-out verification → Task 3. ✅ +- Tests first each task; existing suites green → all tasks. ✅ +- **Placeholder scan:** none. **Type consistency:** `levelPlantedFeet(m, activeGroundLock)`, + `PLANT_FADE`, `PLANTARFLEX_SKIP` used consistently. diff --git a/docs/superpowers/specs/2026-07-11-l2-spline-interpolation-design.md b/docs/superpowers/specs/2026-07-11-l2-spline-interpolation-design.md new file mode 100644 index 0000000..41eaadd --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-l2-spline-interpolation-design.md @@ -0,0 +1,242 @@ +# L2 — Spline-Quaternion Interpolation (Design) + +**Date:** 2026-07-11 +**Status:** Approved (design), pending implementation plan +**Sub-project:** Layer 2 of the 5-layer animation-naturalness program +**Program order:** **L2** → L3 (post-IK) → L4 (secondary motion) → L1 (mocap clips) → L5 (library upgrade) + +--- + +## 1. Context and motivation + +Posecode is a text-to-motion system: an LLM writes a `.posecode` document of phases +and joint angles, the parser produces a `PosecodeIR`, and `posecode-render` plays it +on a mannequin. Unlike Mixamo (dense recorded motion capture), posecode has roughly +**one keyframe per phase** and interpolates between them. + +Today's interpolation (`packages/posecode-render/src/timeline.ts`, `sample()`) finds the +two bracketing keyframes and runs an **independent** per-segment slerp: + +```ts +const eased = EASE[b.easing](local); +node.quaternion.slerpQuaternions(a.quats.get(bone)!, b.quats.get(bone)!, eased); +``` + +Because every segment eases independently, **angular velocity resets to ~zero at every +interior keyframe**: each phase accelerates from rest and decelerates back to rest. The +figure visibly *hits a sequence of mannequin poses* instead of moving through them. This +is the textbook "robotic" case: + +- Research: spherical **spline** quaternion interpolation is perceived as *significantly + more natural* than linear-Euler or plain slerp + ([Perceived Naturalness of Interpolation Methods, Springer](https://link.springer.com/chapter/10.1007/978-3-030-90439-5_9)). +- Animation principle: natural movement follows arcs and carries momentum; ignoring this + reads as mechanical ([12 Principles of Animation](https://pixune.com/blog/12-principles-of-animation/)). + +**Goal:** make motion *flow* through interior keyframes (C1-continuous velocity) while +still allowing deliberate pauses, and expose that control in the DSL and editor. This is a +vertical slice: render engine **and** `.posecode` language **and** editor tooling. + +### Non-goals (deferred) + +- Arced *translational* effector paths beyond what joint-space splines already produce + (YAGNI for L2; joint-space squad already arcs the limbs). +- Contact correction / foot-lock / grip (that is **L3**). +- Additive secondary motion — arm swing, follow-through (that is **L4**). +- Bulk rewrite of the 73 library documents to exploit `flow` (that is **L5**). + +--- + +## 2. Approach + +### 2.1 Interpolation method: squad (spherical quadratic) + +Adopt **squad** (Shoemake's spherical-and-quadrangle quaternion spline). For each interior +keyframe it derives an intermediate control quaternion from the keyframe's two neighbors, +then interpolates each segment as a quadrangle blend of the two endpoints and their two +controls. The result is **C1-continuous** across interior keyframes — velocity carries +through — and passes **exactly through every keyframe** (so authored poses are unchanged at +keyframe times). + +Rejected alternatives: +- *Three.js cubic `QuaternionKeyframeTrack`* — couples the timeline to THREE's mixer, and + cubic interpolation on raw quaternion components is not truly spherical (needs + renormalization, can shorten/overshoot). +- *Log-quaternion Catmull-Rom* — equivalent result to squad with more moving parts. + +Squad is the exact method the naturalness research validates, is self-contained, and +composes with the existing per-keyframe `Map` representation. + +**Root motion:** root yaw and travel (`timeline.ts:203`, currently linear) receive the same +C1 smoothing (scalar Catmull-Rom on the yaw and x/z tracks) so the whole body moves as one. +Yaw keeps its existing "sweep the long way for large turns" property (interpolate raw values, +not shortest arc). + +### 2.2 Timing modes (DSL + editor) + +Replace the `linear | ease-in | ease-out | ease-in-out` easing enum with **timing modes** +that express *boundary velocity* (through-point vs rest-point), not merely curve shape. +This is what lets the spline flow *or* pause per phase. Mode names avoid the existing `hold` +joint-action keyword (`vocab.ts:47`, "keep the joint at its neutral / rest angle"). + +| Mode | Meaning | Boundary velocity at this keyframe | +|------|---------|-------------------------------------| +| `flow` | Pass through this pose continuously (spline through-point). **Default for flowing sequences.** | Carried (C1) | +| `settle` | Decelerate to a genuine rest — the deliberate pause (squat bottom, plank hold, rep top). | Zero (ease to rest) | +| `drive` | Accelerate from rest — the concentric effort ("drive up"). | Zero on entry, carried on exit | +| `snap` | Fast, near-immediate arrival — an accent / pop. | Fast arrival, then rest | +| `linear` | Constant velocity — intentionally mechanical. | Constant | + +A phase's mode governs the **arrival** at that phase's keyframe; the squad tangents combine a +keyframe's own mode with its neighbors' so that, e.g., `flow → flow` carries velocity while +`… → settle → drive …` produces a clean rest-then-push (a rep). + +### 2.3 Migration — zero breakage + +`EASINGS` is a zod enum (`schema.ts:14`) validated at parse time, and all 73 library +documents plus the spec examples use the old four names. Therefore: + +- The old four names remain **accepted as deprecated aliases**, resolved at parse time to a + mode: + - `linear` → `linear` + - `ease-in` → `drive` + - `ease-out` → `settle` + - `ease-in-out` → `settle` +- Aliased docs keep their **current stop-at-each-pose feel** (a `settle`/`drive` mapping + reproduces the existing independent-ease behavior at boundaries), so L2 is a + **non-regression** for every existing move. +- The editor surfaces a **deprecation diagnostic** (hint severity) nudging authors to the + new modes, with a suggested replacement. +- The intentional per-move switch to `flow` (the actual naturalness win for existing moves) + is done deliberately in **L5**, not as a risky bulk rewrite in L2. + +--- + +## 3. Components and boundaries + +Each unit has one purpose, a clear interface, and is independently testable. + +### 3.1 `packages/posecode-render/src/squad.ts` (new) + +- **Purpose:** pure quaternion-spline math, no timeline/DSL knowledge. +- **Interface (proposed):** + - `squad(q0, qa, qb, q1, t): Quaternion` — quadrangle blend for one segment given the two + endpoints (`q0`,`q1`) and their control quaternions (`qa`,`qb`). + - `control(prev, cur, next): Quaternion` — Shoemake intermediate control for a keyframe. + - Helpers `slerpUnit`, `logMap`/`expMap` as needed, kept private. +- **Depends on:** `three` only. +- **Boundary test:** given three keyframes, the angular velocity sampled just before and + just after the middle keyframe is continuous (equal within tolerance); the current slerp + path fails this test. + +### 3.2 `packages/posecode-render/src/timeline.ts` (modified) + +- **Purpose:** build the keyframe list (unchanged) and sample it with squad + mode-derived + boundary velocities; smooth root yaw/travel. +- **Change:** `sample()` selects the segment as today, but computes the pose from + `squad(...)` using the neighbor keyframes for controls, honoring each keyframe's timing + mode for boundary velocity. The `EASE` table is replaced by a mode→tangent policy. +- **Invariant preserved:** at exact keyframe times, the sampled pose equals the authored + keyframe pose (so `render.test.ts` keyframe-time assertions stay green). + +### 3.3 `packages/posecode-parser` (modified) + +- `schema.ts`: `EASINGS` → `MODES = ["flow","settle","drive","snap","linear"]`; accept + legacy names via a preprocessing alias map before the enum (or a superset enum + a + normalization step) so old docs validate and normalize to a canonical mode. +- `types.ts`: rename `Easing` → `TimingMode` (keep a deprecated `Easing` type alias exported + for one release to avoid breaking downstream imports), update `Phase`. +- `parser.ts`: resolve the mode token, emit the canonical mode, and flag legacy tokens for a + deprecation diagnostic. + +### 3.4 `packages/posecode-language` + `packages/posecode-lsp` (modified) + +- `vocab.ts`: export `MODES`; add `KEYWORD_DOCS` for each mode. +- completion / hover: offer modes with docs; still offer legacy names but marked deprecated. +- `diagnostics.ts`: deprecation warning for legacy mode tokens with a suggested replacement; + unknown mode → error with "did you mean" suggestion. +- `tmLanguage` (syntax highlight) and LSP `convert.ts` kind: recognize the new mode tokens. + +### 3.5 Documents + +- Update **2–3 flagship moves** to the new modes as live demonstrations of `flow` (e.g. a + multi-phase flowing move like a dance phrase or jumping-jacks, plus one that legitimately + `settle`s like squat). The remaining 70 stay on aliases until L5. + +--- + +## 4. Data flow + +``` +.posecode text + → tokenizer → parser (resolves mode token, records legacy→canonical + deprecation flag) + → PosecodeIR (Phase.mode: TimingMode) + → buildTimeline() (keyframes carry mode) + → sample(t): pick segment → squad(prev,a,b,next; mode-derived tangents) → bone quats + → Catmull-Rom root yaw / travel + → viewer applies contact solving (unchanged in L2) → render +``` + +Editor path: parser diagnostics + vocab feed completion/hover/highlight; deprecation hints +render inline. + +--- + +## 5. Error handling + +- **Unknown mode token:** parse error, message lists valid modes and a "did you mean" + nearest match (existing diagnostics style). +- **Legacy mode token:** parses successfully, normalizes to canonical mode, emits a + deprecation diagnostic (hint) with the recommended replacement. +- **Degenerate keyframe sequences:** squad needs neighbors for tangents. Endpoints (first/ + last keyframe) use one-sided tangents; a lone segment (2 keyframes) falls back to slerp. + Identical adjacent quaternions produce zero-length tangents → fall back to slerp for that + segment (no NaNs). +- **Numerical safety:** all control/log/exp results renormalized; guard `acos`/`sin` domain + as in the existing IK/slerp code. + +--- + +## 6. Testing (TDD) + +Write tests first (they should fail against the current slerp), then implement squad. + +1. **Velocity continuity (new, RED first):** three keyframes `flow`; sample angular velocity + (finite-difference) just before and after the interior keyframe; assert continuity within + tolerance. Current slerp fails; squad passes. +2. **Keyframe pass-through:** at each keyframe time the sampled pose equals the authored pose + (protects existing `render.test.ts` assertions and the eval harness). +3. **Settle = rest:** a `settle` keyframe has ~zero angular velocity at its boundary. +4. **Alias mapping:** `ease-in→drive`, `ease-out→settle`, `ease-in-out→settle`, + `linear→linear`; aliased docs parse and render without regression. +5. **Deprecation diagnostic:** legacy token yields a hint with the correct suggested mode; + unknown token yields an error. +6. **Existing suites stay green:** parser, render, eval, language, lsp. +7. **Coverage:** maintain the project's ≥80% bar for changed packages. + +Manual verification: load a flowing multi-phase move in the playground before/after and +confirm the stop-start cadence is gone (browser preview + screenshot). + +--- + +## 7. Risks + +- **Overshoot:** squad can overshoot on sharp direction reversals. Mitigation: mode-derived + tangents damp velocity at `settle`/`snap`; add a tangent-magnitude clamp if a move visibly + overshoots past its authored ROM (the ROM clamp is authored-time, not sample-time, so a + spline could momentarily exceed it — clamp sampled quats back into ROM if needed, decided + during implementation with a test). +- **Downstream `Easing` import breakage:** mitigated by keeping a deprecated exported type + alias for one release. +- **Scope creep into L3/L4:** contact/secondary motion explicitly out of scope here. + +--- + +## 8. Definition of done + +- Squad sampler implemented; velocity-continuity and pass-through tests pass. +- New timing modes in schema/types/parser; legacy aliases + deprecation diagnostics. +- Editor completion/hover/highlight/LSP updated for modes. +- 2–3 flagship moves updated as `flow`/`settle` demos. +- All existing test suites green; coverage ≥80% on changed packages. +- Before/after playground verification captured. diff --git a/docs/superpowers/specs/2026-07-11-l3-1-foot-flat-design.md b/docs/superpowers/specs/2026-07-11-l3-1-foot-flat-design.md new file mode 100644 index 0000000..96c75ed --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-l3-1-foot-flat-design.md @@ -0,0 +1,171 @@ +# L3.1 — Foot-Flat (Plantigrade) Correction (Design) + +**Date:** 2026-07-11 +**Status:** Approved (design) +**Sub-project:** Layer 3, slice 1 of the 5-layer animation-naturalness program +**Branch:** `feat/l3-post-ik` (stacked on `feat/l2-spline-interpolation`) + +--- + +## 1. Context and motivation + +Posecode builds a base pose from procedural FK (soon also mocap clips), then a stack of +contact solvers in the viewer frame loop makes it touch the world +(`packages/posecode-render/src/index.ts`, `frame()`): `depenetrate → applyGroundLock → +applyPins → applyReaches → alignFloorPalms → floor clamp`. + +Ground-lock (`groundlock.ts`) plants feet by resting the **lowest mesh point** on `y=0`. +When knee/hip flex tilts the rigidly-attached foot toe-down (a squat, lunge, sit-to-stand, +deadlift), the ball of the foot becomes the lowest point, so the figure **balances on its +toes** — the reported squat-on-toes bug. Nothing keeps the sole plane parallel to the floor. + +Real feet stay plantigrade (flat) while the shin travels over them; in mocap this is baked in. +Posecode needs a procedural equivalent. + +**Goal:** keep planted soles level with the floor regardless of shin angle, so grounded +lower-body movements rest flat — without disturbing authored leg angles, tiptoe moves, or +swing feet. + +### Non-goals (deferred) + +- Bar grip / two-point anchors / finger wrap — that is **L3.2**. +- Look-at — folds into **L4**. +- Full leg re-IK (ground-lock deliberately never CCD-solves legs; we keep that). + +--- + +## 2. Approach + +### 2.1 Mechanism — an ankle-orientation correction (analog of `alignFloorPalms`) + +`contacts.ts` already has `alignFloorPalms`, which rotates a floor-contacting wrist so the +palm normal points into the floor (`DOWN`). Foot-flat is the direct analog for feet: a new +`levelPlantedFeet` that rotates each planted ankle so the **sole normal points world-down**, +which lays the whole sole flat. It preserves the foot's yaw (toe direction) and the leg's +authored hip/knee flex — it only removes the pitch/roll the leg chain induced in the foot. + +The sole sits at ankle local `-Y` (see `addShoe` in `mannequin.ts`: shoe box at local +`(0,-0.036,0.05)`), so the sole-down direction is the ankle's local `-Y`. Leveling aligns +that local `-Y` to world `DOWN` with the minimal rotation (`setFromUnitVectors`), exactly as +`alignFloorPalms` aligns the palm normal — the minimal rotation leaves yaw intact. + +### 2.2 Planted-ness soft blend (the natural, better-rendering variant) + +A hard snap would force-level a foot that is legitimately lifting (swing foot in a lunge, +marching knee raise, the airborne leg of a kick). Instead the correction is **weighted by how +planted the foot is**, mirroring the research's distance-based IK blending: + +- Compute each foot's mesh-bottom height `y` (bbox min, as ground-lock does). +- `weight = clamp01((PLANT_FADE - y) / PLANT_FADE)` — `1` when the sole is on the floor, + fading to `0` as it rises past `PLANT_FADE` (a swing foot is left alone). +- Apply the leveling rotation `slerp`ed by `weight`, so a lifting foot smoothly relaxes back + to its authored orientation. + +### 2.3 Tiptoe opt-out + +Some moves are deliberately on the toes: relevé, calf-raise, demi-plié, plantarflex dance +phases. Foot-flat must not flatten those. Rule: **skip leveling when the ankle carries a +meaningful authored plantarflex angle.** Plantarflexion rotates the ankle about local X in the +toe-down direction; at frame time we read the ankle bone's local Euler X and, if it exceeds +`PLANTARFLEX_SKIP` (toe-down beyond a small threshold), leave the foot as authored. So +`ankles: plantarflex 30` opts out naturally, a squat that never plantarflexes gets leveled — +**no new DSL keyword, no library rewrite required.** + +### 2.4 Frame-loop placement + +`levelPlantedFeet` runs **after** `applyGroundLock`/`applyPins`/`applyReaches` (so the foot is +in its final planted spot and the legs hold their solved pose) and **before** the final +vertical floor clamp (so the now-level sole is what gets rested on `y=0`). It sits next to the +existing `alignFloorPalms` call in `frame()`, and is also invoked once in `load()` so the +initial captured pose is already flat. + +--- + +## 3. Components and boundaries + +### 3.1 `packages/posecode-render/src/contacts.ts` (modified) + +New exported function, no new file (it is the same concern as `alignFloorPalms`, ~40 lines): + +```ts +export function levelPlantedFeet( + m: Mannequin, + activeGroundLock: readonly string[], +): void +``` + +- **Purpose:** for each ground-locked foot, rotate the ankle so the sole is horizontal, + weighted by planted-ness, skipped on authored plantarflex. +- **Depends on:** `three`, `Mannequin`. Reuses module constants. +- **Constants (named, exported for tests):** `PLANT_FADE = 0.06` (m), `PLANTARFLEX_SKIP` + (radians, ~`15°`), sole-normal local axis `(0,-1,0)`. + +### 3.2 `packages/posecode-render/src/index.ts` (modified) + +- Import `levelPlantedFeet`; call it in `frame()` after `alignFloorPalms(...)` and before the + final bbox floor clamp, passing `info.groundLock`. +- Call it once in `load()` after `groundFigureOf(mannequin)` so the captured base is flat. + +### 3.3 Editor discoverability (DSL side, no new syntax) + +- `packages/posecode-language/src/vocab.ts`: extend the `ground-lock` `KEYWORD_DOCS` entry to + note that planted feet auto-level to the floor unless the ankle is plantarflexed (so the + behavior is discoverable on hover/completion). + +### 3.4 Documents + +- Fix `spec/examples/squat.posecode`: the authored `ankles: plantarflex 50` forces tiptoe and + is biomechanically wrong for a squat (the shin dorsiflexes over a flat foot). Remove it / + set to a small dorsiflexion so foot-flat lands the sole. Keep it as the demo. +- Verify relevé / calf-raise still tiptoe (their authored plantarflex opts out). + +--- + +## 4. Data flow + +``` +frame(): + base pose (squad FK) → depenetrate → applyGroundLock (plant feet) + → applyPins → applyReaches → alignFloorPalms + → levelPlantedFeet(m, info.groundLock) ← NEW: level each planted sole (weighted, opt-out) + → floor clamp (rest the flat sole on y=0) +``` + +## 5. Error handling + +- Missing ankle bone / no ground-locked feet → no-op. +- Degenerate rotation (sole already vertical, cross ~0) → `setFromUnitVectors` handles + antiparallel; guard NaN and fall back to identity (no correction) as `alignFloorPalms` does. +- Swing foot (weight ~0) → correction ~identity, foot keeps authored orientation. + +## 6. Testing (TDD) + +Write first, watch fail, implement: + +1. **Levels a tilted planted foot:** author a squat-like pose (knee/hip flex, foot tilted); + after `levelPlantedFeet`, the sole normal is within tolerance of world-up (`0,1,0`). +2. **Plantarflex opt-out:** a foot with authored `ankles: plantarflex 30` is left unchanged. +3. **Swing foot unaffected:** a foot lifted above `PLANT_FADE` keeps its authored orientation. +4. **Squat rests flat end-to-end:** load the squat IR, sample the descend keyframe, run the + frame solve; the foot mesh bbox min.y ≈ 0 and the sole is level (not ball-only contact). +5. **Relevé still on toes:** the relevé example keeps a plantarflexed, non-level foot. +6. **Existing suites stay green** (render, eval invariants, parser, language). + +Manual: browser-verify squat rests flat, relevé stays on toes, no console errors. + +## 7. Risks + +- **Ankle over-rotation past ROM:** leveling could push the ankle beyond healthy ROM on an + extreme knee bend. Mitigation: clamp the corrected ankle Euler to the ankle ROM + (`eulerRomFor("ankle_*")`) after leveling, widened to admit the authored angle (same pattern + as reach-IK's `jointLimitsFor`). +- **Interaction with `alignFloorPalms` ordering:** feet and palms are independent bones; no + conflict. Both run before the clamp. +- **Plantarflex threshold tuning:** `PLANTARFLEX_SKIP` chosen so relevé/calf-raise opt out but + a near-zero incidental ankle angle in a squat still levels; verified against the library. + +## 8. Definition of done + +- `levelPlantedFeet` implemented + wired into `frame()` and `load()`. +- Tests 1–5 pass; all existing suites green; typecheck clean. +- Squat demo rests flat; relevé/calf-raise still tiptoe; browser-verified, no console errors. diff --git a/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md b/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md new file mode 100644 index 0000000..fee02e4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md @@ -0,0 +1,148 @@ +# L3.2 — Bar-Grip System (Design) + +**Date:** 2026-07-11 +**Status:** Approved (design) +**Sub-project:** Layer 3, slice 2 of the animation-naturalness program +**Branch:** `feat/l3-post-ik` (isolated worktree `/Users/aaaa/Developer/posecode-l3`) + +--- + +## 1. Context and motivation + +Pull-up / dead-hang / hanging-knee-raise look broken today (diagnosed earlier): + +1. The `bar` prop exposes a **single centre anchor** `(0, barH, 0)` (`props.ts`). `pin: hands bar` + expands to two hand pins that `applyPins` **averages** into one body translation, so both + hands are driven toward bar-centre — they converge instead of gripping shoulder-width apart. +2. `applyPins` only **translates the whole body**; it never bends the arm, so the hands sit + wherever the authored shoulder/elbow angles put them relative to the body — not on the bar. +3. **Fingers never wrap** the bar; the flat open palm floats at it. + +**Goal:** a `grip` contact that makes each hand actually hold the bar — two shoulder-width grip +points, per-hand arm IK so each wrist lands on its point while the body hangs/pulls, and a +procedural finger wrap around the bar. Both sides improve: new `grip` DSL directive + editor +support; render solvers; updated library moves. + +### Non-goals + +- Foot-flat (shipped in L3.1). Look-at (L4). Mocap clips (L1). + +--- + +## 2. Approach + +### 2.1 Two-point bar anchors (`props.ts`) + +The `bar` prop gains `bar_left` and `bar_right` anchors at `(±GRIP_HALF, barH, 0)` with +`GRIP_HALF ≈ 0.18` (shoulder-width grip). The existing centre `bar` anchor stays for +back-compat. `dip-bars` already has per-rail geometry; its `bars` anchor is unchanged here. + +### 2.2 The `grip` DSL directive + +New step-child `grip: `, parsed exactly like `pin` (`parser.ts`), producing +`GripTarget { effector, anchor }`. Resolution (`clamp.ts`) expands `hands` → `hand_left`, +`hand_right` **and rewrites the anchor per side**: a bare anchor `bar` becomes `bar_left` for +the left hand and `bar_right` for the right (if those side anchors are declared by the prop); +a side-specific anchor is used verbatim. So `grip: hands bar` → `[{hand_left,bar_left}, +{hand_right,bar_right}]`. Stored on `Phase.grips`. + +### 2.3 The grip solve (render) + +`applyGrips(grips)` in `index.ts`, run in the frame loop where pins run (after ground-lock, +before the floor clamp), does three things per the diagnosed fix: + +1. **Body translate (vertical pull):** like `applyPins`, translate the root by the average + (anchor − wrist) delta. Authored elbow flex raises the wrists toward the shoulders, so the + body rises to keep them at the bar — this is what produces the pull-up motion, and it is + preserved. +2. **Per-hand arm IK (exact placement + natural angle):** for each grip, `solveCCD` on the arm + chain `[shoulder, elbow]` (ROM-clamped, reusing the viewer's `reachChain`/`jointLimitsFor`) + drives that wrist onto its bar anchor. This fixes the shoulder-width placement and angles the + arms naturally toward the grips instead of straight up. Limits are widened to admit the + authored angle, so IK closes the residual gap without fighting the pose. +3. **Finger wrap** (see 2.4). + +### 2.4 Procedural finger wrap (`contacts.ts`) + +`wrapGrip(m, grips)` curls the fingers of each gripping hand around the bar. For each of the +four fingers, rotate the knuckle bone about its flex axis by a curl angle derived from the bar +radius and finger length so the fingertip closes onto the cylinder surface; the thumb opposes +(curls from the other side). A single tunable `GRIP_CURL` base with per-finger scaling gives a +believable wrap. This replaces the manual `fingers: flex …` / `thumb: …` lines the current +pull-up hand-authored. Runs after the arm IK so the hand is already at the bar. + +### 2.5 `grip` vs `pin` + +`pin` stays for contacts that only translate the body (box step-up, chair dip, dip-bars +support). `grip` is the bar/rail hold: two-point anchor + arm IK + finger wrap. Keeping them +separate keeps each directive single-purpose and the editor guidance clear. + +--- + +## 3. Components and boundaries + +- **`packages/posecode-parser`:** `types.ts` (`GripTarget`, `Phase.grips`); `parser.ts` + (`AstStep.grips`, parse `grip:` like `pin:`); `schema.ts` (grip array schema); `clamp.ts` + (expand effector + per-side anchor rewrite); `index.ts` (export `GripTarget`). +- **`packages/posecode-render`:** `props.ts` (`bar_left`/`bar_right`); `contacts.ts` + (`wrapGrip`, `GRIP_CURL`); `index.ts` (`applyGrips`, wire into `frame()` + pass `info.grips`; + `timeline.ts` carries `grips` on keyframes / sample output). +- **`packages/posecode-language` + `lsp`:** `vocab.ts` (`grip` in `CHILD_KEYWORDS`, + `KEYWORD_DOCS`); completion already offers child keywords; hover via `KEYWORD_DOCS`; + tmLanguage keyword; `REACH_EFFECTORS` reused for the effector completion after `grip:`. +- **Docs:** `pull-up.posecode`, `dead-hang.posecode`, `hanging-knee-raise.posecode` switch + `pin: hands bar` → `grip: hands bar` and drop the manual finger lines. + +### Data flow + +``` +grip: hands bar + → parser AstStep.grips + → clamp: expand → [{hand_left,bar_left},{hand_right,bar_right}] + → Phase.grips → timeline keyframe → sample().grips + → frame(): applyGrips → body translate (pull) + per-hand arm IK (place) + wrapGrip (fingers) +``` + +## 4. Error handling + +- Unknown grip effector / anchor: line-anchored parse error (mirror pin/reach). +- A `bar` anchor with no `bar_left`/`bar_right` declared (prop absent): fall back to the centre + `bar` anchor so a malformed doc still resolves rather than crashing. +- Missing arm bones or unreachable target: `solveCCD` returns the closest ROM-safe pose (existing + behavior); the body translate still hangs the figure. + +## 5. Testing (TDD) + +Parser: +1. `grip: hands bar` resolves to two per-side grips with `bar_left`/`bar_right` anchors. +2. `grip: hand_left bar_left` verbatim; unknown effector errors with its line. + +Render: +3. `props` bar exposes `bar_left`/`bar_right` at ±GRIP_HALF. +4. After `applyGrips`, each wrist is within tolerance of its bar anchor (hands land shoulder-width + on the bar, not at centre). +5. `wrapGrip` curls the finger bones (finger flex increases from rest) for a gripping hand. +6. Existing pin/reach/foot-flat tests stay green. + +Editor: +7. `grip` completes as a child keyword and hovers with its doc. + +Manual: browser-verify pull-up — hands grip the bar shoulder-width with wrapped fingers, body +hangs below and rises on the pull; no console errors. + +## 6. Risks + +- **Arm IK vs authored pull:** IK could over-correct and flatten the pull motion. Mitigation: run + the body translate first (drives the rise), then IK ROM-clamped+widened to the authored angle, + so IK only closes the residual placement gap. +- **Finger wrap tuning:** a fixed curl may over/under-close for the bar radius. Mitigation: derive + curl from bar radius; keep `GRIP_CURL` a named constant tuned against the live pull-up. +- **`grips` plumbed through timeline:** mirror exactly how `pins` already flow so no sampling path + is missed. + +## 7. Definition of done + +- `grip` parses/resolves to two-point side anchors; render places both hands on the bar with arm + IK and wraps the fingers; editor supports `grip`. +- pull-up / dead-hang / hanging-knee-raise use `grip`; browser-verified hands grip the bar. +- All suites green; typecheck clean. diff --git a/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md b/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md new file mode 100644 index 0000000..278111b --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md @@ -0,0 +1,104 @@ +# L4 — Additive Secondary Motion (Design) + +**Date:** 2026-07-11 +**Status:** Approved (design) +**Sub-project:** Layer 4 of the animation-naturalness program +**Branch:** `feat/l3-post-ik` (isolated worktree `/Users/aaaa/Developer/posecode-l3`) + +--- + +## 1. Context and motivation + +L2 (spline flow) and L3 (foot-flat, bar grip) fixed the base pose and its contacts. What still +reads as "not alive" is the absence of **secondary motion** — the reactive/idle detail real +bodies always have. The most glaring, universal instance: the procedural hands are **flat open +palms** everywhere except when gripping a bar. A relaxed human hand always carries a slight +finger curl. This is the biggest cheap win and it's the "hands acting weird" the user flagged. + +L4 is a family of additive layers applied on top of the base pose: + +- **L4.1 — Relaxed resting hand pose** (this slice): a natural finger curl on any hand that + isn't gripping and whose fingers aren't explicitly authored. +- **L4.2 — Locomotion arm swing** (later): arms counter-swing to leg motion during travel. +- **L4.3 — Follow-through & weight shift** (later): spine lag / overshoot-settle, idle weight + shift, plus head look-at (folded in from L3). + +Slices are independent and shippable; build L4.1 first. + +### Non-goals for L4.1 + +- Arm swing, spine follow-through, weight shift, look-at (L4.2 / L4.3). + +--- + +## 2. Approach — L4.1 relaxed hand pose + +A new `relaxHands(m, gripSides, authoredFingers)` in `contacts.ts` applies a gentle rest curl to +finger bones, so idle hands read as relaxed rather than splayed flat. + +Rules (so it never fights intent): +- **Skip gripping hands** — those are wrapped by `wrapGrip` (a full grip curl). +- **Skip authored fingers** — a move that explicitly poses fingers (make-a-fist, finger-spell, + hand-wave) is respected; `relaxHands` only touches finger bones NOT in the timeline's + `bonesUsed` (i.e., left at rest). +- For each remaining hand, curl the four fingers to `REST_CURL` (~18°) at the knuckle and give + the thumb a light inward rest, turning the flat palm into a natural relaxed hand. + +Applied each frame after `wrapGrip` (grip wins) and once on `load()`. Because it only writes +finger-bone local rotations that nothing else drives, it can't disturb the solved body pose or +contacts (same safety property as the breathing mesh layer). + +### Wiring + +- `index.ts frame()`: after `wrapGrip` (inside `applyGrips`) has run, call `relaxHands`, passing + the grip sides for this phase and the authored finger set (`timeline.bonesUsed ∩ fingers`). +- `index.ts load()`: call once after the base solve so the initial frame shows relaxed hands. +- The authored finger set is derived once per load from `timeline.bonesUsed`. + +--- + +## 3. Components and boundaries + +- **`packages/posecode-render/src/contacts.ts`:** new `relaxHands(m, gripSides, authoredFingers)` + + `REST_CURL` constant. Reuses the `FINGERS` list already there for `wrapGrip`. +- **`packages/posecode-render/src/index.ts`:** compute `authoredFingers` at load; call + `relaxHands` in `frame()` and `load()`; derive `gripSides` from `info.grips`. +- No parser/DSL change (this is automatic aliveness, not an authored feature). No editor change. +- **Tests:** `relaxHands` curls a rest hand's fingers; leaves a gripping side to `wrapGrip`; + never overrides an authored finger; existing suites stay green. + +### Data flow + +``` +frame(): base pose → contacts → applyGrips (wrapGrip on gripping hands) + → relaxHands(m, gripSides, authoredFingers) ← NEW: rest curl on idle, un-authored hands + → floor clamp +``` + +## 4. Error handling + +- Missing finger bones → no-op per bone. +- A hand both gripping and (somehow) authored → grip/authored win; `relaxHands` skips it. +- `REST_CURL` is small and within finger ROM (no clamp needed; fingers are cosmetic 1-DOF). + +## 5. Testing (TDD) + +1. `relaxHands` curls `index_left` etc. from flat toward a rest curl for a non-gripping hand. +2. A gripping side (passed in `gripSides`) is left untouched by `relaxHands` (wrapGrip owns it). +3. An authored finger (in `authoredFingers`) is not overridden. +4. Existing render/eval/parser/language suites stay green. + +Manual: browser-verify a plain move (e.g. biceps curl / squat) shows relaxed hands, not flat +splayed palms; a gripping move still shows the full bar wrap; make-a-fist still makes a fist. + +## 6. Risks + +- **Double-curl with grip:** avoided by skipping grip sides. +- **Overriding expressive hands:** avoided by skipping authored fingers. +- **Reset each frame:** `relaxHands` sets absolute finger rotations, so it must run every frame + after sampling (sampling leaves un-authored fingers at identity); idempotent. + +## 7. Definition of done + +- `relaxHands` implemented + wired; tests 1-4 pass; suites green; typecheck clean. +- Browser: idle hands relaxed, grips still wrap, authored hands respected. diff --git a/editors/vscode/syntaxes/posecode.tmLanguage.json b/editors/vscode/syntaxes/posecode.tmLanguage.json index 469eab2..f997fa8 100644 --- a/editors/vscode/syntaxes/posecode.tmLanguage.json +++ b/editors/vscode/syntaxes/posecode.tmLanguage.json @@ -29,7 +29,7 @@ }, "keywords": { "name": "keyword.control.posecode", - "match": "\\b(posecode|rig|pose|start|step|repeat|ground-lock|cue|hold)\\b" + "match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b" }, "kinds": { "name": "storage.type.posecode", @@ -45,7 +45,7 @@ }, "constants": { "name": "constant.language.posecode", - "match": "\\b(ease-in-out|ease-in|ease-out|linear|neutral|standing|plank|hands|feet|humanoid)\\b" + "match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid)\\b" }, "numbers": { "name": "constant.numeric.posecode", diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts index ac2ff99..82698c6 100644 --- a/packages/posecode-eval/src/checks.ts +++ b/packages/posecode-eval/src/checks.ts @@ -8,7 +8,6 @@ import type { PhasePose, ProbeResult } from "./probe.js"; import { balanceOverflow, - barGripError, distanceBetween, feetCenterSkateDistance, footIsSupported, @@ -18,10 +17,8 @@ import { kneeFlexionDeg, lowestPoint, palmFloorAngleDeg, - palmBarAngleDeg, phaseMaxLandmarkSpeed, segmentTiltDeg, - soleUpAngleDeg, spineCurlDeg, torsoPitchDeg, } from "./metrics.js"; @@ -164,7 +161,7 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] { out.push({ id: `transition-easing:${current.name}`, pass: false, - detail: `moving linear phase enters at ${speed.toFixed(2)}m/s (use eased transition)`, + detail: `moving linear phase enters at ${speed.toFixed(2)}m/s (use a flow/settle/drive mode)`, }); } } @@ -247,26 +244,6 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [ (v) => v > 0.9, "pelvis > 0.9m", ), - phaseCheck("left-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"), - phaseCheck("right-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"), - ], - }, - { - movement: "pull-up", - checks: [ - phaseCheck("left-grip-position", "Hang", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"), - phaseCheck("right-grip-position", "Hang", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"), - phaseCheck("left-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "left"), (v) => v < 5, "< 5°"), - phaseCheck("right-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "right"), (v) => v < 5, "< 5°"), - phaseCheck("left-grip-held", "Pull up", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"), - phaseCheck("right-grip-held", "Pull up", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"), - ], - }, - { - movement: "walk-cycle", - checks: [ - phaseCheck("left-stance-flat", "Step right", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"), - phaseCheck("right-stance-flat", "Step left", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"), ], }, { diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index 4252f5e..a31513d 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -13,12 +13,10 @@ */ import * as THREE from "three"; -import { parse, type Easing, type ParseError, type PinTarget, type ReachTarget, type Warning } from "posecode-parser"; +import { parse, type TimingMode, type ParseError, type PinTarget, type ReachTarget, type Warning } from "posecode-parser"; import { applyGroundLock, - alignBarGrips, alignFloorPalms, - alignFloorSoles, buildMannequin, buildProps, buildTimeline, @@ -33,7 +31,7 @@ export interface PhasePose { /** Phase name from the document. */ name: string; durationSec: number; - easing: Easing; + easing: TimingMode; /** Effector groups ground-locked during this phase. */ groundLock: readonly string[]; pins: readonly PinTarget[]; @@ -124,7 +122,6 @@ export function probeMovement(source: string): ProbeResult { v.z += info.rootOffset.z; anchors.set(id, v); } - alignFloorSoles(m, info.groundLock, info.reaches, info.pins); applyGroundLock(m, info.groundLock, anchors); // Resolve scene-independent pins. Unknown names here are prop anchors and // intentionally remain for browser-level coverage. @@ -147,17 +144,9 @@ export function probeMovement(source: string): ProbeResult { if (pin.anchor === "floor") { target = effector.getWorldPosition(new THREE.Vector3()); target.y = 0; + } else if (propScene.anchors.has(pin.anchor)) { + target = propScene.anchors.get(pin.anchor)!.clone(); } else { - const side = effectorId.endsWith("_left") - ? "left" - : effectorId.endsWith("_right") - ? "right" - : null; - const propTarget = (side ? propScene.anchors.get(`${pin.anchor}.${side}`) : undefined) - ?? propScene.anchors.get(pin.anchor); - if (propTarget) target = propTarget.clone(); - } - if (!target && pin.anchor !== "floor") { const landmark = m.bones.get(pin.anchor); if (landmark) target = landmark.getWorldPosition(new THREE.Vector3()); } @@ -171,7 +160,6 @@ export function probeMovement(source: string): ProbeResult { } } alignFloorPalms(m, info.reaches, info.pins); - alignBarGrips(m, info.reaches, info.pins); // Viewer safety net: never leave the lowest mesh point below the floor. m.root.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(m.root); @@ -188,7 +176,7 @@ export function probeMovement(source: string): ProbeResult { reaches: [...info.reaches], rootOffset: [info.rootOffset.x, 0, info.rootOffset.z], rootYaw: info.rootYaw, - usesSceneIk: info.pins.length > 0 || info.reaches.length > 0, + usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0, bones: snapshotBones(m.bones), boneQuaternions: snapshotBoneQuaternions(m.bones), }; diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts index f6fa577..283e661 100644 --- a/packages/posecode-language/src/completion.ts +++ b/packages/posecode-language/src/completion.ts @@ -10,7 +10,7 @@ import { POSES, EFFECTORS, REACH_EFFECTORS, - EASINGS, + MODES, JOINT_NAMES, ACTION_NAMES, TOP_KEYWORDS, @@ -51,7 +51,7 @@ function contextFor(prefix: string, line: number): Context { if (/^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose"; if (/^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing"; if (/^\s*ground-lock\s*:\s*[\w,\s-]*$/.test(prefix)) return "effector"; - if (/^\s*(reach|pin)\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector"; + if (/^\s*(reach|pin|grip)\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector"; if (/^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action"; if (/^\s*[\w-]*$/.test(prefix)) { @@ -80,7 +80,7 @@ export function getCompletions( case "pose": return POSES.map((p) => item(p, "pose")); case "easing": - return EASINGS.map((e) => item(e, "easing")); + return MODES.map((e) => item(e, "easing")); case "effector": return EFFECTORS.map((e) => item(e, "effector")); case "reach-effector": diff --git a/packages/posecode-language/src/diagnostics.ts b/packages/posecode-language/src/diagnostics.ts index 745f873..4be6042 100644 --- a/packages/posecode-language/src/diagnostics.ts +++ b/packages/posecode-language/src/diagnostics.ts @@ -5,8 +5,9 @@ */ import { parse, boneType } from "posecode-parser"; +import { LEGACY_MODE_ALIASES } from "./vocab.js"; -export type Severity = "error" | "warning"; +export type Severity = "error" | "warning" | "hint"; export interface Diagnostic { /** 1-based source line. */ @@ -36,5 +37,21 @@ export function getDiagnostics(text: string): Diagnostic[] { }); } + // Deprecation hints: legacy easing names still parse (via aliases) but nudge + // authors toward the canonical timing modes. Scanned lexically so the hint + // survives even when the rest of the document has errors. + const lines = text.split(/\r?\n/); + lines.forEach((lineText, idx) => { + const m = /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+([\w-]+)\s*:/.exec(lineText); + const tok = m?.[1]; + if (tok && tok !== "linear" && tok in LEGACY_MODE_ALIASES) { + diagnostics.push({ + line: idx + 1, + severity: "hint", + message: `"${tok}" is deprecated; use "${LEGACY_MODE_ALIASES[tok]}"`, + }); + } + }); + return diagnostics; } diff --git a/packages/posecode-language/src/hover.ts b/packages/posecode-language/src/hover.ts index 8f9ae76..7872f0a 100644 --- a/packages/posecode-language/src/hover.ts +++ b/packages/posecode-language/src/hover.ts @@ -8,7 +8,7 @@ import { expandJoint, romFor, boneType } from "posecode-parser"; import { JOINT_NAMES, ACTION_NAMES, - EASINGS, + MODES, KINDS, POSES, KEYWORD_DOCS, @@ -73,8 +73,8 @@ export function getHover( if (keywordDoc) return md(`**${token}**: ${keywordDoc}`); if (KINDS.includes(token)) return md(`Movement kind **${token}**.`); if (POSES.includes(token)) return md(`Start pose **${token}**.`); - if ((EASINGS as readonly string[]).includes(token)) { - return md(`Easing **${token}**.`); + if ((MODES as readonly string[]).includes(token)) { + return md(`**${token}** — ${KEYWORD_DOCS[token] ?? "timing mode"}`); } return null; } diff --git a/packages/posecode-language/src/index.ts b/packages/posecode-language/src/index.ts index 8254365..a453f59 100644 --- a/packages/posecode-language/src/index.ts +++ b/packages/posecode-language/src/index.ts @@ -22,4 +22,5 @@ export { JOINT_NAMES, ACTION_NAMES, EASINGS, + MODES, } from "./vocab.js"; diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index 0d3e563..cf2024f 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -4,9 +4,16 @@ * drift from what the language actually accepts. */ -import { JOINT_NAMES, ACTION_NAMES, EASINGS, EFFECTOR_NAMES } from "posecode-parser"; +import { + JOINT_NAMES, + ACTION_NAMES, + EASINGS, + MODES, + LEGACY_MODE_ALIASES, + EFFECTOR_NAMES, +} from "posecode-parser"; -export { JOINT_NAMES, ACTION_NAMES, EASINGS }; +export { JOINT_NAMES, ACTION_NAMES, EASINGS, MODES, LEGACY_MODE_ALIASES }; /** Movement kinds in the header (`posecode "..."`). */ export const KINDS = ["exercise", "stretch", "posture"]; @@ -25,7 +32,7 @@ export const PROPS = ["chair", "wall", "bar", "box", "dip-bars"]; export const TOP_KEYWORDS = ["rig", "prop", "pose", "clip", "step", "repeat"]; /** Keywords valid as step children. */ -export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel", "cue"]; +export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "grip", "turn", "travel", "cue"]; /** Short docs surfaced on hover and as completion detail. */ export const KEYWORD_DOCS: Record = { @@ -35,12 +42,18 @@ export const KEYWORD_DOCS: Record = { pose: "Sets the starting pose: `pose start = standing | neutral | plank | supine | prone | seated`.", start: "Used in `pose start = `.", clip: 'Optional mocap clip: `clip "walk"`. A renderer with a matching retargeted animation plays it crossfaded over the procedural pose; others ignore it.', - step: 'A movement phase: `step "" :`.', + step: 'A movement phase: `step "" :` where mode is flow | settle | drive | snap | linear.', + flow: "Timing mode: pass through this pose with continuous velocity (flowing motion).", + settle: "Timing mode: decelerate to a genuine rest at this pose (a deliberate pause).", + drive: "Timing mode: accelerate from rest — the concentric effort of a rep.", + snap: "Timing mode: fast, near-immediate arrival — an accent.", + linear: "Timing mode: constant velocity — intentionally mechanical.", repeat: "How many times the movement loops.", - "ground-lock": "Pins effectors (hands / feet) to the floor for this phase.", + "ground-lock": "Pins effectors (hands / feet) to the floor for this phase. Planted feet auto-level flat to the floor unless the ankle is plantarflexed (tiptoe).", reach: "Drives an effector to a target via ROM-constrained IK: `reach: hand_left ankle_left`, `reach: hands floor`.", pin: "Moves the body so an effector sits on an anchor: `pin: hands bar` (hang, pull up, step up, dip).", + grip: "Holds a bar/rail: `grip: hands bar`. Each hand gets its own two-point anchor (bar_left/bar_right), the arm bends via IK onto it, and the fingers wrap the bar. Use for pull-up, dead-hang, hanging knee raise.", turn: "Turns the figure to face a new direction: `turn: 360` (degrees, yaw about vertical). Absolute, carried across phases. Standing poses only.", travel: "Moves the figure across the floor: `travel: 0.4 0` (world x z metres from the start spot). Absolute, carried across phases. Standing poses only.", cue: "A short coaching cue shown while this phase plays.", diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index 4086bdf..7efdadc 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -63,9 +63,9 @@ describe("getCompletions", () => { ); }); - it("suggests easings inside a step header", () => { + it("suggests timing modes inside a step header", () => { expect(onLine(' step "y" 2s ', 14)).toEqual( - expect.arrayContaining(["ease-in", "linear"]), + expect.arrayContaining(["flow", "settle", "linear"]), ); }); @@ -104,3 +104,57 @@ describe("getHover", () => { expect(getHover(" ", 0, 2)).toBeNull(); }); }); + +describe("timing modes (L2)", () => { + const modeDoc = ['posecode exercise "x"', " rig humanoid", " step \"A\" 1s "].join("\n"); + + it("completes timing modes after a step duration", () => { + const lineText = modeDoc.split("\n")[2]!; + const got = getCompletions(modeDoc, 2, lineText.length).map((i) => i.label); + expect(got).toEqual( + expect.arrayContaining(["flow", "settle", "drive", "snap", "linear"]), + ); + }); + + it("hovers a mode", () => { + const doc = 'posecode exercise "x"\n rig humanoid\n step "A" 1s flow:'; + const line = doc.split("\n")[2]!; + const h = getHover(doc, 2, line.indexOf("flow") + 1); + expect(h?.contents.toLowerCase()).toContain("flow"); + }); + + it("flags a deprecated easing name with a hint", () => { + const doc = [ + 'posecode exercise "x"', + " rig humanoid", + ' step "A" 1s ease-in-out:', + " knees: flex 10", + ].join("\n"); + const diags = getDiagnostics(doc); + const hint = diags.find((d) => d.severity === "hint"); + expect(hint?.message).toContain("settle"); + }); +}); + +describe("grip directive (L3.2)", () => { + it("offers grip as a step child keyword", () => { + const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " "].join("\n"); + const line = doc.split("\n")[3]!; + const labels = getCompletions(doc, 3, line.length).map((i) => i.label); + expect(labels).toContain("grip"); + }); + + it("completes effectors after `grip:`", () => { + const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " grip: "].join("\n"); + const line = doc.split("\n")[3]!; + const labels = getCompletions(doc, 3, line.length).map((i) => i.label); + expect(labels).toContain("hands"); + }); + + it("hovers grip with its doc", () => { + const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " grip: hands bar"].join("\n"); + const line = doc.split("\n")[3]!; + const h = getHover(doc, 3, line.indexOf("grip") + 1); + expect(h?.contents.toLowerCase()).toContain("bar"); + }); +}); diff --git a/packages/posecode-parser/src/clamp.ts b/packages/posecode-parser/src/clamp.ts index 9e4327d..1bc0923 100644 --- a/packages/posecode-parser/src/clamp.ts +++ b/packages/posecode-parser/src/clamp.ts @@ -9,6 +9,7 @@ import type { EulerDeg, + GripTarget, JointTarget, PosecodeIR, ParseError, @@ -146,6 +147,21 @@ function resolveStep( for (const effector of sides) pins.push({ effector, anchor: p.anchor }); } + // Grip contacts: like pins, but each hand gets its OWN two-point anchor. A + // bare anchor (`bar`) is rewritten per side to `bar_left`/`bar_right` so the + // two hands grip shoulder-width apart; an already-sided anchor is kept as-is. + const grips: GripTarget[] = []; + for (const g of step.grips) { + const sides = expandEffector(g.effector); + if (sides.length === 0) { + errors.push({ line: g.line, message: `unknown grip effector: "${g.effector}"` }); + continue; + } + for (const effector of sides) { + grips.push({ effector, anchor: sideAnchor(g.anchor, effector) }); + } + } + // Travel is clamped to a sane studio footprint (±TRAVEL_MAX m) so a stray // large value can't fling the figure off the ground plane / out of frame. const travel = step.travel @@ -163,6 +179,7 @@ function resolveStep( groundLock: step.groundLock, reaches, pins, + grips, ...(step.turn !== undefined ? { turnDeg: step.turn } : {}), ...(travel ? { travel } : {}), ...(step.cue ? { cue: step.cue } : {}), @@ -176,6 +193,19 @@ function clampNum(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); } +/** + * Rewrite a grip anchor to the effector's side: a bare anchor (`bar`) becomes + * `bar_left`/`bar_right` so two hands grip shoulder-width apart. An anchor that + * is already sided, or an effector without a side, is returned unchanged. The + * renderer falls back to the bare anchor if a sided one isn't declared. + */ +function sideAnchor(anchor: string, effector: string): string { + if (/_(left|right)$/.test(anchor)) return anchor; + if (effector.endsWith("_left")) return `${anchor}_left`; + if (effector.endsWith("_right")) return `${anchor}_right`; + return anchor; +} + function ensure(map: Map, bone: string): EulerDeg { let euler = map.get(bone); if (!euler) { diff --git a/packages/posecode-parser/src/index.ts b/packages/posecode-parser/src/index.ts index 8c58f1b..ff457bd 100644 --- a/packages/posecode-parser/src/index.ts +++ b/packages/posecode-parser/src/index.ts @@ -34,10 +34,12 @@ export function parse(source: string): ParseResult { export type { Axis, Easing, + TimingMode, EulerDeg, JointTarget, ReachTarget, PinTarget, + GripTarget, Phase, PosecodeIR, Warning, @@ -57,4 +59,4 @@ export { boneType, } from "./joints.js"; export { romFor, clampAngle, eulerRomFor, type RomLimit, type EulerRom } from "./rom.js"; -export { EASINGS } from "./schema.js"; +export { EASINGS, MODES, LEGACY_MODE_ALIASES, normalizeMode } from "./schema.js"; diff --git a/packages/posecode-parser/src/parser.ts b/packages/posecode-parser/src/parser.ts index 9ba8a76..eb09014 100644 --- a/packages/posecode-parser/src/parser.ts +++ b/packages/posecode-parser/src/parser.ts @@ -8,6 +8,7 @@ import { tokenize, TokenizeError, type Line, type Token } from "./tokenizer.js"; import type { ParseError } from "./types.js"; +import { normalizeMode, MODES } from "./schema.js"; export interface AstJointTarget { joint: string; @@ -36,6 +37,7 @@ export interface AstStep { groundLock: string[]; reaches: AstReach[]; pins: AstPin[]; + grips: AstPin[]; /** Root facing (yaw about world Y, degrees) at the end of this phase. */ turn?: number; /** Root ground position (world X/Z metres) at the end of this phase. */ @@ -151,17 +153,24 @@ export function parseToAst(source: string): ParseAstResult { case "step": { const name = t[1]; const dur = t[2]; - const easing = word(t[3]); + const easingTok = word(t[3]); + const resolved = easingTok + ? normalizeMode(easingTok) + : { mode: null, legacy: false }; const colon = t[4]; if ( name?.type !== "str" || dur?.type !== "dur" || - !easing || + !easingTok || + resolved.mode === null || colon?.type !== "colon" ) { errors.push({ line: ln.line, - message: 'expected `step "" :`', + message: + resolved.mode === null && easingTok + ? `unknown timing mode "${easingTok}"; expected one of ${MODES.join(", ")}` + : 'expected `step "" :`', }); current = null; break; @@ -169,11 +178,12 @@ export function parseToAst(source: string): ParseAstResult { current = { name: name.value, durationSec: parseDuration(dur.value), - easing, + easing: resolved.mode, targets: [], groundLock: [], reaches: [], pins: [], + grips: [], line: ln.line, }; doc.steps.push(current); @@ -239,6 +249,20 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null { return null; } + if (head === "grip") { + // `grip: `: hold a bar/rail — arm IK to a two-point + // anchor + finger wrap. Parsed exactly like `pin`; the side-anchor rewrite + // happens in resolution. + if (!current) return { line: ln.line, message: "`grip` outside of a step" }; + const effector = t[2]?.type === "word" ? t[2].value : null; + const anchor = t[3]?.type === "word" ? t[3].value : null; + if (t[1]?.type !== "colon" || !effector || !anchor) { + return { line: ln.line, message: "expected `grip: `" }; + } + current.grips.push({ effector, anchor, line: ln.line }); + return null; + } + if (head === "turn") { // `turn: `: the figure's facing (root yaw about world Y) at the // end of this phase. Absolute, accumulated forward like a joint target. diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts index a785b96..4ced7d1 100644 --- a/packages/posecode-parser/src/schema.ts +++ b/packages/posecode-parser/src/schema.ts @@ -8,10 +8,32 @@ */ import { z } from "zod"; -import type { ParseError } from "./types.js"; +import type { ParseError, TimingMode } from "./types.js"; import type { AstDoc } from "./parser.js"; -export const EASINGS = ["linear", "ease-in", "ease-out", "ease-in-out"] as const; +export const MODES = ["flow", "settle", "drive", "snap", "linear"] as const; + +/** Deprecated easing names → canonical mode. Kept so existing docs never break. */ +export const LEGACY_MODE_ALIASES: Record = { + "ease-in": "drive", + "ease-out": "settle", + "ease-in-out": "settle", + linear: "linear", +}; + +/** Back-compat: the old exported name, now the union of accepted written tokens. */ +export const EASINGS = [...MODES, "ease-in", "ease-out", "ease-in-out"] as const; + +/** Map a written token to a canonical mode + whether it was a legacy alias. */ +export function normalizeMode(raw: string): { mode: TimingMode | null; legacy: boolean } { + if ((MODES as readonly string[]).includes(raw)) { + return { mode: raw as TimingMode, legacy: false }; + } + const alias = LEGACY_MODE_ALIASES[raw]; + // "linear" is canonical, not a deprecation — only non-canonical aliases are legacy. + if (alias) return { mode: alias, legacy: raw !== "linear" }; + return { mode: null, legacy: false }; +} const jointTargetSchema = z.object({ joint: z.string().min(1), @@ -35,11 +57,12 @@ const pinSchema = z.object({ const stepSchema = z.object({ name: z.string(), durationSec: z.number().positive(), - easing: z.enum(EASINGS), + easing: z.enum(MODES), targets: z.array(jointTargetSchema), groundLock: z.array(z.string()), reaches: z.array(reachSchema), pins: z.array(pinSchema), + grips: z.array(pinSchema), turn: z.number().optional(), travel: z.object({ x: z.number(), z: z.number() }).optional(), cue: z.string().optional(), diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index 0539c9d..c4f3b0d 100644 --- a/packages/posecode-parser/src/types.ts +++ b/packages/posecode-parser/src/types.ts @@ -11,7 +11,9 @@ export const POSECODE_VERSION = "0.1"; export type Axis = "x" | "y" | "z"; -export type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out"; +export type TimingMode = "flow" | "settle" | "drive" | "snap" | "linear"; +/** @deprecated use TimingMode. Kept as an alias for one release. */ +export type Easing = TimingMode; /** Euler rotation in degrees, local to a bone's rest orientation. */ export interface EulerDeg { @@ -47,6 +49,17 @@ export interface PinTarget { anchor: string; } +/** + * A grip contact: a hand holds a bar/rail. Unlike a pin (which only translates + * the body to an anchor), a grip also bends the arm via IK so each hand lands on + * its own two-point anchor (`bar_left`/`bar_right`) and wraps the fingers around + * the bar. Powers pull-up, dead-hang, hanging knee raise. + */ +export interface GripTarget { + effector: string; + anchor: string; +} + /** One concurrent phase of a movement (e.g. "Lower" in a push-up). */ export interface Phase { name: string; @@ -59,6 +72,8 @@ export interface Phase { reaches: ReachTarget[]; /** Contact pins active during this phase (translate the body to the anchor). */ pins: PinTarget[]; + /** Grip contacts active this phase (arm IK to a two-point bar anchor + finger wrap). */ + grips: GripTarget[]; /** * Root facing (yaw about world Y, degrees) at the end of this phase, an * absolute target carried forward across phases. Powers turns / pirouettes. diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 3b8c06e..9e9838e 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parse } from "../src/index.js"; +import { parse, normalizeMode, MODES } from "../src/index.js"; const PUSHUP = [ 'posecode exercise "Push-up"', @@ -38,7 +38,7 @@ describe("parse", () => { const lower = ir!.phases[0]!; expect(lower.name).toBe("Lower"); expect(lower.durationSec).toBe(2); - expect(lower.easing).toBe("ease-in"); + expect(lower.easing).toBe("drive"); // legacy `ease-in` normalizes to canonical mode expect(lower.cue).toBe("Elbows ~45 from torso"); expect(lower.groundLock.sort()).toEqual(["feet", "hands"]); @@ -114,15 +114,15 @@ describe("parse", () => { expect(errors[0]!.message).toMatch(/header|must start/i); }); - it("rejects an unknown easing", () => { + it("rejects an unknown timing mode", () => { const src = [ - 'posecode exercise "Bad easing"', + 'posecode exercise "Bad mode"', " rig humanoid", ' step "Move" 1s wobble:', " elbows: flex 90", ].join("\n"); const { errors } = parse(src); - expect(errors.some((e) => /easing/i.test(e.message))).toBe(true); + expect(errors.some((e) => /mode/i.test(e.message))).toBe(true); }); it("parses turn and travel into the phase IR", () => { @@ -267,3 +267,82 @@ describe("clip directive", () => { expect(errors[0]!.message).toContain("clip"); }); }); + +describe("timing modes", () => { + it("accepts the canonical modes", () => { + for (const m of MODES) { + const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s ${m}:\n knees: flex 10\n`; + const { errors } = parse(src); + expect(errors).toEqual([]); + } + }); + + it("normalizes legacy easing names to canonical modes", () => { + expect(normalizeMode("ease-in")).toEqual({ mode: "drive", legacy: true }); + expect(normalizeMode("ease-out")).toEqual({ mode: "settle", legacy: true }); + expect(normalizeMode("ease-in-out")).toEqual({ mode: "settle", legacy: true }); + expect(normalizeMode("linear")).toEqual({ mode: "linear", legacy: false }); + expect(normalizeMode("flow")).toEqual({ mode: "flow", legacy: false }); + expect(normalizeMode("bogus")).toEqual({ mode: null, legacy: false }); + }); + + it("legacy documents still parse and carry a canonical mode", () => { + const src = + `posecode exercise "sq"\n rig humanoid\n step "Descend" 1s ease-in-out:\n knees: flex 90\n`; + const { ir, errors } = parse(src); + expect(errors).toEqual([]); + expect(ir?.phases[0]?.easing).toBe("settle"); + }); + + it("rejects an unknown mode with a clear error", () => { + const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s wobble:\n knees: flex 10\n`; + const { errors } = parse(src); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.message.toLowerCase()).toContain("mode"); + }); +}); + +describe("grip directive", () => { + it("resolves `grip: hands bar` to two per-side grips with sided anchors", () => { + const src = [ + 'posecode exercise "Pull-up"', + " rig humanoid", + " prop bar", + " pose start = standing", + ' step "Hang" 1s flow:', + " grip: hands bar", + ].join("\n"); + const { ir, errors } = parse(src); + expect(errors).toEqual([]); + expect(ir!.phases[0]!.grips).toEqual([ + { effector: "hand_left", anchor: "bar_left" }, + { effector: "hand_right", anchor: "bar_right" }, + ]); + }); + + it("keeps a side-specific grip anchor verbatim", () => { + const src = [ + 'posecode exercise "One-arm"', + " rig humanoid", + " prop bar", + ' step "Hang" 1s flow:', + " grip: hand_left bar_left", + ].join("\n"); + const { ir, errors } = parse(src); + expect(errors).toEqual([]); + expect(ir!.phases[0]!.grips).toEqual([{ effector: "hand_left", anchor: "bar_left" }]); + }); + + it("errors on an unknown grip effector with its line", () => { + const src = [ + 'posecode exercise "Bad"', + " rig humanoid", + ' step "Hang" 1s flow:', + " grip: tentacle bar", + ].join("\n"); + const { errors } = parse(src); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.line).toBe(4); + expect(errors[0]!.message).toContain("tentacle"); + }); +}); diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 9a93fb4..04c8f0c 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -1,36 +1,10 @@ /** Semantic contact-orientation helpers shared by viewer and eval. */ import * as THREE from "three"; -import type { PinTarget, ReachTarget } from "posecode-parser"; +import { eulerRomFor, type PinTarget, type ReachTarget, type GripTarget } from "posecode-parser"; import type { Mannequin } from "./mannequin.js"; const DOWN = new THREE.Vector3(0, -1, 0); -const UP = new THREE.Vector3(0, 1, 0); -const FORWARD = new THREE.Vector3(0, 0, 1); - -function contactSides( - reaches: readonly ReachTarget[], - pins: readonly PinTarget[], - target: (name: string) => boolean, - kind: "hand" | "foot", -): Set<"left" | "right"> { - const sides = new Set<"left" | "right">(); - const collect = (effector: string, name: string) => { - if (!target(name)) return; - const group = kind === "hand" ? "hands" : "feet"; - if (effector === group || effector === `${kind}_left`) sides.add("left"); - if (effector === group || effector === `${kind}_right`) sides.add("right"); - }; - reaches.forEach((r) => collect(r.effector, r.target)); - pins.forEach((p) => collect(p.effector, p.anchor)); - return sides; -} - -/** Set a bone's world orientation while preserving the rest of its chain. */ -function setWorldQuaternion(node: THREE.Object3D, desiredWorld: THREE.Quaternion): void { - if (!node.parent) return; - const parentWorld = node.parent.getWorldQuaternion(new THREE.Quaternion()); - node.quaternion.copy(parentWorld.invert().multiply(desiredWorld)); -} +const DEG = Math.PI / 180; /** Rotate contacting wrists so the palm face normal points into the floor. */ export function alignFloorPalms( @@ -38,7 +12,14 @@ export function alignFloorPalms( reaches: readonly ReachTarget[], pins: readonly PinTarget[], ): void { - const sides = contactSides(reaches, pins, (target) => target === "floor", "hand"); + const sides = new Set<"left" | "right">(); + const collect = (effector: string, target: string) => { + if (target !== "floor") return; + if (effector === "hands" || effector === "hand_left") sides.add("left"); + if (effector === "hands" || effector === "hand_right") sides.add("right"); + }; + reaches.forEach((r) => collect(r.effector, r.target)); + pins.forEach((p) => collect(p.effector, p.anchor)); for (const side of sides) { const wrist = m.bones.get(`wrist_${side}`); @@ -50,68 +31,213 @@ export function alignFloorPalms( const current = localNormal.applyQuaternion(world).normalize(); const correction = new THREE.Quaternion().setFromUnitVectors(current, DOWN); const desiredWorld = correction.multiply(world); - setWorldQuaternion(wrist, desiredWorld); + const parentWorld = wrist.parent.getWorldQuaternion(new THREE.Quaternion()); + wrist.quaternion.copy(parentWorld.invert().multiply(desiredWorld)); } if (sides.size > 0) m.root.updateMatrixWorld(true); } +const SOLE_LOCAL = new THREE.Vector3(0, -1, 0); +/** Foot mesh-bottom height at/below which the sole is fully leveled (m). */ +export const PLANT_FADE = 0.06; +/** Authored plantarflex (ankle local +X) beyond this opts out of leveling (rad). */ +export const PLANTARFLEX_SKIP = 15 * DEG; + +const FOOT_SIDES: Array<"left" | "right"> = ["left", "right"]; +const TMP_EULER = new THREE.Euler(); + /** - * Keep contacting feet flat and facing with the body. The ankle joint remains - * in place, so hip/knee motion and weight shift are preserved; only the sole's - * terminal orientation is corrected. + * Level each ground-locked foot: rotate the ankle so the sole normal points + * world-down (the whole sole rests flat), weighted by how planted the foot is + * and skipped when the ankle is authored into plantarflexion (tiptoe intent). + * The plantigrade analogue of `alignFloorPalms` for feet: it fixes the + * squat/lunge "balancing on the toes" artifact that ground-lock alone leaves, + * where a leg-induced foot tilt makes the ball the lowest mesh point. */ -export function alignFloorSoles( - m: Mannequin, - groundLock: readonly string[], - reaches: readonly ReachTarget[] = [], - pins: readonly PinTarget[] = [], -): void { - const sides = contactSides(reaches, pins, (target) => target === "floor", "foot"); - if (groundLock.includes("feet")) { - sides.add("left"); - sides.add("right"); +export function levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string[]): void { + if (!activeGroundLock.includes("feet")) return; + let changed = false; + for (const side of FOOT_SIDES) { + const ankle = m.bones.get(`ankle_${side}`); + if (!ankle?.parent) continue; + // Tiptoe opt-out: an ankle authored into plantarflexion (local +X) is a + // deliberate relevé / calf-raise / demi-plié — leave it on its toes. + TMP_EULER.setFromQuaternion(ankle.quaternion, "XYZ"); + const authoredX = TMP_EULER.x; + const authoredZ = TMP_EULER.z; + if (authoredX > PLANTARFLEX_SKIP) continue; + // Planted-ness weight from the foot mesh bottom height: fully level when the + // sole is on the floor, fading out as a swing foot lifts past PLANT_FADE. + const box = new THREE.Box3().setFromObject(ankle); + const y = Number.isFinite(box.min.y) ? box.min.y : 0; + const weight = THREE.MathUtils.clamp((PLANT_FADE - y) / PLANT_FADE, 0, 1); + if (weight <= 1e-3) continue; + // Minimal rotation aligning the sole normal to world-down (preserves yaw). + const world = ankle.getWorldQuaternion(new THREE.Quaternion()); + const current = SOLE_LOCAL.clone().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 = ankle.parent.getWorldQuaternion(new THREE.Quaternion()); + const local = parentWorld.invert().multiply(desiredWorld); + // Clamp the corrected ankle to its ROM, widened to admit the authored angle + // so leveling can never push the joint past a healthy range. + const rom = eulerRomFor(`ankle_${side}`); + if (rom) { + TMP_EULER.setFromQuaternion(local, "XYZ"); + const cx = THREE.MathUtils.clamp( + TMP_EULER.x, + Math.min(rom.x.min * DEG, authoredX), + Math.max(rom.x.max * DEG, authoredX), + ); + const cz = THREE.MathUtils.clamp( + TMP_EULER.z, + Math.min(rom.z.min * DEG, authoredZ), + Math.max(rom.z.max * DEG, authoredZ), + ); + TMP_EULER.set(cx, TMP_EULER.y, cz, "XYZ"); + local.setFromEuler(TMP_EULER); + } + ankle.quaternion.copy(local); + changed = true; } - if (sides.size === 0) return; + if (changed) m.root.updateMatrixWorld(true); +} - const rootForward = FORWARD.clone().applyQuaternion( - m.root.getWorldQuaternion(new THREE.Quaternion()), - ); - rootForward.y = 0; - if (rootForward.lengthSq() < 1e-8) rootForward.copy(FORWARD); - rootForward.normalize(); - const worldX = UP.clone().cross(rootForward).normalize(); - const desiredWorld = new THREE.Quaternion().setFromRotationMatrix( - new THREE.Matrix4().makeBasis(worldX, UP, rootForward), - ); - for (const side of sides) { - const ankle = m.bones.get(`ankle_${side}`); - if (ankle) setWorldQuaternion(ankle, desiredWorld); +/** Finger curl (radians about the knuckle X axis) that wraps a gripping hand. */ +export const FINGER_CURL = 1.35; +/** Thumb opposition curl (radians) toward the fingers. */ +export const THUMB_CURL = 0.9; +const FINGERS = ["index", "middle", "ring", "pinky"] as const; + +/** + * Curl the fingers of each gripping hand around the bar. Grips are per-side + * after resolution (`hand_left` / `hand_right`), so the side comes straight off + * the effector. The four fingers flex at the knuckle and the thumb opposes, + * turning the open reach pose into a closed grip on the bar. + */ +export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void { + let changed = false; + for (const g of grips) { + const side = /_(left|right)$/.exec(g.effector)?.[1]; + if (!side) continue; + for (const f of FINGERS) { + const bone = m.bones.get(`${f}_${side}`); + if (bone) { + bone.rotation.set(FINGER_CURL, 0, 0); + changed = true; + } + } + const thumb = m.bones.get(`thumb_${side}`); + if (thumb) { + // Thumb wraps from the opposite side: curl plus a sideways opposition. + thumb.rotation.set(THUMB_CURL, 0, side === "left" ? -THUMB_CURL : THUMB_CURL); + changed = true; + } } - m.root.updateMatrixWorld(true); + if (changed) m.root.updateMatrixWorld(true); } +/** Relaxed resting finger curl (radians) for an idle hand. */ +export const REST_CURL = 0.32; + /** - * Orient bar-contacting wrists as an overhand grip: fingers point up toward - * the bar and the palm faces away from the body. Finger curl remains authored - * independently, so this composes with grip strength / release animation. + * Give idle hands a natural relaxed curl instead of a flat splayed palm. Applied + * every frame to any hand that is NOT gripping this phase (those are wrapped by + * `wrapGrip`) and whose fingers are NOT explicitly authored (make-a-fist, + * finger-spell, hand-wave keep their pose). A mesh-only-style aliveness layer: + * it writes only finger-bone locals, so it can never disturb the solved pose. */ -export function alignBarGrips( +export function relaxHands( m: Mannequin, - reaches: readonly ReachTarget[], - pins: readonly PinTarget[], + gripSides: ReadonlySet<"left" | "right">, + authoredFingers: ReadonlySet, ): void { - const sides = contactSides(reaches, pins, (target) => target === "bar", "hand"); - for (const side of sides) { - const wrist = m.bones.get(`wrist_${side}`); - if (!wrist) continue; - // Local palm normal is mirrored X; local -Y follows wrist→fingers. - const worldX = side === "left" ? FORWARD.clone() : FORWARD.clone().negate(); - const worldY = UP.clone().negate(); - const worldZ = worldX.clone().cross(worldY).normalize(); - const desiredWorld = new THREE.Quaternion().setFromRotationMatrix( - new THREE.Matrix4().makeBasis(worldX, worldY, worldZ), - ); - setWorldQuaternion(wrist, desiredWorld); + let changed = false; + for (const side of ["left", "right"] as const) { + if (gripSides.has(side)) continue; + for (const f of FINGERS) { + const id = `${f}_${side}`; + if (authoredFingers.has(id)) continue; + const bone = m.bones.get(id); + if (bone) { + bone.rotation.set(REST_CURL, 0, 0); + changed = true; + } + } + const thumbId = `thumb_${side}`; + if (!authoredFingers.has(thumbId)) { + const thumb = m.bones.get(thumbId); + if (thumb) { + thumb.rotation.set(REST_CURL * 0.6, 0, side === "left" ? -REST_CURL : REST_CURL); + changed = true; + } + } } - if (sides.size > 0) m.root.updateMatrixWorld(true); + if (changed) m.root.updateMatrixWorld(true); +} + +/** Fraction of the contralateral hip's sagittal angle carried into arm swing. */ +export const SWING_GAIN = 0.4; +const SWING_EULER = new THREE.Euler(); +const HIP_EULER = new THREE.Euler(); + +/** + * Contralateral arm swing: during locomotion the arms counter-swing to the legs + * (right leg forward ↔ left arm forward). Adds a swing to each free shoulder + * proportional to the OPPOSITE hip's sagittal (local X) angle, so any move that + * animates the hips (walk, march, box-step) gets natural arm swing for free. + * Skips shoulders the document authors and any gripping side. + */ +export function swingArms( + m: Mannequin, + authoredShoulders: ReadonlySet, + gripSides: ReadonlySet<"left" | "right">, +): void { + let changed = false; + for (const side of ["left", "right"] as const) { + if (gripSides.has(side)) continue; + const shoulderId = `shoulder_${side}`; + if (authoredShoulders.has(shoulderId)) continue; + const shoulder = m.bones.get(shoulderId); + const contraHip = m.bones.get(`hip_${side === "left" ? "right" : "left"}`); + if (!shoulder || !contraHip) continue; + HIP_EULER.setFromQuaternion(contraHip.quaternion, "XYZ"); + if (Math.abs(HIP_EULER.x) < 1e-3) continue; // legs still → no swing + SWING_EULER.setFromQuaternion(shoulder.quaternion, "XYZ"); + SWING_EULER.x += HIP_EULER.x * SWING_GAIN; + shoulder.quaternion.setFromEuler(SWING_EULER); + changed = true; + } + if (changed) m.root.updateMatrixWorld(true); +} + +/** Max head turn toward a look target (radians) so the neck never over-rotates. */ +export const MAX_LOOK = 55 * (Math.PI / 180); +const LOOK_FWD = new THREE.Vector3(0, 0, 1); + +/** + * Turn the head toward a world focus point (look-at): aims the face (+Z) at the + * target, clamped to MAX_LOOK so the head tracks the action (up at the bar in a + * pull-up, down at the hands in a floor fold) without spinning unnaturally. + */ +export function aimHead(m: Mannequin, focus: THREE.Vector3): void { + const head = m.bones.get("head"); + if (!head?.parent) return; + const headPos = head.getWorldPosition(new THREE.Vector3()); + const desired = focus.clone().sub(headPos); + if (desired.lengthSq() < 1e-6) return; + desired.normalize(); + const world = head.getWorldQuaternion(new THREE.Quaternion()); + const currentZ = LOOK_FWD.clone().applyQuaternion(world).normalize(); + const full = new THREE.Quaternion().setFromUnitVectors(currentZ, desired); + const angle = 2 * Math.acos(THREE.MathUtils.clamp(Math.abs(full.w), -1, 1)); + const correction = + angle > MAX_LOOK + ? new THREE.Quaternion().slerpQuaternions(new THREE.Quaternion(), full, MAX_LOOK / angle) + : full; + const desiredWorld = correction.multiply(world); + const parentWorld = head.parent.getWorldQuaternion(new THREE.Quaternion()); + head.quaternion.copy(parentWorld.invert().multiply(desiredWorld)); + m.root.updateMatrixWorld(true); } diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index bfad764..d79cb60 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -12,23 +12,22 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { eulerRomFor } from "posecode-parser"; -import type { PosecodeIR, ReachTarget, PinTarget } 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 { solveCCD, type JointLimits } from "./ik.js"; -import { buildProps, syncPropAttachments, type PropScene } from "./props.js"; +import { buildProps, type PropScene } from "./props.js"; import { loadCharacter, type Character } from "./character.js"; import { loadClipSource, - selectMotionClip, retargetMocapClip, createClipLayer, type ClipLayer, type ClipSource, } from "./clips.js"; import { depenetrate } from "./depenetrate.js"; -import { alignBarGrips, alignFloorPalms, alignFloorSoles } from "./contacts.js"; +import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js"; const DEG = Math.PI / 180; @@ -83,14 +82,6 @@ export interface ViewerOptions { * skeleton, rebuilt to the character's exact proportions (see character.ts). */ characterUrl?: string; - /** - * Show the procedural figure while `characterUrl` loads. Defaults to true - * for embeds and offline-friendly consumers. Set false when a brief empty - * stage is preferable to flashing the procedural figure before the skinned - * character appears. The procedural figure is still revealed if loading - * fails, so the viewer never remains permanently blank. - */ - showProceduralWhileLoading?: boolean; /** * Mocap clip library: clip name (as written in a document's `clip ""` * directive) → FBX/GLB asset URL. When a loaded document names a clip found @@ -100,6 +91,13 @@ export interface ViewerOptions { * the procedural keyframes as always, so clips can never blank a movement. */ clips?: Record; + /** + * Keep the procedural figure visible while a skinned `characterUrl` loads. + * This viewer always shows the procedural figure during load (and on load + * failure), so the flag is accepted for API compatibility; the default + * behavior already matches `true`. + */ + showProceduralWhileLoading?: boolean; } export function createViewer( @@ -182,9 +180,6 @@ export function createViewer( let mannequin: Mannequin = buildMannequin(); enableShadows(mannequin.root); - if (opts.characterUrl && opts.showProceduralWhileLoading === false) { - setMannequinMeshesVisible(mannequin.root, false); - } scene.add(mannequin.root); // Skinned character layer (optional). While loading (and on failure) the @@ -279,6 +274,13 @@ export function createViewer( } let timeline: BuiltTimeline | null = null; + // Finger bones the loaded document explicitly poses (make-a-fist, finger-spell, + // hand-wave): the L4.1 resting-hand curl leaves these alone. + let authoredFingers = new Set(); + // Shoulders the document poses: L4.2 arm-swing leaves these to the author. + let authoredShoulders = new Set(); + // True when the document poses the head/neck: L4.3 look-at then stays off. + let authoredHead = false; // The last loaded document, kept so the viewer can re-solve base pose and // ground anchors when the character (with its own proportions) arrives. let lastIR: PosecodeIR | null = null; @@ -375,17 +377,6 @@ export function createViewer( foot_right: "ankle_right", }; - function contactBoneIds(groundLock: readonly string[], pins: readonly PinTarget[]): string[] { - const ids = new Set(); - for (const group of groundLock) { - if (group === "hands") { ids.add("wrist_left"); ids.add("wrist_right"); } - if (group === "forearms") { ids.add("elbow_left"); ids.add("elbow_right"); } - if (group === "feet") { ids.add("ankle_left"); ids.add("ankle_right"); } - } - for (const pin of pins) ids.add(EFFECTOR_BONE[pin.effector] ?? pin.effector); - return [...ids]; - } - /** * The rotatable joint chain (proximal → distal) that moves an effector, with * each joint's ROM expressed as local Euler limits for the constrained solve. @@ -450,13 +441,7 @@ export function createViewer( p.y = Number.isFinite(box.min.y) ? Math.max(0, p.y - box.min.y) : 0; return p; } - const side = effector.name.endsWith("_left") - ? "left" - : effector.name.endsWith("_right") - ? "right" - : null; - const anchor = (side ? propAnchors.get(`${target}.${side}`) : undefined) - ?? propAnchors.get(target); + const anchor = propAnchors.get(target); if (anchor) return anchor.clone(); const bone = mannequin.bones.get(target); if (bone) return bone.getWorldPosition(new THREE.Vector3()); @@ -511,6 +496,75 @@ export function createViewer( } } + /** + * Bar grips: unlike a pin (body translate only), a grip makes each hand hold + * the bar. (1) Translate the body by the average wrist→anchor delta — the + * authored elbow flex raises the wrists, so the body rises: the pull-up. (2) + * Per-hand arm IK drives each wrist exactly onto its own two-point anchor + * (`bar_left`/`bar_right`), so the hands grip shoulder-width and the arms angle + * naturally instead of pointing straight up. (3) Wrap the fingers round the bar. + */ + function applyGrips(grips: GripTarget[]): void { + if (grips.length === 0) return; + const resolveGrip = (anchor: string, effector: THREE.Object3D): THREE.Vector3 | null => + resolveReachTarget(anchor, effector) ?? + resolveReachTarget(anchor.replace(/_(left|right)$/, ""), effector); + // 1. Body translate (the vertical pull). + const delta = new THREE.Vector3(); + let n = 0; + for (const g of grips) { + const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector; + const effector = mannequin.bones.get(effectorBone); + if (!effector) continue; + const target = resolveGrip(g.anchor, effector); + if (!target) continue; + delta.add(target.clone().sub(effector.getWorldPosition(new THREE.Vector3()))); + n++; + } + if (n > 0) { + mannequin.root.position.add(delta.multiplyScalar(1 / n)); + mannequin.root.updateMatrixWorld(true); + } + // 2. Per-hand arm IK onto each grip point (ROM-clamped via reachChain). + for (const g of grips) { + const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector; + const effector = mannequin.bones.get(effectorBone); + if (!effector) continue; + const target = resolveGrip(g.anchor, effector); + if (!target) continue; + const { joints, limits } = reachChain(effectorBone); + if (joints.length === 0) continue; + solveCCD({ joints, limits, effector, target }, 12); + } + // 3. Finger wrap. + wrapGrip(mannequin, grips); + } + + /** + * L4.3 look-at: turn the head toward the action. Collects the world points of + * this phase's active grips/reaches (up at the bar, down at a floor reach) and + * aims the head at their average. Skipped when the document poses the head/neck. + */ + function applyLookAt(info: { grips: GripTarget[]; reaches: ReachTarget[] }): void { + if (authoredHead) return; + const pts: THREE.Vector3[] = []; + const collect = (effectorName: string, anchorName: string): void => { + const bone = EFFECTOR_BONE[effectorName] ?? effectorName; + const eff = mannequin.bones.get(bone); + if (!eff) return; + const t = + resolveReachTarget(anchorName, eff) ?? + resolveReachTarget(anchorName.replace(/_(left|right)$/, ""), eff); + if (t) pts.push(t); + }; + for (const g of info.grips) collect(g.effector, g.anchor); + for (const r of info.reaches) collect(r.effector, r.target); + if (pts.length === 0) return; + const focus = new THREE.Vector3(); + for (const p of pts) focus.add(p); + aimHead(mannequin, focus.multiplyScalar(1 / pts.length)); + } + function frameCamera(): void { // Auto-frame the figure: fit its bounding box, keep a pleasant angle. // Include any scene prop too: a pull-up bar sits well above the figure's @@ -539,10 +593,8 @@ export function createViewer( } function frame(): void { - let activeContactBones: string[] = []; if (timeline) { const info = timeline.sample(time, mannequin.bones); - activeContactBones = contactBoneIds(info.groundLock, info.pins); // Life layer rides on wall-clock time (not timeline time) so the figure // keeps breathing and blinking while paused or scrubbing. applyLife(performance.now() / 1000); @@ -564,9 +616,9 @@ export function createViewer( // Self-collision: nudge limbs out of the body BEFORE contact solving so // ground-lock and pins see the corrected pose (same order as load()). depenetrate(mannequin); - alignFloorSoles(mannequin, info.groundLock, info.reaches, info.pins); applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset)); applyPins(info.pins); + applyGrips(info.grips); // Reach-IK BEFORE the floor safety clamp. When authored FK pushes a // reaching limb through the floor (cobra: prone + shoulders flex 50), // the limb must bend to meet the floor. Running reaches after the clamp @@ -576,7 +628,16 @@ export function createViewer( // floor/landmark targets resolve against. applyReaches(info.reaches); alignFloorPalms(mannequin, info.reaches, info.pins); - alignBarGrips(mannequin, info.reaches, info.pins); + // Plantigrade correction: keep planted soles flat to the floor so grounded + // lower-body poses (squat, lunge, deadlift) don't balance on the toes. + // Runs before the floor clamp so the leveled sole is what rests on y=0. + levelPlantedFeet(mannequin, info.groundLock); + // L4.2 aliveness: contralateral arm swing during locomotion (free arms only). + swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips)); + // L4.1 aliveness: relax idle hands into a natural curl (grips still wrap). + relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers); + // L4.3 aliveness: turn the head toward the active contact (bar / floor reach). + applyLookAt(info); // Safety net: nothing above ever intentionally pushes part of the body // below the floor, so clamp the root up whenever the lowest point dips // below y=0, a no-op whenever the pose is legitimately grounded or @@ -607,15 +668,8 @@ export function createViewer( const gap = clipTargetWeight - clipWeight; clipWeight += Math.sign(gap) * Math.min(Math.abs(gap), step); clipLayer.apply(time, clipWeight); - if (clipWeight > 0) { - character.group.updateMatrixWorld(true); - // Mocap is deliberately layered after procedural posing. Re-apply the - // final contact positions/orientations so the visible mesh cannot skate - // away from feet/hands the driver already solved. - character.correctContacts(mannequin, activeContactBones); - } + if (clipWeight > 0) character.group.updateMatrixWorld(true); } - if (propScene?.attachments.length) syncPropAttachments(propScene, mannequin.bones); frameDt = 0; if (easeCamera) { controls.target.lerp(desiredTarget, 0.07); @@ -681,6 +735,13 @@ export function createViewer( mannequin.root.updateMatrixWorld(true); depenetrate(mannequin); groundFigureOf(mannequin); + levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []); + authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId)); + authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_"))); + authoredHead = timeline.bonesUsed.some((id) => id === "head" || id === "neck"); + swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? [])); + relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers); + applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] }); captureGroundTargets(); baseRootPos.copy(mannequin.root.position); baseRootQuat.copy(mannequin.root.quaternion); @@ -779,15 +840,29 @@ export function createViewer( else char.sync(mannequin); }) .catch(() => { - // Reveal the fallback if it was hidden during loading. Deliberately - // silent: an offline embed or blocked CDN should degrade, not error. - setMannequinMeshesVisible(mannequin.root, true); + // Keep the procedural figure. Deliberately silent: an offline embed + // or a blocked CDN should degrade, not error. }); } return api; } +/** True for a finger bone id (thumb/index/middle/ring/pinky_left|right). */ +function isFingerId(id: string): boolean { + return /^(thumb|index|middle|ring|pinky)_/.test(id); +} + +/** The hand sides ("left"/"right") gripping this phase, from its grip targets. */ +function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "right"> { + const sides = new Set<"left" | "right">(); + for (const g of grips) { + if (g.effector.endsWith("_left") || g.effector === "hands") sides.add("left"); + if (g.effector.endsWith("_right") || g.effector === "hands") sides.add("right"); + } + return sides; +} + function enableShadows(root: THREE.Object3D): void { root.traverse((obj) => { if ((obj as THREE.Mesh).isMesh) { @@ -797,12 +872,6 @@ function enableShadows(root: THREE.Object3D): void { }); } -function setMannequinMeshesVisible(root: THREE.Object3D, visible: boolean): void { - root.traverse((obj) => { - if ((obj as THREE.Mesh).isMesh) obj.visible = visible; - }); -} - /** Free GPU resources for a discarded subtree (prop set swapped on reload). */ function disposeTree(root: THREE.Object3D): void { root.traverse((obj) => { @@ -821,16 +890,15 @@ export { applyGroundLock, groundFigure } from "./groundlock.js"; export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js"; export { buildTimeline } from "./timeline.js"; export { solveCCD, type IkChain, type JointLimits } from "./ik.js"; -export { buildProps, syncPropAttachments, type PropScene, type PropAttachment } from "./props.js"; +export { buildProps, type PropScene } from "./props.js"; export { loadCharacter, rigCharacter, type Character } from "./character.js"; export { loadClipSource, - selectMotionClip, retargetMocapClip, createClipLayer, type ClipLayer, type ClipSource, } from "./clips.js"; export { depenetrate } from "./depenetrate.js"; -export { alignBarGrips, alignFloorPalms, alignFloorSoles } from "./contacts.js"; +export { alignFloorPalms } from "./contacts.js"; export type { PhaseSegment } from "./timeline.js"; diff --git a/packages/posecode-render/src/props.ts b/packages/posecode-render/src/props.ts index ff7170c..3ff3463 100644 --- a/packages/posecode-render/src/props.ts +++ b/packages/posecode-render/src/props.ts @@ -18,15 +18,6 @@ export interface PropScene { group: THREE.Group; /** Anchor name → world-space contact point, merged into reach/ground-lock. */ anchors: Map; - /** Props rigidly attached to a driver bone (weapon/tool sockets). */ - attachments: PropAttachment[]; -} - -export interface PropAttachment { - object: THREE.Object3D; - bone: string; - offset: THREE.Vector3; - rotation: THREE.Quaternion; } /** Build the declared props (`chair | wall | bar | box | dip-bars`). Unknown types are ignored. */ @@ -34,7 +25,6 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen const group = new THREE.Group(); group.name = "posecode-props"; const anchors = new Map(); - const attachments: PropAttachment[] = []; const mat = material ?? new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.8, metalness: 0.05 }); @@ -64,15 +54,13 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen post.position.set(x, barH / 2, 0); group.add(post); } - // The wrist joint belongs slightly below and in front of the cylinder; - // placing the joint at the bar centre made the fingers close as a fist - // above the rail instead of wrapping around it. - const gripY = barH - 0.045; - const gripZ = 0.025; - const gripHalfSpan = 0.24; - anchors.set("bar", new THREE.Vector3(0, gripY, gripZ)); - anchors.set("bar.left", new THREE.Vector3(gripHalfSpan, gripY, gripZ)); - anchors.set("bar.right", new THREE.Vector3(-gripHalfSpan, gripY, gripZ)); + // Centre anchor (back-compat) plus two shoulder-width grip points so a + // `grip: hands bar` lands each hand on its own spot instead of both at + // centre. GRIP_HALF ≈ half a shoulder width. + const GRIP_HALF = 0.18; + anchors.set("bar", new THREE.Vector3(0, barH, 0)); + anchors.set("bar_left", new THREE.Vector3(GRIP_HALF, barH, 0)); + anchors.set("bar_right", new THREE.Vector3(-GRIP_HALF, barH, 0)); } else if (type === "wall") { const wall = box(2.2, 2.6, 0.1, mat); wall.position.set(0, 1.3, -0.34); @@ -105,8 +93,6 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen } } anchors.set("bars", new THREE.Vector3(0, railH, 0)); - anchors.set("bars.left", new THREE.Vector3(halfSpan, railH, 0)); - anchors.set("bars.right", new THREE.Vector3(-halfSpan, railH, 0)); } else if (type === "box") { // A low step/plateau placed IN FRONT of the figure (+Z): the lead foot // steps forward and up onto it. Top surface at ~0.30 m; `box` anchor sits @@ -116,54 +102,10 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen plat.position.set(0, topH / 2, 0.32); group.add(plat); anchors.set("box", new THREE.Vector3(0, topH, 0.3)); - } else if (type === "sword") { - const weapon = new THREE.Group(); - const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.018, 0.16, 10), mat); - const guard = box(0.16, 0.025, 0.035, mat); - guard.position.y = -0.09; - const blade = box(0.045, 0.72, 0.012, mat); - blade.position.y = -0.46; - weapon.add(grip, guard, blade); - group.add(weapon); - attachments.push({ - object: weapon, - bone: "wrist_right", - offset: new THREE.Vector3(0, -0.075, 0), - rotation: new THREE.Quaternion(), - }); - } else if (type === "gun") { - const weapon = new THREE.Group(); - const body = box(0.055, 0.09, 0.28, mat); - body.position.z = 0.12; - const handle = box(0.05, 0.16, 0.07, mat); - handle.position.set(0, -0.1, 0.02); - weapon.add(body, handle); - group.add(weapon); - attachments.push({ - object: weapon, - bone: "wrist_right", - offset: new THREE.Vector3(0, -0.035, 0.04), - rotation: new THREE.Quaternion(), - }); } } - return { group, anchors, attachments }; -} - -/** Follow final solved driver-bone transforms with held props. */ -export function syncPropAttachments(scene: PropScene, bones: Map): void { - const p = new THREE.Vector3(); - const q = new THREE.Quaternion(); - for (const attachment of scene.attachments) { - const bone = bones.get(attachment.bone); - if (!bone) continue; - bone.getWorldPosition(p); - bone.getWorldQuaternion(q); - attachment.object.position.copy(attachment.offset).applyQuaternion(q).add(p); - attachment.object.quaternion.copy(q).multiply(attachment.rotation); - } - scene.group.updateMatrixWorld(true); + return { group, anchors }; } function box(w: number, h: number, d: number, mat: THREE.Material): THREE.Mesh { diff --git a/packages/posecode-render/src/squad.ts b/packages/posecode-render/src/squad.ts new file mode 100644 index 0000000..da77928 --- /dev/null +++ b/packages/posecode-render/src/squad.ts @@ -0,0 +1,88 @@ +/** + * Spherical-quadrangle (squad) quaternion interpolation — Shoemake's C1 + * quaternion spline. Given a keyframe and its two neighbors, `squadControl` + * derives the intermediate control quaternion; `squad` blends one segment. + * + * All functions return NEW quaternions (or write into a caller `out`); the + * shared keyframe quaternions are never mutated. + */ + +import * as THREE from "three"; + +/** Ensure `b` is in the same hemisphere as `a` (shortest-path continuity). */ +function alignHemisphere(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion { + const out = b.clone(); + if (a.dot(out) < 0) out.set(-out.x, -out.y, -out.z, -out.w); + return out; +} + +/** q^-1 for a UNIT quaternion is its conjugate. */ +function conjugate(q: THREE.Quaternion): THREE.Quaternion { + return new THREE.Quaternion(-q.x, -q.y, -q.z, q.w); +} + +/** Natural log of a unit quaternion → a pure quaternion (w = 0). */ +function logUnit(q: THREE.Quaternion): THREE.Quaternion { + const v = new THREE.Vector3(q.x, q.y, q.z); + const vLen = v.length(); + const w = THREE.MathUtils.clamp(q.w, -1, 1); + if (vLen < 1e-8) return new THREE.Quaternion(0, 0, 0, 0); + const theta = Math.atan2(vLen, w); + const k = theta / vLen; + return new THREE.Quaternion(v.x * k, v.y * k, v.z * k, 0); +} + +/** Exp of a pure quaternion (w = 0) → a unit quaternion. */ +function expPure(q: THREE.Quaternion): THREE.Quaternion { + const v = new THREE.Vector3(q.x, q.y, q.z); + const theta = v.length(); + if (theta < 1e-8) return new THREE.Quaternion(0, 0, 0, 1); + const s = Math.sin(theta) / theta; + return new THREE.Quaternion(v.x * s, v.y * s, v.z * s, Math.cos(theta)); +} + +function mul(a: THREE.Quaternion, b: THREE.Quaternion): THREE.Quaternion { + return a.clone().multiply(b); +} + +/** + * Shoemake control quaternion for `cur`: + * s = cur * exp( -( log(cur^-1 * next) + log(cur^-1 * prev) ) / 4 ) + * Neighbors are hemisphere-aligned to `cur` first for shortest-path continuity. + */ +export function squadControl( + prev: THREE.Quaternion, + cur: THREE.Quaternion, + next: THREE.Quaternion, +): THREE.Quaternion { + const p = alignHemisphere(cur, prev); + const n = alignHemisphere(cur, next); + const inv = conjugate(cur); + const logNext = logUnit(mul(inv, n)); + const logPrev = logUnit(mul(inv, p)); + const sum = new THREE.Quaternion( + -(logNext.x + logPrev.x) / 4, + -(logNext.y + logPrev.y) / 4, + -(logNext.z + logPrev.z) / 4, + 0, + ); + return mul(cur, expPure(sum)).normalize(); +} + +/** + * Squad blend of one segment: slerp(slerp(q0,q1,t), slerp(s0,s1,t), 2t(1-t)). + * Endpoints `q0`,`q1`; their controls `s0`,`s1`. Returns q0 at t=0, q1 at t=1. + */ +export function squad( + q0: THREE.Quaternion, + s0: THREE.Quaternion, + s1: THREE.Quaternion, + q1: THREE.Quaternion, + t: number, + out: THREE.Quaternion = new THREE.Quaternion(), +): THREE.Quaternion { + const q1a = alignHemisphere(q0, q1); + const a = new THREE.Quaternion().slerpQuaternions(q0, q1a, t); + const b = new THREE.Quaternion().slerpQuaternions(s0, alignHemisphere(s0, s1), t); + return out.slerpQuaternions(a, alignHemisphere(a, b), 2 * t * (1 - t)); +} diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts index 120bfd7..e1c1588 100644 --- a/packages/posecode-render/src/timeline.ts +++ b/packages/posecode-render/src/timeline.ts @@ -8,23 +8,24 @@ */ import * as THREE from "three"; -import type { PosecodeIR, ReachTarget, PinTarget } from "posecode-parser"; +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; type EulerDegTuple = [number, number, number]; -type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out"; interface Keyframe { time: number; name: string; cue?: string; - easing: Easing; + easing: TimingMode; quats: Map; groundLock: string[]; reaches: ReachTarget[]; pins: PinTarget[]; + grips: GripTarget[]; /** Root facing (yaw about world Y, radians) at this keyframe. */ yaw: number; /** Root ground offset (world X/Z metres) from the load spot at this keyframe. */ @@ -56,6 +57,7 @@ export interface BuiltTimeline { groundLock: string[]; reaches: ReachTarget[]; pins: PinTarget[]; + grips: GripTarget[]; /** Interpolated root facing (yaw about world Y, radians). */ rootYaw: number; /** Interpolated root ground offset (world X/Z metres) from the load spot. */ @@ -71,14 +73,27 @@ function eulerToQuat([x, y, z]: EulerDegTuple): THREE.Quaternion { ); } -const EASE: Record number> = { +/** + * Per-mode remap of the normalized segment parameter (arrival shaping). `flow` + * and `linear` are even; `settle`/`snap` decelerate into rest; `drive` + * accelerates from rest. The spline (squad) carries velocity across keyframes; + * this only shapes the timing within a segment. + */ +const MODE_EASE: Record number> = { + flow: (t) => t, + settle: (t) => 1 - (1 - t) * (1 - t), + drive: (t) => t * t, + snap: (t) => 1 - (1 - t) * (1 - t) * (1 - t), linear: (t) => t, - // Cubic one-sided eases reduce the acceleration discontinuity of the old - // quadratic curves. Smootherstep is C2-continuous at both endpoints, which - // removes the visible "step" as a phase changes direction or contact mode. - "ease-in": (t) => t * t * t, - "ease-out": (t) => 1 - (1 - t) * (1 - t) * (1 - t), - "ease-in-out": (t) => t * t * t * (t * (t * 6 - 15) + 10), +}; + +/** A keyframe is a rest-point (zero boundary velocity) for these modes. */ +const REST_MODE: Record = { + flow: false, + settle: true, + drive: false, + snap: true, + linear: false, }; export function buildTimeline(ir: PosecodeIR): BuiltTimeline { @@ -99,11 +114,12 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { keyframes.push({ time: 0, name: ir.startPose ?? "start", - easing: "linear", + easing: "flow", quats: snapshot(curr), groundLock: [], reaches: [], pins: [], + grips: [], yaw: 0, pos: { x: 0, z: 0 }, }); @@ -126,6 +142,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { groundLock: phase.groundLock, reaches: phase.reaches, pins: phase.pins, + grips: phase.grips, yaw: currYaw * DEG, pos: { ...currPos }, }); @@ -142,11 +159,12 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { keyframes.push({ time: t, name: "reset", - easing: "ease-in-out", + easing: "flow", quats: snapshot(new Map(baseJoints)), groundLock: [], reaches: [], pins: [], + grips: [], yaw: wrapYaw, pos: { x: 0, z: 0 }, }); @@ -182,23 +200,40 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { travelExtent, sample(time, bones) { const tt = duration > 0 ? ((time % duration) + duration) % duration : 0; + let i = 0; let a = keyframes[0]!; let b = keyframes[keyframes.length - 1]!; - for (let i = 0; i < keyframes.length - 1; i++) { - if (tt >= keyframes[i]!.time && tt < keyframes[i + 1]!.time) { - a = keyframes[i]!; - b = keyframes[i + 1]!; + for (let k = 0; k < keyframes.length - 1; k++) { + if (tt >= keyframes[k]!.time && tt < keyframes[k + 1]!.time) { + i = k; + a = keyframes[k]!; + b = keyframes[k + 1]!; break; } } const span = Math.max(1e-6, b.time - a.time); const local = THREE.MathUtils.clamp((tt - a.time) / span, 0, 1); - const eased = EASE[b.easing](local); + 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). + 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; - node.quaternion.slerpQuaternions(a.quats.get(bone)!, b.quats.get(bone)!, eased); + 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 = REST_MODE[a.easing] + ? q0.clone() + : squadControl(kPrev.quats.get(bone)!, q0, q1); + const s1 = REST_MODE[b.easing] + ? q1.clone() + : squadControl(q0, q1, kNext.quats.get(bone)!); + squad(q0, s0, s1, q1, eased, node.quaternion); } // Root facing/position: linear interpolation of the raw values so a large // turn (e.g. 360°) sweeps the whole way round rather than taking a short @@ -214,6 +249,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { groundLock: b.groundLock, reaches: b.reaches, pins: b.pins, + grips: b.grips, rootYaw, rootOffset, }; diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts new file mode 100644 index 0000000..6636290 --- /dev/null +++ b/packages/posecode-render/test/contacts.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { buildMannequin } from "../src/mannequin.js"; +import { levelPlantedFeet, relaxHands, swingArms, aimHead } from "../src/contacts.js"; +import { groundFigure } from "../src/groundlock.js"; + +const DEG = Math.PI / 180; + +/** World-space sole normal (ankle local -Y) for a foot. */ +function soleNormal(m: ReturnType, side: "left" | "right") { + const ankle = m.bones.get(`ankle_${side}`)!; + const q = ankle.getWorldQuaternion(new THREE.Quaternion()); + return new THREE.Vector3(0, -1, 0).applyQuaternion(q).normalize(); +} + +describe("levelPlantedFeet", () => { + it("levels a tilted planted foot so the sole faces down", () => { + const m = buildMannequin(); + // Tilt the leg so the foot pitches within the ankle's dorsiflexion range + // (deeper tilts legitimately lift the heel — capped by ROM), then plant it + // on the floor as the frame loop's ground-lock does before leveling. + m.bones.get("knee_left")!.rotation.x = 12 * DEG; + m.root.updateMatrixWorld(true); + groundFigure(m); + const tiltedDot = soleNormal(m, "left").dot(new THREE.Vector3(0, -1, 0)); + levelPlantedFeet(m, ["feet"]); + m.root.updateMatrixWorld(true); + const n = soleNormal(m, "left"); + // Leveling makes the sole face world-down (dot ~ 1), flatter than the tilt. + expect(n.dot(new THREE.Vector3(0, -1, 0))).toBeGreaterThan(0.98); + expect(n.dot(new THREE.Vector3(0, -1, 0))).toBeGreaterThan(tiltedDot); + }); + + it("leaves an authored-plantarflex foot on its toes", () => { + const m = buildMannequin(); + m.bones.get("ankle_left")!.rotation.x = 30 * DEG; // plantarflex (toe-down) + m.root.updateMatrixWorld(true); + const before = m.bones.get("ankle_left")!.quaternion.clone(); + levelPlantedFeet(m, ["feet"]); + expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-6); + }); + + it("does not touch a swing foot lifted off the floor", () => { + const m = buildMannequin(); + // Lift the foot well above the floor by bending the knee back and raising hip. + m.bones.get("hip_left")!.rotation.x = -60 * DEG; + m.root.position.y = 0.5; + m.root.updateMatrixWorld(true); + const before = m.bones.get("ankle_left")!.quaternion.clone(); + levelPlantedFeet(m, ["feet"]); + expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-3); + }); +}); + +describe("relaxHands (L4.1)", () => { + it("curls the fingers of an idle, un-authored hand into a natural rest", () => { + const m = buildMannequin(); + expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // flat at rest + relaxHands(m, new Set(), new Set()); + expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(0.1); + expect(m.bones.get("middle_right")!.rotation.x).toBeGreaterThan(0.1); + }); + + it("leaves a gripping hand for wrapGrip (skips grip sides)", () => { + const m = buildMannequin(); + relaxHands(m, new Set(["left"]), new Set()); + expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // untouched + expect(m.bones.get("index_right")!.rotation.x).toBeGreaterThan(0.1); // right relaxed + }); + + it("does not override an explicitly authored finger", () => { + const m = buildMannequin(); + m.bones.get("index_left")!.rotation.x = 1.4; // authored fist + relaxHands(m, new Set(), new Set(["index_left"])); + expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5); + }); +}); + +describe("swingArms (L4.2)", () => { + it("adds contralateral arm swing when a hip is flexed and the arm is free", () => { + const m = buildMannequin(); + // Flex the right hip forward (walking: right leg forward → left arm forward). + m.bones.get("hip_right")!.rotation.x = -0.6; + const before = m.bones.get("shoulder_left")!.rotation.x; + swingArms(m, new Set(), new Set()); + const after = m.bones.get("shoulder_left")!.rotation.x; + expect(Math.abs(after - before)).toBeGreaterThan(0.05); // swung + // swings the same sagittal direction as the contralateral hip (forward) + expect(Math.sign(after - before)).toBe(Math.sign(-0.6)); + }); + + it("respects an authored shoulder (no swing)", () => { + const m = buildMannequin(); + m.bones.get("hip_right")!.rotation.x = -0.6; + m.bones.get("shoulder_left")!.rotation.x = 0.9; // authored + swingArms(m, new Set(["shoulder_left"]), new Set()); + expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(0.9, 5); + }); + + it("skips a gripping side", () => { + const m = buildMannequin(); + m.bones.get("hip_right")!.rotation.x = -0.6; + const before = m.bones.get("shoulder_left")!.rotation.x; + swingArms(m, new Set(), new Set(["left"])); + expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5); + }); +}); + +describe("aimHead (L4.3 look-at)", () => { + it("turns the head toward a focus point (face +Z tracks the target)", () => { + const m = buildMannequin(); + m.root.updateMatrixWorld(true); + const head = m.bones.get("head")!; + const headPos = head.getWorldPosition(new THREE.Vector3()); + const focus = headPos.clone().add(new THREE.Vector3(0, 1.2, 0.6)); + const faceDir = () => + new THREE.Vector3(0, 0, 1) + .applyQuaternion(head.getWorldQuaternion(new THREE.Quaternion())) + .normalize(); + const want = focus.clone().sub(headPos).normalize(); + const before = faceDir().dot(want); + aimHead(m, focus); + m.root.updateMatrixWorld(true); + expect(faceDir().dot(want)).toBeGreaterThan(before); + }); + + it("clamps the look so the head never spins past its range", () => { + const m = buildMannequin(); + m.root.updateMatrixWorld(true); + const head = m.bones.get("head")!; + const headPos = head.getWorldPosition(new THREE.Vector3()); + const behind = headPos.clone().add(new THREE.Vector3(0, 0, -2)); + aimHead(m, behind); + const e = new THREE.Euler().setFromQuaternion(m.bones.get("head")!.quaternion, "XYZ"); + expect(Math.hypot(e.x, e.y, e.z)).toBeLessThan(1.2); + }); +}); diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts index 5992888..95ae7bd 100644 --- a/packages/posecode-render/test/render.test.ts +++ b/packages/posecode-render/test/render.test.ts @@ -4,9 +4,9 @@ import { buildMannequin } from "../src/mannequin.js"; import { buildTimeline } from "../src/timeline.js"; import { solveCCD } from "../src/ik.js"; import { poseFor } from "../src/poses.js"; -import { buildProps, syncPropAttachments } from "../src/props.js"; +import { buildProps } from "../src/props.js"; import { applyGroundLock, groundFigure } from "../src/groundlock.js"; -import { alignBarGrips, alignFloorSoles } from "../src/contacts.js"; +import { levelPlantedFeet, wrapGrip } from "../src/contacts.js"; import { parse, eulerRomFor } from "posecode-parser"; const DEG = Math.PI / 180; @@ -399,59 +399,11 @@ describe("props", () => { const { anchors, group } = buildProps(["chair", "bar", "wall"]); expect(anchors.has("seat")).toBe(true); expect(anchors.has("bar")).toBe(true); - expect(anchors.has("bar.left")).toBe(true); - expect(anchors.has("bar.right")).toBe(true); - expect(anchors.get("bar.left")!.x).toBeGreaterThan(anchors.get("bar.right")!.x); expect(anchors.has("wall")).toBe(true); expect(anchors.get("bar")!.y).toBeGreaterThan(1.5); // overhead expect(group.children.length).toBeGreaterThan(0); }); - it("attaches held sword and gun props to the solved wrist socket", () => { - const props = buildProps(["sword", "gun"]); - const m = buildMannequin(); - m.root.position.set(0.4, 0.2, -0.3); - m.root.updateMatrixWorld(true); - syncPropAttachments(props, m.bones); - expect(props.attachments).toHaveLength(2); - const wrist = m.bones.get("wrist_right")!.getWorldPosition(new THREE.Vector3()); - for (const attachment of props.attachments) { - expect(attachment.object.position.distanceTo(wrist)).toBeLessThan(0.2); - } - }); - -}); - -describe("oriented contacts", () => { - it("keeps grounded soles flat and facing with the figure", () => { - const m = buildMannequin(); - m.bones.get("hip_left")!.rotation.x = -70 * DEG; - m.bones.get("knee_left")!.rotation.x = 90 * DEG; - m.root.rotation.y = 35 * DEG; - m.root.updateMatrixWorld(true); - alignFloorSoles(m, ["feet"]); - const q = m.bones.get("ankle_left")!.getWorldQuaternion(new THREE.Quaternion()); - const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q); - expect(up.y).toBeGreaterThan(0.999); - }); - - it("orients both palms into a stable overhand bar grip", () => { - const m = buildMannequin(); - m.root.updateMatrixWorld(true); - const pins = [ - { effector: "hand_left", anchor: "bar" }, - { effector: "hand_right", anchor: "bar" }, - ]; - alignBarGrips(m, [], pins); - for (const side of ["left", "right"] as const) { - const q = m.bones.get(`wrist_${side}`)!.getWorldQuaternion(new THREE.Quaternion()); - const localNormal = side === "left" - ? new THREE.Vector3(1, 0, 0) - : new THREE.Vector3(-1, 0, 0); - const normal = localNormal.applyQuaternion(q); - expect(normal.z).toBeGreaterThan(0.999); - } - }); }); describe("ccd ik", () => { @@ -752,3 +704,112 @@ describe("cobra", () => { expect(Math.abs(pelvisUp - pelvisFlat)).toBeLessThan(0.05); }); }); + +describe("spline interpolation (L2)", () => { + it("interpolates joints with continuous velocity through an interior keyframe", () => { + const src = [ + 'posecode exercise "flowy"', + " rig humanoid", + ' step "A" 1s flow:', + " shoulders: flex 40", + ' step "B" 1s flow:', + " shoulders: flex 120", + ' step "C" 1s flow:', + " shoulders: flex 40", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + const read = (t: number) => { + tl.sample(t, m.bones); + return m.bones.get("shoulder_left")!.quaternion.clone(); + }; + const eps = 1e-3; + const kf = 2; // end of "B" is an interior keyframe (t=2) + const vBefore = read(kf).angleTo(read(kf - eps)) / eps; + const vAfter = read(kf + eps).angleTo(read(kf)) / eps; + expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.3); // flow carries velocity + }); + + it("settle brings a joint to rest at its keyframe", () => { + const src = [ + 'posecode exercise "rest"', + " rig humanoid", + ' step "Down" 1s settle:', + " knees: flex 90", + ' step "Up" 1s drive:', + " knees: flex 0", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + const read = (t: number) => { + tl.sample(t, m.bones); + return m.bones.get("knee_left")!.quaternion.clone(); + }; + const eps = 1e-3; + const v = read(1).angleTo(read(1 - eps)) / eps; // velocity arriving at the settle kf + expect(v).toBeLessThan(0.2); + }); +}); + +describe("foot-flat correction (L3.1)", () => { + it("rests a squatting foot flatter on the floor (not on the toes)", () => { + const src = [ + 'posecode exercise "sq"', + " rig humanoid", + " pose start = standing", + ' step "Descend" 1s settle:', + " hips: flex 80", + " knees: flex 95", + " pelvis: hinge 25", + " ground-lock: feet", + ].join("\n"); + const { ir } = parse(src); + const tl = buildTimeline(ir!); + const m = buildMannequin(); + tl.sample(1, m.bones); + m.root.updateMatrixWorld(true); + groundFigure(m); + applyGroundLock(m, ["feet"]); + const soleDot = () => { + const ankle = m.bones.get("ankle_left")!; + return new THREE.Vector3(0, -1, 0) + .applyQuaternion(ankle.getWorldQuaternion(new THREE.Quaternion())) + .normalize() + .dot(new THREE.Vector3(0, -1, 0)); + }; + const before = soleDot(); + levelPlantedFeet(m, ["feet"]); + m.root.updateMatrixWorld(true); + // Leveling makes the sole markedly flatter than raw ground-lock leaves it. + expect(soleDot()).toBeGreaterThan(before); + expect(soleDot()).toBeGreaterThan(0.9); + }); +}); + +describe("bar grip (L3.2)", () => { + it("exposes two-point bar grip anchors shoulder-width apart", () => { + const { anchors } = buildProps(["bar"]); + const l = anchors.get("bar_left"); + const r = anchors.get("bar_right"); + expect(l).toBeDefined(); + expect(r).toBeDefined(); + expect(l!.x).toBeGreaterThan(0); // left hand grips the +X side + expect(r!.x).toBeLessThan(0); + expect(Math.abs(l!.x - r!.x)).toBeCloseTo(0.36, 2); + expect(l!.y).toBeCloseTo(r!.y, 5); // same bar height + }); + + it("wraps the fingers of a gripping hand into a curl", () => { + const m = buildMannequin(); + const restIndex = m.bones.get("index_left")!.rotation.x; + const restThumb = m.bones.get("thumb_left")!.rotation.x; + wrapGrip(m, [{ effector: "hand_left", anchor: "bar_left" }]); + expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(restIndex + 0.5); + expect(m.bones.get("middle_left")!.rotation.x).toBeGreaterThan(0.5); + expect(m.bones.get("thumb_left")!.rotation.x).toBeGreaterThan(restThumb + 0.3); + // the un-gripped right hand is untouched + expect(m.bones.get("index_right")!.rotation.x).toBeCloseTo(0, 5); + }); +}); diff --git a/packages/posecode-render/test/squad.test.ts b/packages/posecode-render/test/squad.test.ts new file mode 100644 index 0000000..0fddb69 --- /dev/null +++ b/packages/posecode-render/test/squad.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { squad, squadControl } from "../src/squad.js"; + +const q = (x: number, y: number, z: number) => + new THREE.Quaternion().setFromEuler(new THREE.Euler(x, y, z, "XYZ")); + +describe("squad", () => { + it("passes exactly through segment endpoints", () => { + const q0 = q(0, 0, 0); + const q1 = q(0, 1, 0); + const s0 = squadControl(q(0, -0.5, 0), q0, q1); + const s1 = squadControl(q0, q1, q(0, 1.5, 0)); + const at0 = squad(q0, s0, s1, q1, 0); + const at1 = squad(q0, s0, s1, q1, 1); + expect(at0.angleTo(q0)).toBeLessThan(1e-6); + expect(at1.angleTo(q1)).toBeLessThan(1e-6); + }); + + it("is C1-continuous across a shared interior keyframe (slerp is not)", () => { + const k0 = q(0, 0, 0); + const k1 = q(0, 1, 0); + const k2 = q(0, 1.2, 0.8); // direction change at k1 + const c_before = squadControl(k0, k1, k2); // control at k1 for both segs + const c0 = squadControl(q(0, -1, 0), k0, k1); // control at k0 + const c2 = squadControl(k1, k2, q(0, 0.4, 1.6)); // control at k2 + + const eps = 1e-3; + const before = squad(k0, c0, c_before, k1, 1 - eps); + const atK1a = squad(k0, c0, c_before, k1, 1); + const atK1b = squad(k1, c_before, c2, k2, 0); + const after = squad(k1, c_before, c2, k2, eps); + + const vBefore = atK1a.angleTo(before) / eps; + const vAfter = after.angleTo(atK1b) / eps; + expect(atK1a.angleTo(atK1b)).toBeLessThan(1e-6); // C0 + expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.15); // C1 within tolerance + }); + + it("falls back cleanly when neighbors are identical (no NaN)", () => { + const a = q(0, 0, 0); + const s = squadControl(a, a, a); + const mid = squad(a, s, s, a, 0.5); + expect(Number.isNaN(mid.x)).toBe(false); + expect(mid.angleTo(a)).toBeLessThan(1e-6); + }); +}); diff --git a/playground/src/main.ts b/playground/src/main.ts index 47fbccb..1b221b6 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -548,7 +548,18 @@ void import("posecode-render").then(({ createViewer }) => { // clips.ts). Only fetched when a loaded movement names the clip, so this // never slows the default page. Disabled with the classic figure, which has // no skinned mesh to retarget onto. - ...(classicFigure ? {} : { clips: { walk: "/clips/walk.fbx" } }), + ...(classicFigure + ? {} + : { + clips: { + walk: "/clips/walk.fbx", + squat: "/clips/back-squat.fbx", + "bicycle-crunch": "/clips/bicycle-crunch.fbx", + "jab-cross": "/clips/jab-cross.fbx", + "jumping-jacks": "/clips/jumping-jacks.fbx", + shuffling: "/clips/shuffling.fbx", + }, + }), }); // Exposed for capture/e2e tooling (frame capture drives README GIFs). (window as unknown as Record).__posecodeViewer = viewer; diff --git a/spec/examples/bicycle-crunch.posecode b/spec/examples/bicycle-crunch.posecode index 16fb155..3490708 100644 --- a/spec/examples/bicycle-crunch.posecode +++ b/spec/examples/bicycle-crunch.posecode @@ -1,5 +1,6 @@ posecode exercise "Bicycle crunch" rig humanoid + clip "bicycle-crunch" pose start = supine step "Set" 0.8s ease-in-out: diff --git a/spec/examples/dance-phrase.posecode b/spec/examples/dance-phrase.posecode index 2943b98..6216a99 100644 --- a/spec/examples/dance-phrase.posecode +++ b/spec/examples/dance-phrase.posecode @@ -2,7 +2,7 @@ posecode exercise "Dance phrase (8-count)" rig humanoid pose start = standing - step "1-2 - breath, arms to second" 2s ease-in-out: + step "1-2 - breath, arms to second" 2s flow: hips: rotate-out 25 shoulders: abduct 80 elbows: flex 16 @@ -10,7 +10,7 @@ posecode exercise "Dance phrase (8-count)" ground-lock: feet cue "Turn out, open the arms to second, lift through the crown" - step "3-4 - demi-plié" 2s ease-in-out: + step "3-4 - demi-plié" 2s flow: hips: flex 18 knees: flex 50 ankles: plantarflex 50 @@ -19,7 +19,7 @@ posecode exercise "Dance phrase (8-count)" ground-lock: feet cue "Bend the knees over the toes, arms round down through first" - step "5-6 - relevé, arms en haut" 2s ease-in-out: + step "5-6 - relevé, arms en haut" 2s flow: hips: flex 0 knees: flex 0 ankles: plantarflex 28 @@ -29,7 +29,7 @@ posecode exercise "Dance phrase (8-count)" ground-lock: feet cue "Press up to relevé and lift the arms into a frame overhead" - step "7-8 - close" 2s ease-out: + step "7-8 - close" 2s settle: ankles: plantarflex 0 hips: rotate-out 0 shoulders: flex 0 diff --git a/spec/examples/dead-hang.posecode b/spec/examples/dead-hang.posecode index 994dca0..8843a03 100644 --- a/spec/examples/dead-hang.posecode +++ b/spec/examples/dead-hang.posecode @@ -7,7 +7,6 @@ posecode exercise "Dead hang" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 ground-lock: feet cue "Reach up and grip the bar" @@ -15,8 +14,7 @@ posecode exercise "Dead hang" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 - pin: hands bar + grip: hands bar cue "Relax into a long, straight-arm hang" step "Down" 1.5s ease-out: diff --git a/spec/examples/hanging-knee-raise.posecode b/spec/examples/hanging-knee-raise.posecode index fbb10e9..a023bc5 100644 --- a/spec/examples/hanging-knee-raise.posecode +++ b/spec/examples/hanging-knee-raise.posecode @@ -7,7 +7,6 @@ posecode exercise "Hanging knee raise" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 ground-lock: feet cue "Reach up and grip the bar" @@ -15,9 +14,8 @@ posecode exercise "Hanging knee raise" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 - pin: hand_left bar - pin: hand_right bar + grip: hand_left bar + grip: hand_right bar cue "Hang from the bar overhead, arms long, body still" step "Raise knees" 1.2s ease-out: @@ -26,9 +24,8 @@ posecode exercise "Hanging knee raise" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 - pin: hand_left bar - pin: hand_right bar + grip: hand_left bar + grip: hand_right bar cue "Draw both knees up toward the chest" step "Lower" 1.4s ease-in: @@ -37,9 +34,8 @@ posecode exercise "Hanging knee raise" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 80 - pin: hand_left bar - pin: hand_right bar + grip: hand_left bar + grip: hand_right bar cue "Lower the legs under control, staying in a steady hang" step "Release" 0.8s ease-in: diff --git a/spec/examples/jab-cross.posecode b/spec/examples/jab-cross.posecode index d9ea3a2..3bf43c6 100644 --- a/spec/examples/jab-cross.posecode +++ b/spec/examples/jab-cross.posecode @@ -1,5 +1,6 @@ posecode exercise "Jab-cross" rig humanoid + clip "jab-cross" pose start = standing step "Jab" 0.4s ease-out: diff --git a/spec/examples/jumping-jacks.posecode b/spec/examples/jumping-jacks.posecode index 6656a02..9612439 100644 --- a/spec/examples/jumping-jacks.posecode +++ b/spec/examples/jumping-jacks.posecode @@ -1,5 +1,6 @@ posecode exercise "Jumping jacks" rig humanoid + clip "jumping-jacks" pose start = standing step "Out" 0.5s ease-out: diff --git a/spec/examples/pull-up.posecode b/spec/examples/pull-up.posecode index f5d500e..5d013ba 100644 --- a/spec/examples/pull-up.posecode +++ b/spec/examples/pull-up.posecode @@ -7,7 +7,6 @@ posecode exercise "Pull-up" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 20 ground-lock: feet cue "Reach up and grip the bar" @@ -15,42 +14,27 @@ posecode exercise "Pull-up" shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 55 - thumb_left: flex 35 - thumb_right: flex 35 - thumb_left: abduct 20 - thumb_right: abduct 20 - pin: hands bar + grip: hands bar cue "Hang from the bar, arms long, shoulders active" step "Pull up" 1.2s ease-out: shoulders: flex 105 elbows: flex 130 elbows: pronate 80 - fingers: flex 55 - thumb_left: flex 35 - thumb_right: flex 35 - thumb_left: abduct 20 - thumb_right: abduct 20 spine: extend 15 chest: extend 10 neck: extend 20 - pin: hands bar + grip: hands bar cue "Pull the chest toward the bar, driving the elbows down" step "Lower" 1.4s ease-in: shoulders: flex 175 elbows: flex 5 elbows: pronate 80 - fingers: flex 55 - thumb_left: flex 35 - thumb_right: flex 35 - thumb_left: abduct 20 - thumb_right: abduct 20 spine: extend 0 chest: extend 0 neck: extend 0 - pin: hands bar + grip: hands bar cue "Lower under control back to a full hang" step "Release" 0.8s ease-in: diff --git a/spec/examples/squat.posecode b/spec/examples/squat.posecode index 97db8f2..a4def3f 100644 --- a/spec/examples/squat.posecode +++ b/spec/examples/squat.posecode @@ -1,11 +1,11 @@ posecode exercise "Body-weight squat" rig humanoid + clip "squat" pose start = standing - step "Descend" 1.6s ease-in-out: + step "Descend" 1.6s settle: hips: flex 80 knees: flex 95 - ankles: dorsiflex 15 pelvis: hinge 25 spine: flex 0 shoulders: flex 70 @@ -13,10 +13,9 @@ posecode exercise "Body-weight squat" ground-lock: feet cue "Sit the hips back, chest proud, knees track over the toes" - step "Drive up" 1.2s ease-out: + step "Drive up" 1.2s drive: hips: flex 0 knees: flex 0 - ankles: plantarflex 0 pelvis: hinge 0 spine: flex 0 shoulders: flex 0