From 2b25abc2e4e5eb3ea48ecc44b74ca93726ebfcd2 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:11:19 +0300 Subject: [PATCH 01/25] docs: L2 spline-quaternion interpolation design spec --- ...26-07-11-l2-spline-interpolation-design.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-l2-spline-interpolation-design.md 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. From e46679a9ad4d51a09070bd0f4b29aef2d6c2bd90 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:18:49 +0300 Subject: [PATCH 02/25] docs: L2 implementation plan --- .../2026-07-11-l2-spline-interpolation.md | 813 ++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-l2-spline-interpolation.md 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. From c323b0136afd0461f0d89a3e28bdf121c26138a4 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:20:03 +0300 Subject: [PATCH 03/25] feat(render): add squad quaternion-spline helper --- packages/posecode-render/src/squad.ts | 88 +++++++++++++++++++++ packages/posecode-render/test/squad.test.ts | 47 +++++++++++ 2 files changed, 135 insertions(+) create mode 100644 packages/posecode-render/src/squad.ts create mode 100644 packages/posecode-render/test/squad.test.ts 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/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); + }); +}); From f69ccf0685ec08472bdb1a461a205560e321a7a1 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:22:37 +0300 Subject: [PATCH 04/25] feat(parser): timing modes with legacy easing aliases --- packages/posecode-parser/src/index.ts | 3 +- packages/posecode-parser/src/parser.ts | 16 ++++++-- packages/posecode-parser/src/schema.ts | 28 +++++++++++-- packages/posecode-parser/src/types.ts | 4 +- packages/posecode-parser/test/parse.test.ts | 44 ++++++++++++++++++--- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/packages/posecode-parser/src/index.ts b/packages/posecode-parser/src/index.ts index 8c58f1b..ee66845 100644 --- a/packages/posecode-parser/src/index.ts +++ b/packages/posecode-parser/src/index.ts @@ -34,6 +34,7 @@ export function parse(source: string): ParseResult { export type { Axis, Easing, + TimingMode, EulerDeg, JointTarget, ReachTarget, @@ -57,4 +58,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..9ad03ca 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; @@ -151,17 +152,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,7 +177,7 @@ export function parseToAst(source: string): ParseAstResult { current = { name: name.value, durationSec: parseDuration(dur.value), - easing, + easing: resolved.mode, targets: [], groundLock: [], reaches: [], diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts index a785b96..1329211 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,7 +57,7 @@ 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), diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index 0539c9d..83e026c 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 { diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 3b8c06e..b73c65a 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,37 @@ 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"); + }); +}); From c7a077bee16de6da042ef999165a7cc20df994de Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:24:21 +0300 Subject: [PATCH 05/25] feat(render): squad spline sampling with per-phase timing modes --- packages/posecode-render/src/timeline.ts | 63 +++++++++++++++----- packages/posecode-render/test/render.test.ts | 48 +++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts index 6d9585d..c572231 100644 --- a/packages/posecode-render/src/timeline.ts +++ b/packages/posecode-render/src/timeline.ts @@ -8,19 +8,19 @@ */ import * as THREE from "three"; -import type { PosecodeIR, ReachTarget, PinTarget } from "posecode-parser"; +import type { PosecodeIR, ReachTarget, PinTarget, 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[]; @@ -71,11 +71,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, - "ease-in": (t) => t * t, - "ease-out": (t) => 1 - (1 - t) * (1 - t), - "ease-in-out": (t) => t * t * (3 - 2 * 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, }; export function buildTimeline(ir: PosecodeIR): BuiltTimeline { @@ -96,7 +112,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { keyframes.push({ time: 0, name: ir.startPose ?? "start", - easing: "linear", + easing: "flow", quats: snapshot(curr), groundLock: [], reaches: [], @@ -139,7 +155,7 @@ 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: [], @@ -179,23 +195,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 diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts index 8eca00a..5ca036f 100644 --- a/packages/posecode-render/test/render.test.ts +++ b/packages/posecode-render/test/render.test.ts @@ -703,3 +703,51 @@ 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); + }); +}); From 7aa9654bb30288969bea2a0e839746ee14075ce7 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:26:43 +0300 Subject: [PATCH 06/25] feat(language): editor support for timing modes + deprecation hints --- .../vscode/syntaxes/posecode.tmLanguage.json | 2 +- packages/posecode-language/src/completion.ts | 4 +-- packages/posecode-language/src/diagnostics.ts | 19 +++++++++- packages/posecode-language/src/hover.ts | 6 ++-- packages/posecode-language/src/index.ts | 1 + packages/posecode-language/src/vocab.ts | 18 ++++++++-- .../posecode-language/test/language.test.ts | 35 +++++++++++++++++-- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/editors/vscode/syntaxes/posecode.tmLanguage.json b/editors/vscode/syntaxes/posecode.tmLanguage.json index 469eab2..a8e1cda 100644 --- a/editors/vscode/syntaxes/posecode.tmLanguage.json +++ b/editors/vscode/syntaxes/posecode.tmLanguage.json @@ -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-language/src/completion.ts b/packages/posecode-language/src/completion.ts index f6fa577..17e9b08 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, @@ -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..cc50d94 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"]; @@ -35,7 +42,12 @@ 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.", reach: diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index 4086bdf..a0bcf38 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,34 @@ 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"); + }); +}); From 1caacc9ea37b0e3f08dd7eea4e067ffc2f781f41 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:27:25 +0300 Subject: [PATCH 07/25] chore(eval): use TimingMode type for phase timing --- packages/posecode-eval/src/checks.ts | 2 +- packages/posecode-eval/src/probe.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts index 180601a..82698c6 100644 --- a/packages/posecode-eval/src/checks.ts +++ b/packages/posecode-eval/src/checks.ts @@ -161,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)`, }); } } diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index 89c2a3f..497ec23 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -13,7 +13,7 @@ */ 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, alignFloorPalms, @@ -31,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[]; From 6576d90900ddbc0cadf6df39e51f7783125baa19 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:30:55 +0300 Subject: [PATCH 08/25] feat: demo flow/settle/drive timing on dance-phrase and squat (L2) --- spec/examples/dance-phrase.posecode | 8 ++++---- spec/examples/squat.posecode | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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/squat.posecode b/spec/examples/squat.posecode index c328477..017022e 100644 --- a/spec/examples/squat.posecode +++ b/spec/examples/squat.posecode @@ -2,7 +2,7 @@ posecode exercise "Body-weight squat" rig humanoid pose start = standing - step "Descend" 1.6s ease-in-out: + step "Descend" 1.6s settle: hips: flex 80 knees: flex 95 ankles: plantarflex 50 @@ -13,7 +13,7 @@ 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 From be105f021277e1fba649a88ced1c658379eb1471 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:44:25 +0300 Subject: [PATCH 09/25] docs: L3.1 foot-flat correction design spec --- .../specs/2026-07-11-l3-1-foot-flat-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-l3-1-foot-flat-design.md 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. From d7bae8e8b09d0047f939ab682343f702151becab Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:45:35 +0300 Subject: [PATCH 10/25] docs: L3.1 foot-flat implementation plan --- .../plans/2026-07-11-l3-1-foot-flat.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-l3-1-foot-flat.md 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. From be3354f23fa573dd5a2b1576a5b56b0ddef0d564 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:48:43 +0300 Subject: [PATCH 11/25] =?UTF-8?q?feat(render):=20levelPlantedFeet=20?= =?UTF-8?q?=E2=80=94=20plantigrade=20foot-flat=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/posecode-render/src/contacts.ts | 70 ++++++++++++++++++- .../posecode-render/test/contacts.test.ts | 53 ++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/posecode-render/test/contacts.test.ts diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 9a5d231..a7ceb60 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -1,9 +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 } from "posecode-parser"; import type { Mannequin } from "./mannequin.js"; const DOWN = new THREE.Vector3(0, -1, 0); +const DEG = Math.PI / 180; /** Rotate contacting wrists so the palm face normal points into the floor. */ export function alignFloorPalms( @@ -35,3 +36,70 @@ export function alignFloorPalms( } 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(); + +/** + * 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 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 (changed) m.root.updateMatrixWorld(true); +} diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts new file mode 100644 index 0000000..6dd52f0 --- /dev/null +++ b/packages/posecode-render/test/contacts.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { buildMannequin } from "../src/mannequin.js"; +import { levelPlantedFeet } 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); + }); +}); From 8f855e4f12f637552f84d62ec8b13e83e40a9eda Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:49:57 +0300 Subject: [PATCH 12/25] feat(render): apply foot-flat in the viewer frame loop and on load --- packages/posecode-render/src/index.ts | 7 +++- packages/posecode-render/test/render.test.ts | 36 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index d9d61fe..239191d 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -27,7 +27,7 @@ import { type ClipSource, } from "./clips.js"; import { depenetrate } from "./depenetrate.js"; -import { alignFloorPalms } from "./contacts.js"; +import { alignFloorPalms, levelPlantedFeet } from "./contacts.js"; const DEG = Math.PI / 180; @@ -544,6 +544,10 @@ export function createViewer( // floor/landmark targets resolve against. applyReaches(info.reaches); alignFloorPalms(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); // 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 @@ -641,6 +645,7 @@ export function createViewer( mannequin.root.updateMatrixWorld(true); depenetrate(mannequin); groundFigureOf(mannequin); + levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []); captureGroundTargets(); baseRootPos.copy(mannequin.root.position); baseRootQuat.copy(mannequin.root.quaternion); diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts index 5ca036f..f041569 100644 --- a/packages/posecode-render/test/render.test.ts +++ b/packages/posecode-render/test/render.test.ts @@ -6,6 +6,7 @@ import { solveCCD } from "../src/ik.js"; import { poseFor } from "../src/poses.js"; import { buildProps } from "../src/props.js"; import { applyGroundLock, groundFigure } from "../src/groundlock.js"; +import { levelPlantedFeet } from "../src/contacts.js"; import { parse, eulerRomFor } from "posecode-parser"; const DEG = Math.PI / 180; @@ -751,3 +752,38 @@ describe("spline interpolation (L2)", () => { 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); + }); +}); From 180931095558b958199185ea341884fb295936d2 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 12:53:06 +0300 Subject: [PATCH 13/25] feat: squat rests flat via foot-flat; document ground-lock leveling (L3.1) --- packages/posecode-language/src/vocab.ts | 2 +- spec/examples/squat.posecode | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index cc50d94..fb5ae44 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -49,7 +49,7 @@ export const KEYWORD_DOCS: Record = { 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).", diff --git a/spec/examples/squat.posecode b/spec/examples/squat.posecode index 017022e..5699bf9 100644 --- a/spec/examples/squat.posecode +++ b/spec/examples/squat.posecode @@ -5,7 +5,6 @@ posecode exercise "Body-weight squat" step "Descend" 1.6s settle: hips: flex 80 knees: flex 95 - ankles: plantarflex 50 pelvis: hinge 25 spine: flex 0 shoulders: flex 70 @@ -16,7 +15,6 @@ posecode exercise "Body-weight squat" step "Drive up" 1.2s drive: hips: flex 0 knees: flex 0 - ankles: plantarflex 0 pelvis: hinge 0 spine: flex 0 shoulders: flex 0 From a588e2382baede18f3d60b9e7ee03f81c72f1575 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Sat, 11 Jul 2026 13:01:57 +0300 Subject: [PATCH 14/25] Switch playground and content links to clean /play routes --- scratch_capture_deadlift.js | 2 +- scripts/capture-gifs.mjs | 2 +- scripts/generate-content-pages.mjs | 5 ++--- scripts/lib/shell.mjs | 2 +- vercel.json | 9 ++++++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/scratch_capture_deadlift.js b/scratch_capture_deadlift.js index 85b43c9..98f8b87 100644 --- a/scratch_capture_deadlift.js +++ b/scratch_capture_deadlift.js @@ -34,7 +34,7 @@ async function capture() { const page = await browser.newPage({ viewport: { width: 1024, height: 768 } }); console.log("Navigating to Deadlift..."); - await page.goto(`${origin}/play.html#doc=deadlift`, { waitUntil: "load" }); + await page.goto(`${origin}/play/deadlift`, { waitUntil: "load" }); await page.reload({ waitUntil: "load" }); await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, { timeout: 30000 }); await page.waitForTimeout(2000); // let auto-camera settle diff --git a/scripts/capture-gifs.mjs b/scripts/capture-gifs.mjs index a699b74..ac5c96d 100644 --- a/scripts/capture-gifs.mjs +++ b/scripts/capture-gifs.mjs @@ -66,7 +66,7 @@ page.on("pageerror", (e) => console.error("[page]", e.message)); for (const t of targets) { const [w, h] = t.size; - await page.goto(`${origin}/play.html#doc=${t.id}`, { waitUntil: "load" }); + await page.goto(`${origin}/play/${t.id}`, { waitUntil: "load" }); await page.reload({ waitUntil: "load" }); await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, { timeout: 60000, diff --git a/scripts/generate-content-pages.mjs b/scripts/generate-content-pages.mjs index 9fefa69..b118633 100644 --- a/scripts/generate-content-pages.mjs +++ b/scripts/generate-content-pages.mjs @@ -87,7 +87,6 @@ async function main() { const steps = parseSteps(p.source); const repeat = parseRepeat(p.source); const name = slugTitle(p.label); - const hash = `#doc=${p.id}`; const url = `/moves/${p.id}.html`; const stepsHtml = steps @@ -129,7 +128,7 @@ async function main() { that LLMs like ChatGPT, Claude, and Gemini can write to describe human movement as text. Every joint angle below is hard-clamped to a safe range of motion.

- ▶ Open ${esc(name)} in the playground → + ▶ Open ${esc(name)} in the playground →

How to do it

    @@ -189,7 +188,7 @@ ${stepsHtml}

    Every example in the Posecode library, grouped by practice. Each page shows the phases, coaching cues, and the exact .posecode source, plus a live 3D playback. Prefer to search and filter interactively? Use the - playground's movement library instead.

    + playground's movement library instead.

    ${groupsHtml} `, }); diff --git a/scripts/lib/shell.mjs b/scripts/lib/shell.mjs index 0c9abd9..6718c26 100644 --- a/scripts/lib/shell.mjs +++ b/scripts/lib/shell.mjs @@ -140,7 +140,7 @@ export function pageShell({ title, description, canonicalPath, jsonLd, bodyHtml,
    Posecode