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
- Playground
+ Playground
Movement library
Language spec
LLM guide
diff --git a/vercel.json b/vercel.json
index f2d7049..62b7d5d 100644
--- a/vercel.json
+++ b/vercel.json
@@ -4,5 +4,12 @@
"installCommand": "npm install",
"buildCommand": "npm run build -w playground",
"outputDirectory": "playground/dist",
- "rewrites": [{ "source": "/play", "destination": "/play.html" }]
+ "redirects": [
+ { "source": "/index.html", "destination": "/", "permanent": true },
+ { "source": "/play.html", "destination": "/play", "permanent": true }
+ ],
+ "rewrites": [
+ { "source": "/play", "destination": "/play.html" },
+ { "source": "/play/:movement", "destination": "/play.html" }
+ ]
}
From cd5804cb771c639b2006acda021c280543392524 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 15:47:31 +0300
Subject: [PATCH 15/25] docs: L3.2 bar-grip system design spec
---
.../specs/2026-07-11-l3-2-bar-grip-design.md | 148 ++++++++++++++++++
1 file changed, 148 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md
diff --git a/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md b/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md
new file mode 100644
index 0000000..fee02e4
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-l3-2-bar-grip-design.md
@@ -0,0 +1,148 @@
+# L3.2 — Bar-Grip System (Design)
+
+**Date:** 2026-07-11
+**Status:** Approved (design)
+**Sub-project:** Layer 3, slice 2 of the animation-naturalness program
+**Branch:** `feat/l3-post-ik` (isolated worktree `/Users/aaaa/Developer/posecode-l3`)
+
+---
+
+## 1. Context and motivation
+
+Pull-up / dead-hang / hanging-knee-raise look broken today (diagnosed earlier):
+
+1. The `bar` prop exposes a **single centre anchor** `(0, barH, 0)` (`props.ts`). `pin: hands bar`
+ expands to two hand pins that `applyPins` **averages** into one body translation, so both
+ hands are driven toward bar-centre — they converge instead of gripping shoulder-width apart.
+2. `applyPins` only **translates the whole body**; it never bends the arm, so the hands sit
+ wherever the authored shoulder/elbow angles put them relative to the body — not on the bar.
+3. **Fingers never wrap** the bar; the flat open palm floats at it.
+
+**Goal:** a `grip` contact that makes each hand actually hold the bar — two shoulder-width grip
+points, per-hand arm IK so each wrist lands on its point while the body hangs/pulls, and a
+procedural finger wrap around the bar. Both sides improve: new `grip` DSL directive + editor
+support; render solvers; updated library moves.
+
+### Non-goals
+
+- Foot-flat (shipped in L3.1). Look-at (L4). Mocap clips (L1).
+
+---
+
+## 2. Approach
+
+### 2.1 Two-point bar anchors (`props.ts`)
+
+The `bar` prop gains `bar_left` and `bar_right` anchors at `(±GRIP_HALF, barH, 0)` with
+`GRIP_HALF ≈ 0.18` (shoulder-width grip). The existing centre `bar` anchor stays for
+back-compat. `dip-bars` already has per-rail geometry; its `bars` anchor is unchanged here.
+
+### 2.2 The `grip` DSL directive
+
+New step-child `grip: `, parsed exactly like `pin` (`parser.ts`), producing
+`GripTarget { effector, anchor }`. Resolution (`clamp.ts`) expands `hands` → `hand_left`,
+`hand_right` **and rewrites the anchor per side**: a bare anchor `bar` becomes `bar_left` for
+the left hand and `bar_right` for the right (if those side anchors are declared by the prop);
+a side-specific anchor is used verbatim. So `grip: hands bar` → `[{hand_left,bar_left},
+{hand_right,bar_right}]`. Stored on `Phase.grips`.
+
+### 2.3 The grip solve (render)
+
+`applyGrips(grips)` in `index.ts`, run in the frame loop where pins run (after ground-lock,
+before the floor clamp), does three things per the diagnosed fix:
+
+1. **Body translate (vertical pull):** like `applyPins`, translate the root by the average
+ (anchor − wrist) delta. Authored elbow flex raises the wrists toward the shoulders, so the
+ body rises to keep them at the bar — this is what produces the pull-up motion, and it is
+ preserved.
+2. **Per-hand arm IK (exact placement + natural angle):** for each grip, `solveCCD` on the arm
+ chain `[shoulder, elbow]` (ROM-clamped, reusing the viewer's `reachChain`/`jointLimitsFor`)
+ drives that wrist onto its bar anchor. This fixes the shoulder-width placement and angles the
+ arms naturally toward the grips instead of straight up. Limits are widened to admit the
+ authored angle, so IK closes the residual gap without fighting the pose.
+3. **Finger wrap** (see 2.4).
+
+### 2.4 Procedural finger wrap (`contacts.ts`)
+
+`wrapGrip(m, grips)` curls the fingers of each gripping hand around the bar. For each of the
+four fingers, rotate the knuckle bone about its flex axis by a curl angle derived from the bar
+radius and finger length so the fingertip closes onto the cylinder surface; the thumb opposes
+(curls from the other side). A single tunable `GRIP_CURL` base with per-finger scaling gives a
+believable wrap. This replaces the manual `fingers: flex …` / `thumb: …` lines the current
+pull-up hand-authored. Runs after the arm IK so the hand is already at the bar.
+
+### 2.5 `grip` vs `pin`
+
+`pin` stays for contacts that only translate the body (box step-up, chair dip, dip-bars
+support). `grip` is the bar/rail hold: two-point anchor + arm IK + finger wrap. Keeping them
+separate keeps each directive single-purpose and the editor guidance clear.
+
+---
+
+## 3. Components and boundaries
+
+- **`packages/posecode-parser`:** `types.ts` (`GripTarget`, `Phase.grips`); `parser.ts`
+ (`AstStep.grips`, parse `grip:` like `pin:`); `schema.ts` (grip array schema); `clamp.ts`
+ (expand effector + per-side anchor rewrite); `index.ts` (export `GripTarget`).
+- **`packages/posecode-render`:** `props.ts` (`bar_left`/`bar_right`); `contacts.ts`
+ (`wrapGrip`, `GRIP_CURL`); `index.ts` (`applyGrips`, wire into `frame()` + pass `info.grips`;
+ `timeline.ts` carries `grips` on keyframes / sample output).
+- **`packages/posecode-language` + `lsp`:** `vocab.ts` (`grip` in `CHILD_KEYWORDS`,
+ `KEYWORD_DOCS`); completion already offers child keywords; hover via `KEYWORD_DOCS`;
+ tmLanguage keyword; `REACH_EFFECTORS` reused for the effector completion after `grip:`.
+- **Docs:** `pull-up.posecode`, `dead-hang.posecode`, `hanging-knee-raise.posecode` switch
+ `pin: hands bar` → `grip: hands bar` and drop the manual finger lines.
+
+### Data flow
+
+```
+grip: hands bar
+ → parser AstStep.grips
+ → clamp: expand → [{hand_left,bar_left},{hand_right,bar_right}]
+ → Phase.grips → timeline keyframe → sample().grips
+ → frame(): applyGrips → body translate (pull) + per-hand arm IK (place) + wrapGrip (fingers)
+```
+
+## 4. Error handling
+
+- Unknown grip effector / anchor: line-anchored parse error (mirror pin/reach).
+- A `bar` anchor with no `bar_left`/`bar_right` declared (prop absent): fall back to the centre
+ `bar` anchor so a malformed doc still resolves rather than crashing.
+- Missing arm bones or unreachable target: `solveCCD` returns the closest ROM-safe pose (existing
+ behavior); the body translate still hangs the figure.
+
+## 5. Testing (TDD)
+
+Parser:
+1. `grip: hands bar` resolves to two per-side grips with `bar_left`/`bar_right` anchors.
+2. `grip: hand_left bar_left` verbatim; unknown effector errors with its line.
+
+Render:
+3. `props` bar exposes `bar_left`/`bar_right` at ±GRIP_HALF.
+4. After `applyGrips`, each wrist is within tolerance of its bar anchor (hands land shoulder-width
+ on the bar, not at centre).
+5. `wrapGrip` curls the finger bones (finger flex increases from rest) for a gripping hand.
+6. Existing pin/reach/foot-flat tests stay green.
+
+Editor:
+7. `grip` completes as a child keyword and hovers with its doc.
+
+Manual: browser-verify pull-up — hands grip the bar shoulder-width with wrapped fingers, body
+hangs below and rises on the pull; no console errors.
+
+## 6. Risks
+
+- **Arm IK vs authored pull:** IK could over-correct and flatten the pull motion. Mitigation: run
+ the body translate first (drives the rise), then IK ROM-clamped+widened to the authored angle,
+ so IK only closes the residual placement gap.
+- **Finger wrap tuning:** a fixed curl may over/under-close for the bar radius. Mitigation: derive
+ curl from bar radius; keep `GRIP_CURL` a named constant tuned against the live pull-up.
+- **`grips` plumbed through timeline:** mirror exactly how `pins` already flow so no sampling path
+ is missed.
+
+## 7. Definition of done
+
+- `grip` parses/resolves to two-point side anchors; render places both hands on the bar with arm
+ IK and wraps the fingers; editor supports `grip`.
+- pull-up / dead-hang / hanging-knee-raise use `grip`; browser-verified hands grip the bar.
+- All suites green; typecheck clean.
From 14793b6f5c2360898c3ae1715475f2b16cfd3dfc Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 15:51:41 +0300
Subject: [PATCH 16/25] feat(parser): grip directive with two-point side-anchor
resolution
---
packages/posecode-parser/src/clamp.ts | 30 ++++++++++++++
packages/posecode-parser/src/index.ts | 1 +
packages/posecode-parser/src/parser.ts | 16 ++++++++
packages/posecode-parser/src/schema.ts | 1 +
packages/posecode-parser/src/types.ts | 13 ++++++
packages/posecode-parser/test/parse.test.ts | 45 +++++++++++++++++++++
6 files changed, 106 insertions(+)
diff --git a/packages/posecode-parser/src/clamp.ts b/packages/posecode-parser/src/clamp.ts
index 9e4327d..1bc0923 100644
--- a/packages/posecode-parser/src/clamp.ts
+++ b/packages/posecode-parser/src/clamp.ts
@@ -9,6 +9,7 @@
import type {
EulerDeg,
+ GripTarget,
JointTarget,
PosecodeIR,
ParseError,
@@ -146,6 +147,21 @@ function resolveStep(
for (const effector of sides) pins.push({ effector, anchor: p.anchor });
}
+ // Grip contacts: like pins, but each hand gets its OWN two-point anchor. A
+ // bare anchor (`bar`) is rewritten per side to `bar_left`/`bar_right` so the
+ // two hands grip shoulder-width apart; an already-sided anchor is kept as-is.
+ const grips: GripTarget[] = [];
+ for (const g of step.grips) {
+ const sides = expandEffector(g.effector);
+ if (sides.length === 0) {
+ errors.push({ line: g.line, message: `unknown grip effector: "${g.effector}"` });
+ continue;
+ }
+ for (const effector of sides) {
+ grips.push({ effector, anchor: sideAnchor(g.anchor, effector) });
+ }
+ }
+
// Travel is clamped to a sane studio footprint (±TRAVEL_MAX m) so a stray
// large value can't fling the figure off the ground plane / out of frame.
const travel = step.travel
@@ -163,6 +179,7 @@ function resolveStep(
groundLock: step.groundLock,
reaches,
pins,
+ grips,
...(step.turn !== undefined ? { turnDeg: step.turn } : {}),
...(travel ? { travel } : {}),
...(step.cue ? { cue: step.cue } : {}),
@@ -176,6 +193,19 @@ function clampNum(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
+/**
+ * Rewrite a grip anchor to the effector's side: a bare anchor (`bar`) becomes
+ * `bar_left`/`bar_right` so two hands grip shoulder-width apart. An anchor that
+ * is already sided, or an effector without a side, is returned unchanged. The
+ * renderer falls back to the bare anchor if a sided one isn't declared.
+ */
+function sideAnchor(anchor: string, effector: string): string {
+ if (/_(left|right)$/.test(anchor)) return anchor;
+ if (effector.endsWith("_left")) return `${anchor}_left`;
+ if (effector.endsWith("_right")) return `${anchor}_right`;
+ return anchor;
+}
+
function ensure(map: Map, bone: string): EulerDeg {
let euler = map.get(bone);
if (!euler) {
diff --git a/packages/posecode-parser/src/index.ts b/packages/posecode-parser/src/index.ts
index ee66845..ff457bd 100644
--- a/packages/posecode-parser/src/index.ts
+++ b/packages/posecode-parser/src/index.ts
@@ -39,6 +39,7 @@ export type {
JointTarget,
ReachTarget,
PinTarget,
+ GripTarget,
Phase,
PosecodeIR,
Warning,
diff --git a/packages/posecode-parser/src/parser.ts b/packages/posecode-parser/src/parser.ts
index 9ad03ca..eb09014 100644
--- a/packages/posecode-parser/src/parser.ts
+++ b/packages/posecode-parser/src/parser.ts
@@ -37,6 +37,7 @@ export interface AstStep {
groundLock: string[];
reaches: AstReach[];
pins: AstPin[];
+ grips: AstPin[];
/** Root facing (yaw about world Y, degrees) at the end of this phase. */
turn?: number;
/** Root ground position (world X/Z metres) at the end of this phase. */
@@ -182,6 +183,7 @@ export function parseToAst(source: string): ParseAstResult {
groundLock: [],
reaches: [],
pins: [],
+ grips: [],
line: ln.line,
};
doc.steps.push(current);
@@ -247,6 +249,20 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
return null;
}
+ if (head === "grip") {
+ // `grip: `: hold a bar/rail — arm IK to a two-point
+ // anchor + finger wrap. Parsed exactly like `pin`; the side-anchor rewrite
+ // happens in resolution.
+ if (!current) return { line: ln.line, message: "`grip` outside of a step" };
+ const effector = t[2]?.type === "word" ? t[2].value : null;
+ const anchor = t[3]?.type === "word" ? t[3].value : null;
+ if (t[1]?.type !== "colon" || !effector || !anchor) {
+ return { line: ln.line, message: "expected `grip: `" };
+ }
+ current.grips.push({ effector, anchor, line: ln.line });
+ return null;
+ }
+
if (head === "turn") {
// `turn: `: the figure's facing (root yaw about world Y) at the
// end of this phase. Absolute, accumulated forward like a joint target.
diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts
index 1329211..4ced7d1 100644
--- a/packages/posecode-parser/src/schema.ts
+++ b/packages/posecode-parser/src/schema.ts
@@ -62,6 +62,7 @@ const stepSchema = z.object({
groundLock: z.array(z.string()),
reaches: z.array(reachSchema),
pins: z.array(pinSchema),
+ grips: z.array(pinSchema),
turn: z.number().optional(),
travel: z.object({ x: z.number(), z: z.number() }).optional(),
cue: z.string().optional(),
diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts
index 83e026c..c4f3b0d 100644
--- a/packages/posecode-parser/src/types.ts
+++ b/packages/posecode-parser/src/types.ts
@@ -49,6 +49,17 @@ export interface PinTarget {
anchor: string;
}
+/**
+ * A grip contact: a hand holds a bar/rail. Unlike a pin (which only translates
+ * the body to an anchor), a grip also bends the arm via IK so each hand lands on
+ * its own two-point anchor (`bar_left`/`bar_right`) and wraps the fingers around
+ * the bar. Powers pull-up, dead-hang, hanging knee raise.
+ */
+export interface GripTarget {
+ effector: string;
+ anchor: string;
+}
+
/** One concurrent phase of a movement (e.g. "Lower" in a push-up). */
export interface Phase {
name: string;
@@ -61,6 +72,8 @@ export interface Phase {
reaches: ReachTarget[];
/** Contact pins active during this phase (translate the body to the anchor). */
pins: PinTarget[];
+ /** Grip contacts active this phase (arm IK to a two-point bar anchor + finger wrap). */
+ grips: GripTarget[];
/**
* Root facing (yaw about world Y, degrees) at the end of this phase, an
* absolute target carried forward across phases. Powers turns / pirouettes.
diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts
index b73c65a..9e9838e 100644
--- a/packages/posecode-parser/test/parse.test.ts
+++ b/packages/posecode-parser/test/parse.test.ts
@@ -301,3 +301,48 @@ describe("timing modes", () => {
expect(errors[0]!.message.toLowerCase()).toContain("mode");
});
});
+
+describe("grip directive", () => {
+ it("resolves `grip: hands bar` to two per-side grips with sided anchors", () => {
+ const src = [
+ 'posecode exercise "Pull-up"',
+ " rig humanoid",
+ " prop bar",
+ " pose start = standing",
+ ' step "Hang" 1s flow:',
+ " grip: hands bar",
+ ].join("\n");
+ const { ir, errors } = parse(src);
+ expect(errors).toEqual([]);
+ expect(ir!.phases[0]!.grips).toEqual([
+ { effector: "hand_left", anchor: "bar_left" },
+ { effector: "hand_right", anchor: "bar_right" },
+ ]);
+ });
+
+ it("keeps a side-specific grip anchor verbatim", () => {
+ const src = [
+ 'posecode exercise "One-arm"',
+ " rig humanoid",
+ " prop bar",
+ ' step "Hang" 1s flow:',
+ " grip: hand_left bar_left",
+ ].join("\n");
+ const { ir, errors } = parse(src);
+ expect(errors).toEqual([]);
+ expect(ir!.phases[0]!.grips).toEqual([{ effector: "hand_left", anchor: "bar_left" }]);
+ });
+
+ it("errors on an unknown grip effector with its line", () => {
+ const src = [
+ 'posecode exercise "Bad"',
+ " rig humanoid",
+ ' step "Hang" 1s flow:',
+ " grip: tentacle bar",
+ ].join("\n");
+ const { errors } = parse(src);
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0]!.line).toBe(4);
+ expect(errors[0]!.message).toContain("tentacle");
+ });
+});
From 9a8f6da573811cf5bdc60acbdad5bc0449330f6a Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 15:59:23 +0300
Subject: [PATCH 17/25] =?UTF-8?q?feat(render):=20bar=20grip=20solve=20?=
=?UTF-8?q?=E2=80=94=20two-point=20anchors,=20arm=20IK,=20finger=20wrap?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/posecode-eval/src/probe.ts | 2 +-
packages/posecode-render/src/contacts.ts | 36 +++++++++++++-
packages/posecode-render/src/index.ts | 49 +++++++++++++++++++-
packages/posecode-render/src/props.ts | 6 +++
packages/posecode-render/src/timeline.ts | 8 +++-
packages/posecode-render/test/render.test.ts | 28 ++++++++++-
6 files changed, 123 insertions(+), 6 deletions(-)
diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts
index 497ec23..a31513d 100644
--- a/packages/posecode-eval/src/probe.ts
+++ b/packages/posecode-eval/src/probe.ts
@@ -176,7 +176,7 @@ export function probeMovement(source: string): ProbeResult {
reaches: [...info.reaches],
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
rootYaw: info.rootYaw,
- usesSceneIk: info.pins.length > 0 || info.reaches.length > 0,
+ usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
bones: snapshotBones(m.bones),
boneQuaternions: snapshotBoneQuaternions(m.bones),
};
diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts
index a7ceb60..7d11e79 100644
--- a/packages/posecode-render/src/contacts.ts
+++ b/packages/posecode-render/src/contacts.ts
@@ -1,6 +1,6 @@
/** Semantic contact-orientation helpers shared by viewer and eval. */
import * as THREE from "three";
-import { eulerRomFor, type PinTarget, type ReachTarget } from "posecode-parser";
+import { eulerRomFor, type PinTarget, type ReachTarget, type GripTarget } from "posecode-parser";
import type { Mannequin } from "./mannequin.js";
const DOWN = new THREE.Vector3(0, -1, 0);
@@ -103,3 +103,37 @@ export function levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string
}
if (changed) m.root.updateMatrixWorld(true);
}
+
+/** Finger curl (radians about the knuckle X axis) that wraps a gripping hand. */
+export const FINGER_CURL = 1.35;
+/** Thumb opposition curl (radians) toward the fingers. */
+export const THUMB_CURL = 0.9;
+const FINGERS = ["index", "middle", "ring", "pinky"] as const;
+
+/**
+ * Curl the fingers of each gripping hand around the bar. Grips are per-side
+ * after resolution (`hand_left` / `hand_right`), so the side comes straight off
+ * the effector. The four fingers flex at the knuckle and the thumb opposes,
+ * turning the open reach pose into a closed grip on the bar.
+ */
+export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
+ let changed = false;
+ for (const g of grips) {
+ const side = /_(left|right)$/.exec(g.effector)?.[1];
+ if (!side) continue;
+ for (const f of FINGERS) {
+ const bone = m.bones.get(`${f}_${side}`);
+ if (bone) {
+ bone.rotation.set(FINGER_CURL, 0, 0);
+ changed = true;
+ }
+ }
+ const thumb = m.bones.get(`thumb_${side}`);
+ if (thumb) {
+ // Thumb wraps from the opposite side: curl plus a sideways opposition.
+ thumb.rotation.set(THUMB_CURL, 0, side === "left" ? -THUMB_CURL : THUMB_CURL);
+ changed = true;
+ }
+ }
+ if (changed) m.root.updateMatrixWorld(true);
+}
diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts
index 239191d..0f78ab5 100644
--- a/packages/posecode-render/src/index.ts
+++ b/packages/posecode-render/src/index.ts
@@ -12,7 +12,7 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { eulerRomFor } from "posecode-parser";
-import type { PosecodeIR, ReachTarget, PinTarget } from "posecode-parser";
+import type { PosecodeIR, ReachTarget, PinTarget, GripTarget } from "posecode-parser";
import { buildMannequin, type Mannequin } from "./mannequin.js";
import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } from "./groundlock.js";
import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js";
@@ -27,7 +27,7 @@ import {
type ClipSource,
} from "./clips.js";
import { depenetrate } from "./depenetrate.js";
-import { alignFloorPalms, levelPlantedFeet } from "./contacts.js";
+import { alignFloorPalms, levelPlantedFeet, wrapGrip } from "./contacts.js";
const DEG = Math.PI / 180;
@@ -482,6 +482,50 @@ export function createViewer(
}
}
+ /**
+ * Bar grips: unlike a pin (body translate only), a grip makes each hand hold
+ * the bar. (1) Translate the body by the average wrist→anchor delta — the
+ * authored elbow flex raises the wrists, so the body rises: the pull-up. (2)
+ * Per-hand arm IK drives each wrist exactly onto its own two-point anchor
+ * (`bar_left`/`bar_right`), so the hands grip shoulder-width and the arms angle
+ * naturally instead of pointing straight up. (3) Wrap the fingers round the bar.
+ */
+ function applyGrips(grips: GripTarget[]): void {
+ if (grips.length === 0) return;
+ const resolveGrip = (anchor: string, effector: THREE.Object3D): THREE.Vector3 | null =>
+ resolveReachTarget(anchor, effector) ??
+ resolveReachTarget(anchor.replace(/_(left|right)$/, ""), effector);
+ // 1. Body translate (the vertical pull).
+ const delta = new THREE.Vector3();
+ let n = 0;
+ for (const g of grips) {
+ const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector;
+ const effector = mannequin.bones.get(effectorBone);
+ if (!effector) continue;
+ const target = resolveGrip(g.anchor, effector);
+ if (!target) continue;
+ delta.add(target.clone().sub(effector.getWorldPosition(new THREE.Vector3())));
+ n++;
+ }
+ if (n > 0) {
+ mannequin.root.position.add(delta.multiplyScalar(1 / n));
+ mannequin.root.updateMatrixWorld(true);
+ }
+ // 2. Per-hand arm IK onto each grip point (ROM-clamped via reachChain).
+ for (const g of grips) {
+ const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector;
+ const effector = mannequin.bones.get(effectorBone);
+ if (!effector) continue;
+ const target = resolveGrip(g.anchor, effector);
+ if (!target) continue;
+ const { joints, limits } = reachChain(effectorBone);
+ if (joints.length === 0) continue;
+ solveCCD({ joints, limits, effector, target }, 12);
+ }
+ // 3. Finger wrap.
+ wrapGrip(mannequin, grips);
+ }
+
function frameCamera(): void {
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
// Include any scene prop too: a pull-up bar sits well above the figure's
@@ -535,6 +579,7 @@ export function createViewer(
depenetrate(mannequin);
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
applyPins(info.pins);
+ applyGrips(info.grips);
// Reach-IK BEFORE the floor safety clamp. When authored FK pushes a
// reaching limb through the floor (cobra: prone + shoulders flex 50),
// the limb must bend to meet the floor. Running reaches after the clamp
diff --git a/packages/posecode-render/src/props.ts b/packages/posecode-render/src/props.ts
index bad7815..3ff3463 100644
--- a/packages/posecode-render/src/props.ts
+++ b/packages/posecode-render/src/props.ts
@@ -54,7 +54,13 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
post.position.set(x, barH / 2, 0);
group.add(post);
}
+ // Centre anchor (back-compat) plus two shoulder-width grip points so a
+ // `grip: hands bar` lands each hand on its own spot instead of both at
+ // centre. GRIP_HALF ≈ half a shoulder width.
+ const GRIP_HALF = 0.18;
anchors.set("bar", new THREE.Vector3(0, barH, 0));
+ anchors.set("bar_left", new THREE.Vector3(GRIP_HALF, barH, 0));
+ anchors.set("bar_right", new THREE.Vector3(-GRIP_HALF, barH, 0));
} else if (type === "wall") {
const wall = box(2.2, 2.6, 0.1, mat);
wall.position.set(0, 1.3, -0.34);
diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts
index c572231..e1c1588 100644
--- a/packages/posecode-render/src/timeline.ts
+++ b/packages/posecode-render/src/timeline.ts
@@ -8,7 +8,7 @@
*/
import * as THREE from "three";
-import type { PosecodeIR, ReachTarget, PinTarget, TimingMode } from "posecode-parser";
+import type { PosecodeIR, ReachTarget, PinTarget, GripTarget, TimingMode } from "posecode-parser";
import { poseFor, type PoseSpec } from "./poses.js";
import { squad, squadControl } from "./squad.js";
@@ -25,6 +25,7 @@ interface Keyframe {
groundLock: string[];
reaches: ReachTarget[];
pins: PinTarget[];
+ grips: GripTarget[];
/** Root facing (yaw about world Y, radians) at this keyframe. */
yaw: number;
/** Root ground offset (world X/Z metres) from the load spot at this keyframe. */
@@ -56,6 +57,7 @@ export interface BuiltTimeline {
groundLock: string[];
reaches: ReachTarget[];
pins: PinTarget[];
+ grips: GripTarget[];
/** Interpolated root facing (yaw about world Y, radians). */
rootYaw: number;
/** Interpolated root ground offset (world X/Z metres) from the load spot. */
@@ -117,6 +119,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
groundLock: [],
reaches: [],
pins: [],
+ grips: [],
yaw: 0,
pos: { x: 0, z: 0 },
});
@@ -139,6 +142,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
groundLock: phase.groundLock,
reaches: phase.reaches,
pins: phase.pins,
+ grips: phase.grips,
yaw: currYaw * DEG,
pos: { ...currPos },
});
@@ -160,6 +164,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
groundLock: [],
reaches: [],
pins: [],
+ grips: [],
yaw: wrapYaw,
pos: { x: 0, z: 0 },
});
@@ -244,6 +249,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
groundLock: b.groundLock,
reaches: b.reaches,
pins: b.pins,
+ grips: b.grips,
rootYaw,
rootOffset,
};
diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts
index f041569..95ae7bd 100644
--- a/packages/posecode-render/test/render.test.ts
+++ b/packages/posecode-render/test/render.test.ts
@@ -6,7 +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 { levelPlantedFeet, wrapGrip } from "../src/contacts.js";
import { parse, eulerRomFor } from "posecode-parser";
const DEG = Math.PI / 180;
@@ -787,3 +787,29 @@ describe("foot-flat correction (L3.1)", () => {
expect(soleDot()).toBeGreaterThan(0.9);
});
});
+
+describe("bar grip (L3.2)", () => {
+ it("exposes two-point bar grip anchors shoulder-width apart", () => {
+ const { anchors } = buildProps(["bar"]);
+ const l = anchors.get("bar_left");
+ const r = anchors.get("bar_right");
+ expect(l).toBeDefined();
+ expect(r).toBeDefined();
+ expect(l!.x).toBeGreaterThan(0); // left hand grips the +X side
+ expect(r!.x).toBeLessThan(0);
+ expect(Math.abs(l!.x - r!.x)).toBeCloseTo(0.36, 2);
+ expect(l!.y).toBeCloseTo(r!.y, 5); // same bar height
+ });
+
+ it("wraps the fingers of a gripping hand into a curl", () => {
+ const m = buildMannequin();
+ const restIndex = m.bones.get("index_left")!.rotation.x;
+ const restThumb = m.bones.get("thumb_left")!.rotation.x;
+ wrapGrip(m, [{ effector: "hand_left", anchor: "bar_left" }]);
+ expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(restIndex + 0.5);
+ expect(m.bones.get("middle_left")!.rotation.x).toBeGreaterThan(0.5);
+ expect(m.bones.get("thumb_left")!.rotation.x).toBeGreaterThan(restThumb + 0.3);
+ // the un-gripped right hand is untouched
+ expect(m.bones.get("index_right")!.rotation.x).toBeCloseTo(0, 5);
+ });
+});
From 2df04f162ae23789d0f1df36aaa57eab814b61a5 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 16:01:12 +0300
Subject: [PATCH 18/25] feat(language): editor support for the grip directive
---
.../vscode/syntaxes/posecode.tmLanguage.json | 2 +-
packages/posecode-language/src/completion.ts | 2 +-
packages/posecode-language/src/vocab.ts | 3 ++-
.../posecode-language/test/language.test.ts | 23 +++++++++++++++++++
4 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/editors/vscode/syntaxes/posecode.tmLanguage.json b/editors/vscode/syntaxes/posecode.tmLanguage.json
index a8e1cda..f997fa8 100644
--- a/editors/vscode/syntaxes/posecode.tmLanguage.json
+++ b/editors/vscode/syntaxes/posecode.tmLanguage.json
@@ -29,7 +29,7 @@
},
"keywords": {
"name": "keyword.control.posecode",
- "match": "\\b(posecode|rig|pose|start|step|repeat|ground-lock|cue|hold)\\b"
+ "match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
},
"kinds": {
"name": "storage.type.posecode",
diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts
index 17e9b08..283e661 100644
--- a/packages/posecode-language/src/completion.ts
+++ b/packages/posecode-language/src/completion.ts
@@ -51,7 +51,7 @@ function contextFor(prefix: string, line: number): Context {
if (/^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose";
if (/^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing";
if (/^\s*ground-lock\s*:\s*[\w,\s-]*$/.test(prefix)) return "effector";
- if (/^\s*(reach|pin)\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector";
+ if (/^\s*(reach|pin|grip)\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector";
if (/^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action";
if (/^\s*[\w-]*$/.test(prefix)) {
diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts
index fb5ae44..cf2024f 100644
--- a/packages/posecode-language/src/vocab.ts
+++ b/packages/posecode-language/src/vocab.ts
@@ -32,7 +32,7 @@ export const PROPS = ["chair", "wall", "bar", "box", "dip-bars"];
export const TOP_KEYWORDS = ["rig", "prop", "pose", "clip", "step", "repeat"];
/** Keywords valid as step children. */
-export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel", "cue"];
+export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "grip", "turn", "travel", "cue"];
/** Short docs surfaced on hover and as completion detail. */
export const KEYWORD_DOCS: Record = {
@@ -53,6 +53,7 @@ export const KEYWORD_DOCS: Record = {
reach:
"Drives an effector to a target via ROM-constrained IK: `reach: hand_left ankle_left`, `reach: hands floor`.",
pin: "Moves the body so an effector sits on an anchor: `pin: hands bar` (hang, pull up, step up, dip).",
+ grip: "Holds a bar/rail: `grip: hands bar`. Each hand gets its own two-point anchor (bar_left/bar_right), the arm bends via IK onto it, and the fingers wrap the bar. Use for pull-up, dead-hang, hanging knee raise.",
turn: "Turns the figure to face a new direction: `turn: 360` (degrees, yaw about vertical). Absolute, carried across phases. Standing poses only.",
travel: "Moves the figure across the floor: `travel: 0.4 0` (world x z metres from the start spot). Absolute, carried across phases. Standing poses only.",
cue: "A short coaching cue shown while this phase plays.",
diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts
index a0bcf38..7efdadc 100644
--- a/packages/posecode-language/test/language.test.ts
+++ b/packages/posecode-language/test/language.test.ts
@@ -135,3 +135,26 @@ describe("timing modes (L2)", () => {
expect(hint?.message).toContain("settle");
});
});
+
+describe("grip directive (L3.2)", () => {
+ it("offers grip as a step child keyword", () => {
+ const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " "].join("\n");
+ const line = doc.split("\n")[3]!;
+ const labels = getCompletions(doc, 3, line.length).map((i) => i.label);
+ expect(labels).toContain("grip");
+ });
+
+ it("completes effectors after `grip:`", () => {
+ const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " grip: "].join("\n");
+ const line = doc.split("\n")[3]!;
+ const labels = getCompletions(doc, 3, line.length).map((i) => i.label);
+ expect(labels).toContain("hands");
+ });
+
+ it("hovers grip with its doc", () => {
+ const doc = ['posecode exercise "x"', " rig humanoid", ' step "Hang" 1s flow:', " grip: hands bar"].join("\n");
+ const line = doc.split("\n")[3]!;
+ const h = getHover(doc, 3, line.indexOf("grip") + 1);
+ expect(h?.contents.toLowerCase()).toContain("bar");
+ });
+});
From ffec912c61dd7af87088326ad841a6f5dd819446 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 16:04:52 +0300
Subject: [PATCH 19/25] feat: pull-up/dead-hang/hanging-knee-raise grip the bar
via grip directive (L3.2)
---
spec/examples/dead-hang.posecode | 4 +---
spec/examples/hanging-knee-raise.posecode | 16 ++++++----------
spec/examples/pull-up.posecode | 10 +++-------
3 files changed, 10 insertions(+), 20 deletions(-)
diff --git a/spec/examples/dead-hang.posecode b/spec/examples/dead-hang.posecode
index 994dca0..8843a03 100644
--- a/spec/examples/dead-hang.posecode
+++ b/spec/examples/dead-hang.posecode
@@ -7,7 +7,6 @@ posecode exercise "Dead hang"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
ground-lock: feet
cue "Reach up and grip the bar"
@@ -15,8 +14,7 @@ posecode exercise "Dead hang"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
- pin: hands bar
+ grip: hands bar
cue "Relax into a long, straight-arm hang"
step "Down" 1.5s ease-out:
diff --git a/spec/examples/hanging-knee-raise.posecode b/spec/examples/hanging-knee-raise.posecode
index fbb10e9..a023bc5 100644
--- a/spec/examples/hanging-knee-raise.posecode
+++ b/spec/examples/hanging-knee-raise.posecode
@@ -7,7 +7,6 @@ posecode exercise "Hanging knee raise"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
ground-lock: feet
cue "Reach up and grip the bar"
@@ -15,9 +14,8 @@ posecode exercise "Hanging knee raise"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
- pin: hand_left bar
- pin: hand_right bar
+ grip: hand_left bar
+ grip: hand_right bar
cue "Hang from the bar overhead, arms long, body still"
step "Raise knees" 1.2s ease-out:
@@ -26,9 +24,8 @@ posecode exercise "Hanging knee raise"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
- pin: hand_left bar
- pin: hand_right bar
+ grip: hand_left bar
+ grip: hand_right bar
cue "Draw both knees up toward the chest"
step "Lower" 1.4s ease-in:
@@ -37,9 +34,8 @@ posecode exercise "Hanging knee raise"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
- pin: hand_left bar
- pin: hand_right bar
+ grip: hand_left bar
+ grip: hand_right bar
cue "Lower the legs under control, staying in a steady hang"
step "Release" 0.8s ease-in:
diff --git a/spec/examples/pull-up.posecode b/spec/examples/pull-up.posecode
index a1af4d5..5d013ba 100644
--- a/spec/examples/pull-up.posecode
+++ b/spec/examples/pull-up.posecode
@@ -7,7 +7,6 @@ posecode exercise "Pull-up"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
ground-lock: feet
cue "Reach up and grip the bar"
@@ -15,30 +14,27 @@ posecode exercise "Pull-up"
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
- pin: hands bar
+ grip: hands bar
cue "Hang from the bar, arms long, shoulders active"
step "Pull up" 1.2s ease-out:
shoulders: flex 105
elbows: flex 130
elbows: pronate 80
- fingers: flex 80
spine: extend 15
chest: extend 10
neck: extend 20
- pin: hands bar
+ grip: hands bar
cue "Pull the chest toward the bar, driving the elbows down"
step "Lower" 1.4s ease-in:
shoulders: flex 175
elbows: flex 5
elbows: pronate 80
- fingers: flex 80
spine: extend 0
chest: extend 0
neck: extend 0
- pin: hands bar
+ grip: hands bar
cue "Lower under control back to a full hang"
step "Release" 0.8s ease-in:
From ce3f90e39bca36912e8983137469d9d7456fa9d9 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 16:11:53 +0300
Subject: [PATCH 20/25] docs: L4 secondary-motion design spec (L4.1 relaxed
hands)
---
.../2026-07-11-l4-secondary-motion-design.md | 104 ++++++++++++++++++
1 file changed, 104 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md
diff --git a/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md b/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md
new file mode 100644
index 0000000..278111b
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-l4-secondary-motion-design.md
@@ -0,0 +1,104 @@
+# L4 — Additive Secondary Motion (Design)
+
+**Date:** 2026-07-11
+**Status:** Approved (design)
+**Sub-project:** Layer 4 of the animation-naturalness program
+**Branch:** `feat/l3-post-ik` (isolated worktree `/Users/aaaa/Developer/posecode-l3`)
+
+---
+
+## 1. Context and motivation
+
+L2 (spline flow) and L3 (foot-flat, bar grip) fixed the base pose and its contacts. What still
+reads as "not alive" is the absence of **secondary motion** — the reactive/idle detail real
+bodies always have. The most glaring, universal instance: the procedural hands are **flat open
+palms** everywhere except when gripping a bar. A relaxed human hand always carries a slight
+finger curl. This is the biggest cheap win and it's the "hands acting weird" the user flagged.
+
+L4 is a family of additive layers applied on top of the base pose:
+
+- **L4.1 — Relaxed resting hand pose** (this slice): a natural finger curl on any hand that
+ isn't gripping and whose fingers aren't explicitly authored.
+- **L4.2 — Locomotion arm swing** (later): arms counter-swing to leg motion during travel.
+- **L4.3 — Follow-through & weight shift** (later): spine lag / overshoot-settle, idle weight
+ shift, plus head look-at (folded in from L3).
+
+Slices are independent and shippable; build L4.1 first.
+
+### Non-goals for L4.1
+
+- Arm swing, spine follow-through, weight shift, look-at (L4.2 / L4.3).
+
+---
+
+## 2. Approach — L4.1 relaxed hand pose
+
+A new `relaxHands(m, gripSides, authoredFingers)` in `contacts.ts` applies a gentle rest curl to
+finger bones, so idle hands read as relaxed rather than splayed flat.
+
+Rules (so it never fights intent):
+- **Skip gripping hands** — those are wrapped by `wrapGrip` (a full grip curl).
+- **Skip authored fingers** — a move that explicitly poses fingers (make-a-fist, finger-spell,
+ hand-wave) is respected; `relaxHands` only touches finger bones NOT in the timeline's
+ `bonesUsed` (i.e., left at rest).
+- For each remaining hand, curl the four fingers to `REST_CURL` (~18°) at the knuckle and give
+ the thumb a light inward rest, turning the flat palm into a natural relaxed hand.
+
+Applied each frame after `wrapGrip` (grip wins) and once on `load()`. Because it only writes
+finger-bone local rotations that nothing else drives, it can't disturb the solved body pose or
+contacts (same safety property as the breathing mesh layer).
+
+### Wiring
+
+- `index.ts frame()`: after `wrapGrip` (inside `applyGrips`) has run, call `relaxHands`, passing
+ the grip sides for this phase and the authored finger set (`timeline.bonesUsed ∩ fingers`).
+- `index.ts load()`: call once after the base solve so the initial frame shows relaxed hands.
+- The authored finger set is derived once per load from `timeline.bonesUsed`.
+
+---
+
+## 3. Components and boundaries
+
+- **`packages/posecode-render/src/contacts.ts`:** new `relaxHands(m, gripSides, authoredFingers)`
+ + `REST_CURL` constant. Reuses the `FINGERS` list already there for `wrapGrip`.
+- **`packages/posecode-render/src/index.ts`:** compute `authoredFingers` at load; call
+ `relaxHands` in `frame()` and `load()`; derive `gripSides` from `info.grips`.
+- No parser/DSL change (this is automatic aliveness, not an authored feature). No editor change.
+- **Tests:** `relaxHands` curls a rest hand's fingers; leaves a gripping side to `wrapGrip`;
+ never overrides an authored finger; existing suites stay green.
+
+### Data flow
+
+```
+frame(): base pose → contacts → applyGrips (wrapGrip on gripping hands)
+ → relaxHands(m, gripSides, authoredFingers) ← NEW: rest curl on idle, un-authored hands
+ → floor clamp
+```
+
+## 4. Error handling
+
+- Missing finger bones → no-op per bone.
+- A hand both gripping and (somehow) authored → grip/authored win; `relaxHands` skips it.
+- `REST_CURL` is small and within finger ROM (no clamp needed; fingers are cosmetic 1-DOF).
+
+## 5. Testing (TDD)
+
+1. `relaxHands` curls `index_left` etc. from flat toward a rest curl for a non-gripping hand.
+2. A gripping side (passed in `gripSides`) is left untouched by `relaxHands` (wrapGrip owns it).
+3. An authored finger (in `authoredFingers`) is not overridden.
+4. Existing render/eval/parser/language suites stay green.
+
+Manual: browser-verify a plain move (e.g. biceps curl / squat) shows relaxed hands, not flat
+splayed palms; a gripping move still shows the full bar wrap; make-a-fist still makes a fist.
+
+## 6. Risks
+
+- **Double-curl with grip:** avoided by skipping grip sides.
+- **Overriding expressive hands:** avoided by skipping authored fingers.
+- **Reset each frame:** `relaxHands` sets absolute finger rotations, so it must run every frame
+ after sampling (sampling leaves un-authored fingers at identity); idempotent.
+
+## 7. Definition of done
+
+- `relaxHands` implemented + wired; tests 1-4 pass; suites green; typecheck clean.
+- Browser: idle hands relaxed, grips still wrap, authored hands respected.
From 77347301c967564b943cfa767a7ea0920e6235ac Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 16:14:49 +0300
Subject: [PATCH 21/25] feat(render): relaxed resting hand pose (L4.1 secondary
motion)
---
packages/posecode-render/src/contacts.ts | 39 +++++++++++++++++++
packages/posecode-render/src/index.ts | 24 +++++++++++-
.../posecode-render/test/contacts.test.ts | 26 ++++++++++++-
3 files changed, 87 insertions(+), 2 deletions(-)
diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts
index 7d11e79..6a6a707 100644
--- a/packages/posecode-render/src/contacts.ts
+++ b/packages/posecode-render/src/contacts.ts
@@ -137,3 +137,42 @@ export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
}
if (changed) m.root.updateMatrixWorld(true);
}
+
+/** Relaxed resting finger curl (radians) for an idle hand. */
+export const REST_CURL = 0.32;
+
+/**
+ * Give idle hands a natural relaxed curl instead of a flat splayed palm. Applied
+ * every frame to any hand that is NOT gripping this phase (those are wrapped by
+ * `wrapGrip`) and whose fingers are NOT explicitly authored (make-a-fist,
+ * finger-spell, hand-wave keep their pose). A mesh-only-style aliveness layer:
+ * it writes only finger-bone locals, so it can never disturb the solved pose.
+ */
+export function relaxHands(
+ m: Mannequin,
+ gripSides: ReadonlySet<"left" | "right">,
+ authoredFingers: ReadonlySet,
+): void {
+ let changed = false;
+ for (const side of ["left", "right"] as const) {
+ if (gripSides.has(side)) continue;
+ for (const f of FINGERS) {
+ const id = `${f}_${side}`;
+ if (authoredFingers.has(id)) continue;
+ const bone = m.bones.get(id);
+ if (bone) {
+ bone.rotation.set(REST_CURL, 0, 0);
+ changed = true;
+ }
+ }
+ const thumbId = `thumb_${side}`;
+ if (!authoredFingers.has(thumbId)) {
+ const thumb = m.bones.get(thumbId);
+ if (thumb) {
+ thumb.rotation.set(REST_CURL * 0.6, 0, side === "left" ? -REST_CURL : REST_CURL);
+ changed = true;
+ }
+ }
+ }
+ if (changed) m.root.updateMatrixWorld(true);
+}
diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts
index 0f78ab5..19a149d 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, levelPlantedFeet, wrapGrip } from "./contacts.js";
+import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands } from "./contacts.js";
const DEG = Math.PI / 180;
@@ -267,6 +267,9 @@ export function createViewer(
}
let timeline: BuiltTimeline | null = null;
+ // Finger bones the loaded document explicitly poses (make-a-fist, finger-spell,
+ // hand-wave): the L4.1 resting-hand curl leaves these alone.
+ let authoredFingers = new Set();
// The last loaded document, kept so the viewer can re-solve base pose and
// ground anchors when the character (with its own proportions) arrives.
let lastIR: PosecodeIR | null = null;
@@ -593,6 +596,8 @@ export function createViewer(
// lower-body poses (squat, lunge, deadlift) don't balance on the toes.
// Runs before the floor clamp so the leveled sole is what rests on y=0.
levelPlantedFeet(mannequin, info.groundLock);
+ // L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
+ relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
// 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
@@ -691,6 +696,8 @@ export function createViewer(
depenetrate(mannequin);
groundFigureOf(mannequin);
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
+ authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
+ relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
captureGroundTargets();
baseRootPos.copy(mannequin.root.position);
baseRootQuat.copy(mannequin.root.quaternion);
@@ -797,6 +804,21 @@ export function createViewer(
return api;
}
+/** True for a finger bone id (thumb/index/middle/ring/pinky_left|right). */
+function isFingerId(id: string): boolean {
+ return /^(thumb|index|middle|ring|pinky)_/.test(id);
+}
+
+/** The hand sides ("left"/"right") gripping this phase, from its grip targets. */
+function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "right"> {
+ const sides = new Set<"left" | "right">();
+ for (const g of grips) {
+ if (g.effector.endsWith("_left") || g.effector === "hands") sides.add("left");
+ if (g.effector.endsWith("_right") || g.effector === "hands") sides.add("right");
+ }
+ return sides;
+}
+
function enableShadows(root: THREE.Object3D): void {
root.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts
index 6dd52f0..0bdb5ca 100644
--- a/packages/posecode-render/test/contacts.test.ts
+++ b/packages/posecode-render/test/contacts.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import * as THREE from "three";
import { buildMannequin } from "../src/mannequin.js";
-import { levelPlantedFeet } from "../src/contacts.js";
+import { levelPlantedFeet, relaxHands } from "../src/contacts.js";
import { groundFigure } from "../src/groundlock.js";
const DEG = Math.PI / 180;
@@ -51,3 +51,27 @@ describe("levelPlantedFeet", () => {
expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-3);
});
});
+
+describe("relaxHands (L4.1)", () => {
+ it("curls the fingers of an idle, un-authored hand into a natural rest", () => {
+ const m = buildMannequin();
+ expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // flat at rest
+ relaxHands(m, new Set(), new Set());
+ expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(0.1);
+ expect(m.bones.get("middle_right")!.rotation.x).toBeGreaterThan(0.1);
+ });
+
+ it("leaves a gripping hand for wrapGrip (skips grip sides)", () => {
+ const m = buildMannequin();
+ relaxHands(m, new Set(["left"]), new Set());
+ expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // untouched
+ expect(m.bones.get("index_right")!.rotation.x).toBeGreaterThan(0.1); // right relaxed
+ });
+
+ it("does not override an explicitly authored finger", () => {
+ const m = buildMannequin();
+ m.bones.get("index_left")!.rotation.x = 1.4; // authored fist
+ relaxHands(m, new Set(), new Set(["index_left"]));
+ expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
+ });
+});
From 27e0c8266fd6af81dbcb3b49f642cfdbedf5f230 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 16:33:47 +0300
Subject: [PATCH 22/25] feat(render): contralateral arm swing during locomotion
(L4.2)
---
packages/posecode-render/src/contacts.ts | 35 +++++++++++++++++++
packages/posecode-render/src/index.ts | 8 ++++-
.../posecode-render/test/contacts.test.ts | 32 ++++++++++++++++-
3 files changed, 73 insertions(+), 2 deletions(-)
diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts
index 6a6a707..a1f239d 100644
--- a/packages/posecode-render/src/contacts.ts
+++ b/packages/posecode-render/src/contacts.ts
@@ -176,3 +176,38 @@ export function relaxHands(
}
if (changed) m.root.updateMatrixWorld(true);
}
+
+/** Fraction of the contralateral hip's sagittal angle carried into arm swing. */
+export const SWING_GAIN = 0.4;
+const SWING_EULER = new THREE.Euler();
+const HIP_EULER = new THREE.Euler();
+
+/**
+ * Contralateral arm swing: during locomotion the arms counter-swing to the legs
+ * (right leg forward ↔ left arm forward). Adds a swing to each free shoulder
+ * proportional to the OPPOSITE hip's sagittal (local X) angle, so any move that
+ * animates the hips (walk, march, box-step) gets natural arm swing for free.
+ * Skips shoulders the document authors and any gripping side.
+ */
+export function swingArms(
+ m: Mannequin,
+ authoredShoulders: ReadonlySet,
+ gripSides: ReadonlySet<"left" | "right">,
+): void {
+ let changed = false;
+ for (const side of ["left", "right"] as const) {
+ if (gripSides.has(side)) continue;
+ const shoulderId = `shoulder_${side}`;
+ if (authoredShoulders.has(shoulderId)) continue;
+ const shoulder = m.bones.get(shoulderId);
+ const contraHip = m.bones.get(`hip_${side === "left" ? "right" : "left"}`);
+ if (!shoulder || !contraHip) continue;
+ HIP_EULER.setFromQuaternion(contraHip.quaternion, "XYZ");
+ if (Math.abs(HIP_EULER.x) < 1e-3) continue; // legs still → no swing
+ SWING_EULER.setFromQuaternion(shoulder.quaternion, "XYZ");
+ SWING_EULER.x += HIP_EULER.x * SWING_GAIN;
+ shoulder.quaternion.setFromEuler(SWING_EULER);
+ changed = true;
+ }
+ if (changed) m.root.updateMatrixWorld(true);
+}
diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts
index 19a149d..375a459 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, levelPlantedFeet, wrapGrip, relaxHands } from "./contacts.js";
+import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms } from "./contacts.js";
const DEG = Math.PI / 180;
@@ -270,6 +270,8 @@ export function createViewer(
// Finger bones the loaded document explicitly poses (make-a-fist, finger-spell,
// hand-wave): the L4.1 resting-hand curl leaves these alone.
let authoredFingers = new Set();
+ // Shoulders the document poses: L4.2 arm-swing leaves these to the author.
+ let authoredShoulders = new Set();
// The last loaded document, kept so the viewer can re-solve base pose and
// ground anchors when the character (with its own proportions) arrives.
let lastIR: PosecodeIR | null = null;
@@ -596,6 +598,8 @@ export function createViewer(
// lower-body poses (squat, lunge, deadlift) don't balance on the toes.
// Runs before the floor clamp so the leveled sole is what rests on y=0.
levelPlantedFeet(mannequin, info.groundLock);
+ // L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
+ swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
// Safety net: nothing above ever intentionally pushes part of the body
@@ -697,6 +701,8 @@ export function createViewer(
groundFigureOf(mannequin);
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
+ authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
+ swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
captureGroundTargets();
baseRootPos.copy(mannequin.root.position);
diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts
index 0bdb5ca..5c38cbf 100644
--- a/packages/posecode-render/test/contacts.test.ts
+++ b/packages/posecode-render/test/contacts.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import * as THREE from "three";
import { buildMannequin } from "../src/mannequin.js";
-import { levelPlantedFeet, relaxHands } from "../src/contacts.js";
+import { levelPlantedFeet, relaxHands, swingArms } from "../src/contacts.js";
import { groundFigure } from "../src/groundlock.js";
const DEG = Math.PI / 180;
@@ -75,3 +75,33 @@ describe("relaxHands (L4.1)", () => {
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
});
});
+
+describe("swingArms (L4.2)", () => {
+ it("adds contralateral arm swing when a hip is flexed and the arm is free", () => {
+ const m = buildMannequin();
+ // Flex the right hip forward (walking: right leg forward → left arm forward).
+ m.bones.get("hip_right")!.rotation.x = -0.6;
+ const before = m.bones.get("shoulder_left")!.rotation.x;
+ swingArms(m, new Set(), new Set());
+ const after = m.bones.get("shoulder_left")!.rotation.x;
+ expect(Math.abs(after - before)).toBeGreaterThan(0.05); // swung
+ // swings the same sagittal direction as the contralateral hip (forward)
+ expect(Math.sign(after - before)).toBe(Math.sign(-0.6));
+ });
+
+ it("respects an authored shoulder (no swing)", () => {
+ const m = buildMannequin();
+ m.bones.get("hip_right")!.rotation.x = -0.6;
+ m.bones.get("shoulder_left")!.rotation.x = 0.9; // authored
+ swingArms(m, new Set(["shoulder_left"]), new Set());
+ expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(0.9, 5);
+ });
+
+ it("skips a gripping side", () => {
+ const m = buildMannequin();
+ m.bones.get("hip_right")!.rotation.x = -0.6;
+ const before = m.bones.get("shoulder_left")!.rotation.x;
+ swingArms(m, new Set(), new Set(["left"]));
+ expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5);
+ });
+});
From 618c7452b19cf262e94cb4de68950ee811e86dc8 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 20:54:21 +0300
Subject: [PATCH 23/25] Reconcile merge: keep L2-L4 animation solvers over
main's overlapping contact work
Main and this branch independently reworked foot/bar contact (main: alignFloorSoles/
alignBarGrips/contactSides; here: levelPlantedFeet/wrapGrip + L4 secondary motion).
Per direction, this branch's approach wins on conflict. Took our complete versions of
contacts.ts, index.ts, props.ts, timeline.ts, render.test.ts, eval probe/checks, and
pull-up/squat examples; accepted main's non-overlapping additions. Added
showProceduralWhileLoading to ViewerOptions for playground API compatibility.
241 tests pass, typecheck clean.
---
packages/posecode-eval/src/checks.ts | 23 ------
packages/posecode-eval/src/probe.ts | 16 +----
packages/posecode-render/src/contacts.ts | 73 +++-----------------
packages/posecode-render/src/index.ts | 68 ++++--------------
packages/posecode-render/src/props.ts | 58 +---------------
packages/posecode-render/test/render.test.ts | 50 +-------------
6 files changed, 28 insertions(+), 260 deletions(-)
diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts
index a56d16c..82698c6 100644
--- a/packages/posecode-eval/src/checks.ts
+++ b/packages/posecode-eval/src/checks.ts
@@ -8,7 +8,6 @@
import type { PhasePose, ProbeResult } from "./probe.js";
import {
balanceOverflow,
- barGripError,
distanceBetween,
feetCenterSkateDistance,
footIsSupported,
@@ -18,10 +17,8 @@ import {
kneeFlexionDeg,
lowestPoint,
palmFloorAngleDeg,
- palmBarAngleDeg,
phaseMaxLandmarkSpeed,
segmentTiltDeg,
- soleUpAngleDeg,
spineCurlDeg,
torsoPitchDeg,
} from "./metrics.js";
@@ -247,26 +244,6 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
(v) => v > 0.9,
"pelvis > 0.9m",
),
- phaseCheck("left-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
- phaseCheck("right-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
- ],
- },
- {
- movement: "pull-up",
- checks: [
- phaseCheck("left-grip-position", "Hang", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
- phaseCheck("right-grip-position", "Hang", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
- phaseCheck("left-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "left"), (v) => v < 5, "< 5°"),
- phaseCheck("right-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "right"), (v) => v < 5, "< 5°"),
- phaseCheck("left-grip-held", "Pull up", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
- phaseCheck("right-grip-held", "Pull up", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
- ],
- },
- {
- movement: "walk-cycle",
- checks: [
- phaseCheck("left-stance-flat", "Step right", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
- phaseCheck("right-stance-flat", "Step left", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
],
},
{
diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts
index 2d154e5..a31513d 100644
--- a/packages/posecode-eval/src/probe.ts
+++ b/packages/posecode-eval/src/probe.ts
@@ -16,9 +16,7 @@ import * as THREE from "three";
import { parse, type TimingMode, type ParseError, type PinTarget, type ReachTarget, type Warning } from "posecode-parser";
import {
applyGroundLock,
- alignBarGrips,
alignFloorPalms,
- alignFloorSoles,
buildMannequin,
buildProps,
buildTimeline,
@@ -124,7 +122,6 @@ export function probeMovement(source: string): ProbeResult {
v.z += info.rootOffset.z;
anchors.set(id, v);
}
- alignFloorSoles(m, info.groundLock, info.reaches, info.pins);
applyGroundLock(m, info.groundLock, anchors);
// Resolve scene-independent pins. Unknown names here are prop anchors and
// intentionally remain for browser-level coverage.
@@ -147,17 +144,9 @@ export function probeMovement(source: string): ProbeResult {
if (pin.anchor === "floor") {
target = effector.getWorldPosition(new THREE.Vector3());
target.y = 0;
+ } else if (propScene.anchors.has(pin.anchor)) {
+ target = propScene.anchors.get(pin.anchor)!.clone();
} else {
- const side = effectorId.endsWith("_left")
- ? "left"
- : effectorId.endsWith("_right")
- ? "right"
- : null;
- const propTarget = (side ? propScene.anchors.get(`${pin.anchor}.${side}`) : undefined)
- ?? propScene.anchors.get(pin.anchor);
- if (propTarget) target = propTarget.clone();
- }
- if (!target && pin.anchor !== "floor") {
const landmark = m.bones.get(pin.anchor);
if (landmark) target = landmark.getWorldPosition(new THREE.Vector3());
}
@@ -171,7 +160,6 @@ export function probeMovement(source: string): ProbeResult {
}
}
alignFloorPalms(m, info.reaches, info.pins);
- alignBarGrips(m, info.reaches, info.pins);
// Viewer safety net: never leave the lowest mesh point below the floor.
m.root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(m.root);
diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts
index 3e232c7..a1f239d 100644
--- a/packages/posecode-render/src/contacts.ts
+++ b/packages/posecode-render/src/contacts.ts
@@ -12,7 +12,14 @@ export function alignFloorPalms(
reaches: readonly ReachTarget[],
pins: readonly PinTarget[],
): void {
- const sides = contactSides(reaches, pins, (target) => target === "floor", "hand");
+ const sides = new Set<"left" | "right">();
+ const collect = (effector: string, target: string) => {
+ if (target !== "floor") return;
+ if (effector === "hands" || effector === "hand_left") sides.add("left");
+ if (effector === "hands" || effector === "hand_right") sides.add("right");
+ };
+ reaches.forEach((r) => collect(r.effector, r.target));
+ pins.forEach((p) => collect(p.effector, p.anchor));
for (const side of sides) {
const wrist = m.bones.get(`wrist_${side}`);
@@ -24,68 +31,8 @@ export function alignFloorPalms(
const current = localNormal.applyQuaternion(world).normalize();
const correction = new THREE.Quaternion().setFromUnitVectors(current, DOWN);
const desiredWorld = correction.multiply(world);
- setWorldQuaternion(wrist, desiredWorld);
- }
- if (sides.size > 0) m.root.updateMatrixWorld(true);
-}
-
-/**
- * Keep contacting feet flat and facing with the body. The ankle joint remains
- * in place, so hip/knee motion and weight shift are preserved; only the sole's
- * terminal orientation is corrected.
- */
-export function alignFloorSoles(
- m: Mannequin,
- groundLock: readonly string[],
- reaches: readonly ReachTarget[] = [],
- pins: readonly PinTarget[] = [],
-): void {
- const sides = contactSides(reaches, pins, (target) => target === "floor", "foot");
- if (groundLock.includes("feet")) {
- sides.add("left");
- sides.add("right");
- }
- if (sides.size === 0) return;
-
- const rootForward = FORWARD.clone().applyQuaternion(
- m.root.getWorldQuaternion(new THREE.Quaternion()),
- );
- rootForward.y = 0;
- if (rootForward.lengthSq() < 1e-8) rootForward.copy(FORWARD);
- rootForward.normalize();
- const worldX = UP.clone().cross(rootForward).normalize();
- const desiredWorld = new THREE.Quaternion().setFromRotationMatrix(
- new THREE.Matrix4().makeBasis(worldX, UP, rootForward),
- );
- for (const side of sides) {
- const ankle = m.bones.get(`ankle_${side}`);
- if (ankle) setWorldQuaternion(ankle, desiredWorld);
- }
- m.root.updateMatrixWorld(true);
-}
-
-/**
- * Orient bar-contacting wrists as an overhand grip: fingers point up toward
- * the bar and the palm faces away from the body. Finger curl remains authored
- * independently, so this composes with grip strength / release animation.
- */
-export function alignBarGrips(
- m: Mannequin,
- reaches: readonly ReachTarget[],
- pins: readonly PinTarget[],
-): void {
- const sides = contactSides(reaches, pins, (target) => target === "bar", "hand");
- for (const side of sides) {
- const wrist = m.bones.get(`wrist_${side}`);
- if (!wrist) continue;
- // Local palm normal is mirrored X; local -Y follows wrist→fingers.
- const worldX = side === "left" ? FORWARD.clone() : FORWARD.clone().negate();
- const worldY = UP.clone().negate();
- const worldZ = worldX.clone().cross(worldY).normalize();
- const desiredWorld = new THREE.Quaternion().setFromRotationMatrix(
- new THREE.Matrix4().makeBasis(worldX, worldY, worldZ),
- );
- setWorldQuaternion(wrist, desiredWorld);
+ const parentWorld = wrist.parent.getWorldQuaternion(new THREE.Quaternion());
+ wrist.quaternion.copy(parentWorld.invert().multiply(desiredWorld));
}
if (sides.size > 0) m.root.updateMatrixWorld(true);
}
diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts
index 28a9eeb..f62be0a 100644
--- a/packages/posecode-render/src/index.ts
+++ b/packages/posecode-render/src/index.ts
@@ -17,11 +17,10 @@ import { buildMannequin, type Mannequin } from "./mannequin.js";
import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } from "./groundlock.js";
import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js";
import { solveCCD, type JointLimits } from "./ik.js";
-import { buildProps, syncPropAttachments, type PropScene } from "./props.js";
+import { buildProps, type PropScene } from "./props.js";
import { loadCharacter, type Character } from "./character.js";
import {
loadClipSource,
- selectMotionClip,
retargetMocapClip,
createClipLayer,
type ClipLayer,
@@ -83,14 +82,6 @@ export interface ViewerOptions {
* skeleton, rebuilt to the character's exact proportions (see character.ts).
*/
characterUrl?: string;
- /**
- * Show the procedural figure while `characterUrl` loads. Defaults to true
- * for embeds and offline-friendly consumers. Set false when a brief empty
- * stage is preferable to flashing the procedural figure before the skinned
- * character appears. The procedural figure is still revealed if loading
- * fails, so the viewer never remains permanently blank.
- */
- showProceduralWhileLoading?: boolean;
/**
* Mocap clip library: clip name (as written in a document's `clip ""`
* directive) → FBX/GLB asset URL. When a loaded document names a clip found
@@ -100,6 +91,13 @@ export interface ViewerOptions {
* the procedural keyframes as always, so clips can never blank a movement.
*/
clips?: Record;
+ /**
+ * Keep the procedural figure visible while a skinned `characterUrl` loads.
+ * This viewer always shows the procedural figure during load (and on load
+ * failure), so the flag is accepted for API compatibility; the default
+ * behavior already matches `true`.
+ */
+ showProceduralWhileLoading?: boolean;
}
export function createViewer(
@@ -182,9 +180,6 @@ export function createViewer(
let mannequin: Mannequin = buildMannequin();
enableShadows(mannequin.root);
- if (opts.characterUrl && opts.showProceduralWhileLoading === false) {
- setMannequinMeshesVisible(mannequin.root, false);
- }
scene.add(mannequin.root);
// Skinned character layer (optional). While loading (and on failure) the
@@ -380,17 +375,6 @@ export function createViewer(
foot_right: "ankle_right",
};
- function contactBoneIds(groundLock: readonly string[], pins: readonly PinTarget[]): string[] {
- const ids = new Set();
- for (const group of groundLock) {
- if (group === "hands") { ids.add("wrist_left"); ids.add("wrist_right"); }
- if (group === "forearms") { ids.add("elbow_left"); ids.add("elbow_right"); }
- if (group === "feet") { ids.add("ankle_left"); ids.add("ankle_right"); }
- }
- for (const pin of pins) ids.add(EFFECTOR_BONE[pin.effector] ?? pin.effector);
- return [...ids];
- }
-
/**
* The rotatable joint chain (proximal → distal) that moves an effector, with
* each joint's ROM expressed as local Euler limits for the constrained solve.
@@ -455,13 +439,7 @@ export function createViewer(
p.y = Number.isFinite(box.min.y) ? Math.max(0, p.y - box.min.y) : 0;
return p;
}
- const side = effector.name.endsWith("_left")
- ? "left"
- : effector.name.endsWith("_right")
- ? "right"
- : null;
- const anchor = (side ? propAnchors.get(`${target}.${side}`) : undefined)
- ?? propAnchors.get(target);
+ const anchor = propAnchors.get(target);
if (anchor) return anchor.clone();
const bone = mannequin.bones.get(target);
if (bone) return bone.getWorldPosition(new THREE.Vector3());
@@ -588,10 +566,8 @@ export function createViewer(
}
function frame(): void {
- let activeContactBones: string[] = [];
if (timeline) {
const info = timeline.sample(time, mannequin.bones);
- activeContactBones = contactBoneIds(info.groundLock, info.pins);
// Life layer rides on wall-clock time (not timeline time) so the figure
// keeps breathing and blinking while paused or scrubbing.
applyLife(performance.now() / 1000);
@@ -613,7 +589,6 @@ export function createViewer(
// Self-collision: nudge limbs out of the body BEFORE contact solving so
// ground-lock and pins see the corrected pose (same order as load()).
depenetrate(mannequin);
- alignFloorSoles(mannequin, info.groundLock, info.reaches, info.pins);
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
applyPins(info.pins);
applyGrips(info.grips);
@@ -664,15 +639,8 @@ export function createViewer(
const gap = clipTargetWeight - clipWeight;
clipWeight += Math.sign(gap) * Math.min(Math.abs(gap), step);
clipLayer.apply(time, clipWeight);
- if (clipWeight > 0) {
- character.group.updateMatrixWorld(true);
- // Mocap is deliberately layered after procedural posing. Re-apply the
- // final contact positions/orientations so the visible mesh cannot skate
- // away from feet/hands the driver already solved.
- character.correctContacts(mannequin, activeContactBones);
- }
+ if (clipWeight > 0) character.group.updateMatrixWorld(true);
}
- if (propScene?.attachments.length) syncPropAttachments(propScene, mannequin.bones);
frameDt = 0;
if (easeCamera) {
controls.target.lerp(desiredTarget, 0.07);
@@ -841,9 +809,8 @@ export function createViewer(
else char.sync(mannequin);
})
.catch(() => {
- // Reveal the fallback if it was hidden during loading. Deliberately
- // silent: an offline embed or blocked CDN should degrade, not error.
- setMannequinMeshesVisible(mannequin.root, true);
+ // Keep the procedural figure. Deliberately silent: an offline embed
+ // or a blocked CDN should degrade, not error.
});
}
@@ -874,12 +841,6 @@ function enableShadows(root: THREE.Object3D): void {
});
}
-function setMannequinMeshesVisible(root: THREE.Object3D, visible: boolean): void {
- root.traverse((obj) => {
- if ((obj as THREE.Mesh).isMesh) obj.visible = visible;
- });
-}
-
/** Free GPU resources for a discarded subtree (prop set swapped on reload). */
function disposeTree(root: THREE.Object3D): void {
root.traverse((obj) => {
@@ -898,16 +859,15 @@ export { applyGroundLock, groundFigure } from "./groundlock.js";
export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js";
export { buildTimeline } from "./timeline.js";
export { solveCCD, type IkChain, type JointLimits } from "./ik.js";
-export { buildProps, syncPropAttachments, type PropScene, type PropAttachment } from "./props.js";
+export { buildProps, type PropScene } from "./props.js";
export { loadCharacter, rigCharacter, type Character } from "./character.js";
export {
loadClipSource,
- selectMotionClip,
retargetMocapClip,
createClipLayer,
type ClipLayer,
type ClipSource,
} from "./clips.js";
export { depenetrate } from "./depenetrate.js";
-export { alignBarGrips, alignFloorPalms, alignFloorSoles } from "./contacts.js";
+export { alignFloorPalms } from "./contacts.js";
export type { PhaseSegment } from "./timeline.js";
diff --git a/packages/posecode-render/src/props.ts b/packages/posecode-render/src/props.ts
index 10e032a..3ff3463 100644
--- a/packages/posecode-render/src/props.ts
+++ b/packages/posecode-render/src/props.ts
@@ -18,15 +18,6 @@ export interface PropScene {
group: THREE.Group;
/** Anchor name → world-space contact point, merged into reach/ground-lock. */
anchors: Map;
- /** Props rigidly attached to a driver bone (weapon/tool sockets). */
- attachments: PropAttachment[];
-}
-
-export interface PropAttachment {
- object: THREE.Object3D;
- bone: string;
- offset: THREE.Vector3;
- rotation: THREE.Quaternion;
}
/** Build the declared props (`chair | wall | bar | box | dip-bars`). Unknown types are ignored. */
@@ -34,7 +25,6 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
const group = new THREE.Group();
group.name = "posecode-props";
const anchors = new Map();
- const attachments: PropAttachment[] = [];
const mat =
material ??
new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.8, metalness: 0.05 });
@@ -103,8 +93,6 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
}
}
anchors.set("bars", new THREE.Vector3(0, railH, 0));
- anchors.set("bars.left", new THREE.Vector3(halfSpan, railH, 0));
- anchors.set("bars.right", new THREE.Vector3(-halfSpan, railH, 0));
} else if (type === "box") {
// A low step/plateau placed IN FRONT of the figure (+Z): the lead foot
// steps forward and up onto it. Top surface at ~0.30 m; `box` anchor sits
@@ -114,54 +102,10 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
plat.position.set(0, topH / 2, 0.32);
group.add(plat);
anchors.set("box", new THREE.Vector3(0, topH, 0.3));
- } else if (type === "sword") {
- const weapon = new THREE.Group();
- const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.018, 0.16, 10), mat);
- const guard = box(0.16, 0.025, 0.035, mat);
- guard.position.y = -0.09;
- const blade = box(0.045, 0.72, 0.012, mat);
- blade.position.y = -0.46;
- weapon.add(grip, guard, blade);
- group.add(weapon);
- attachments.push({
- object: weapon,
- bone: "wrist_right",
- offset: new THREE.Vector3(0, -0.075, 0),
- rotation: new THREE.Quaternion(),
- });
- } else if (type === "gun") {
- const weapon = new THREE.Group();
- const body = box(0.055, 0.09, 0.28, mat);
- body.position.z = 0.12;
- const handle = box(0.05, 0.16, 0.07, mat);
- handle.position.set(0, -0.1, 0.02);
- weapon.add(body, handle);
- group.add(weapon);
- attachments.push({
- object: weapon,
- bone: "wrist_right",
- offset: new THREE.Vector3(0, -0.035, 0.04),
- rotation: new THREE.Quaternion(),
- });
}
}
- return { group, anchors, attachments };
-}
-
-/** Follow final solved driver-bone transforms with held props. */
-export function syncPropAttachments(scene: PropScene, bones: Map): void {
- const p = new THREE.Vector3();
- const q = new THREE.Quaternion();
- for (const attachment of scene.attachments) {
- const bone = bones.get(attachment.bone);
- if (!bone) continue;
- bone.getWorldPosition(p);
- bone.getWorldQuaternion(q);
- attachment.object.position.copy(attachment.offset).applyQuaternion(q).add(p);
- attachment.object.quaternion.copy(q).multiply(attachment.rotation);
- }
- scene.group.updateMatrixWorld(true);
+ return { group, anchors };
}
function box(w: number, h: number, d: number, mat: THREE.Material): THREE.Mesh {
diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts
index 9301f92..95ae7bd 100644
--- a/packages/posecode-render/test/render.test.ts
+++ b/packages/posecode-render/test/render.test.ts
@@ -4,7 +4,7 @@ import { buildMannequin } from "../src/mannequin.js";
import { buildTimeline } from "../src/timeline.js";
import { solveCCD } from "../src/ik.js";
import { poseFor } from "../src/poses.js";
-import { buildProps, syncPropAttachments } from "../src/props.js";
+import { buildProps } from "../src/props.js";
import { applyGroundLock, groundFigure } from "../src/groundlock.js";
import { levelPlantedFeet, wrapGrip } from "../src/contacts.js";
import { parse, eulerRomFor } from "posecode-parser";
@@ -399,59 +399,11 @@ describe("props", () => {
const { anchors, group } = buildProps(["chair", "bar", "wall"]);
expect(anchors.has("seat")).toBe(true);
expect(anchors.has("bar")).toBe(true);
- expect(anchors.has("bar.left")).toBe(true);
- expect(anchors.has("bar.right")).toBe(true);
- expect(anchors.get("bar.left")!.x).toBeGreaterThan(anchors.get("bar.right")!.x);
expect(anchors.has("wall")).toBe(true);
expect(anchors.get("bar")!.y).toBeGreaterThan(1.5); // overhead
expect(group.children.length).toBeGreaterThan(0);
});
- it("attaches held sword and gun props to the solved wrist socket", () => {
- const props = buildProps(["sword", "gun"]);
- const m = buildMannequin();
- m.root.position.set(0.4, 0.2, -0.3);
- m.root.updateMatrixWorld(true);
- syncPropAttachments(props, m.bones);
- expect(props.attachments).toHaveLength(2);
- const wrist = m.bones.get("wrist_right")!.getWorldPosition(new THREE.Vector3());
- for (const attachment of props.attachments) {
- expect(attachment.object.position.distanceTo(wrist)).toBeLessThan(0.2);
- }
- });
-
-});
-
-describe("oriented contacts", () => {
- it("keeps grounded soles flat and facing with the figure", () => {
- const m = buildMannequin();
- m.bones.get("hip_left")!.rotation.x = -70 * DEG;
- m.bones.get("knee_left")!.rotation.x = 90 * DEG;
- m.root.rotation.y = 35 * DEG;
- m.root.updateMatrixWorld(true);
- alignFloorSoles(m, ["feet"]);
- const q = m.bones.get("ankle_left")!.getWorldQuaternion(new THREE.Quaternion());
- const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q);
- expect(up.y).toBeGreaterThan(0.999);
- });
-
- it("orients both palms into a stable overhand bar grip", () => {
- const m = buildMannequin();
- m.root.updateMatrixWorld(true);
- const pins = [
- { effector: "hand_left", anchor: "bar" },
- { effector: "hand_right", anchor: "bar" },
- ];
- alignBarGrips(m, [], pins);
- for (const side of ["left", "right"] as const) {
- const q = m.bones.get(`wrist_${side}`)!.getWorldQuaternion(new THREE.Quaternion());
- const localNormal = side === "left"
- ? new THREE.Vector3(1, 0, 0)
- : new THREE.Vector3(-1, 0, 0);
- const normal = localNormal.applyQuaternion(q);
- expect(normal.z).toBeGreaterThan(0.999);
- }
- });
});
describe("ccd ik", () => {
From b634ba1acb698c215497b27caa6a44e48eddf610 Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sat, 11 Jul 2026 20:58:19 +0300
Subject: [PATCH 24/25] feat(render): look-at head tracking toward active
contacts (L4.3)
---
packages/posecode-render/src/contacts.ts | 30 +++++++++++++++++
packages/posecode-render/src/index.ts | 33 ++++++++++++++++++-
.../posecode-render/test/contacts.test.ts | 32 +++++++++++++++++-
3 files changed, 93 insertions(+), 2 deletions(-)
diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts
index a1f239d..04c8f0c 100644
--- a/packages/posecode-render/src/contacts.ts
+++ b/packages/posecode-render/src/contacts.ts
@@ -211,3 +211,33 @@ export function swingArms(
}
if (changed) m.root.updateMatrixWorld(true);
}
+
+/** Max head turn toward a look target (radians) so the neck never over-rotates. */
+export const MAX_LOOK = 55 * (Math.PI / 180);
+const LOOK_FWD = new THREE.Vector3(0, 0, 1);
+
+/**
+ * Turn the head toward a world focus point (look-at): aims the face (+Z) at the
+ * target, clamped to MAX_LOOK so the head tracks the action (up at the bar in a
+ * pull-up, down at the hands in a floor fold) without spinning unnaturally.
+ */
+export function aimHead(m: Mannequin, focus: THREE.Vector3): void {
+ const head = m.bones.get("head");
+ if (!head?.parent) return;
+ const headPos = head.getWorldPosition(new THREE.Vector3());
+ const desired = focus.clone().sub(headPos);
+ if (desired.lengthSq() < 1e-6) return;
+ desired.normalize();
+ const world = head.getWorldQuaternion(new THREE.Quaternion());
+ const currentZ = LOOK_FWD.clone().applyQuaternion(world).normalize();
+ const full = new THREE.Quaternion().setFromUnitVectors(currentZ, desired);
+ const angle = 2 * Math.acos(THREE.MathUtils.clamp(Math.abs(full.w), -1, 1));
+ const correction =
+ angle > MAX_LOOK
+ ? new THREE.Quaternion().slerpQuaternions(new THREE.Quaternion(), full, MAX_LOOK / angle)
+ : full;
+ const desiredWorld = correction.multiply(world);
+ const parentWorld = head.parent.getWorldQuaternion(new THREE.Quaternion());
+ head.quaternion.copy(parentWorld.invert().multiply(desiredWorld));
+ m.root.updateMatrixWorld(true);
+}
diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts
index f62be0a..d79cb60 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, levelPlantedFeet, wrapGrip, relaxHands, swingArms } from "./contacts.js";
+import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js";
const DEG = Math.PI / 180;
@@ -279,6 +279,8 @@ export function createViewer(
let authoredFingers = new Set();
// Shoulders the document poses: L4.2 arm-swing leaves these to the author.
let authoredShoulders = new Set();
+ // True when the document poses the head/neck: L4.3 look-at then stays off.
+ let authoredHead = false;
// The last loaded document, kept so the viewer can re-solve base pose and
// ground anchors when the character (with its own proportions) arrives.
let lastIR: PosecodeIR | null = null;
@@ -538,6 +540,31 @@ export function createViewer(
wrapGrip(mannequin, grips);
}
+ /**
+ * L4.3 look-at: turn the head toward the action. Collects the world points of
+ * this phase's active grips/reaches (up at the bar, down at a floor reach) and
+ * aims the head at their average. Skipped when the document poses the head/neck.
+ */
+ function applyLookAt(info: { grips: GripTarget[]; reaches: ReachTarget[] }): void {
+ if (authoredHead) return;
+ const pts: THREE.Vector3[] = [];
+ const collect = (effectorName: string, anchorName: string): void => {
+ const bone = EFFECTOR_BONE[effectorName] ?? effectorName;
+ const eff = mannequin.bones.get(bone);
+ if (!eff) return;
+ const t =
+ resolveReachTarget(anchorName, eff) ??
+ resolveReachTarget(anchorName.replace(/_(left|right)$/, ""), eff);
+ if (t) pts.push(t);
+ };
+ for (const g of info.grips) collect(g.effector, g.anchor);
+ for (const r of info.reaches) collect(r.effector, r.target);
+ if (pts.length === 0) return;
+ const focus = new THREE.Vector3();
+ for (const p of pts) focus.add(p);
+ aimHead(mannequin, focus.multiplyScalar(1 / pts.length));
+ }
+
function frameCamera(): void {
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
// Include any scene prop too: a pull-up bar sits well above the figure's
@@ -609,6 +636,8 @@ export function createViewer(
swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
+ // L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
+ applyLookAt(info);
// Safety net: nothing above ever intentionally pushes part of the body
// below the floor, so clamp the root up whenever the lowest point dips
// below y=0, a no-op whenever the pose is legitimately grounded or
@@ -709,8 +738,10 @@ export function createViewer(
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
+ authoredHead = timeline.bonesUsed.some((id) => id === "head" || id === "neck");
swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
+ applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] });
captureGroundTargets();
baseRootPos.copy(mannequin.root.position);
baseRootQuat.copy(mannequin.root.quaternion);
diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts
index 5c38cbf..6636290 100644
--- a/packages/posecode-render/test/contacts.test.ts
+++ b/packages/posecode-render/test/contacts.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import * as THREE from "three";
import { buildMannequin } from "../src/mannequin.js";
-import { levelPlantedFeet, relaxHands, swingArms } from "../src/contacts.js";
+import { levelPlantedFeet, relaxHands, swingArms, aimHead } from "../src/contacts.js";
import { groundFigure } from "../src/groundlock.js";
const DEG = Math.PI / 180;
@@ -105,3 +105,33 @@ describe("swingArms (L4.2)", () => {
expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5);
});
});
+
+describe("aimHead (L4.3 look-at)", () => {
+ it("turns the head toward a focus point (face +Z tracks the target)", () => {
+ const m = buildMannequin();
+ m.root.updateMatrixWorld(true);
+ const head = m.bones.get("head")!;
+ const headPos = head.getWorldPosition(new THREE.Vector3());
+ const focus = headPos.clone().add(new THREE.Vector3(0, 1.2, 0.6));
+ const faceDir = () =>
+ new THREE.Vector3(0, 0, 1)
+ .applyQuaternion(head.getWorldQuaternion(new THREE.Quaternion()))
+ .normalize();
+ const want = focus.clone().sub(headPos).normalize();
+ const before = faceDir().dot(want);
+ aimHead(m, focus);
+ m.root.updateMatrixWorld(true);
+ expect(faceDir().dot(want)).toBeGreaterThan(before);
+ });
+
+ it("clamps the look so the head never spins past its range", () => {
+ const m = buildMannequin();
+ m.root.updateMatrixWorld(true);
+ const head = m.bones.get("head")!;
+ const headPos = head.getWorldPosition(new THREE.Vector3());
+ const behind = headPos.clone().add(new THREE.Vector3(0, 0, -2));
+ aimHead(m, behind);
+ const e = new THREE.Euler().setFromQuaternion(m.bones.get("head")!.quaternion, "XYZ");
+ expect(Math.hypot(e.x, e.y, e.z)).toBeLessThan(1.2);
+ });
+});
From e89f576f1116f3ff68b0158b986fd1a7314a48bd Mon Sep 17 00:00:00 2001
From: a-baran-orhan
Date: Sun, 12 Jul 2026 16:37:37 +0300
Subject: [PATCH 25/25] feat(L1): wire 6 Mixamo mocap clips; gitignore FBX
binaries; opt-in moves via clip directive
Clips sourced from Mixamo, kept out of the repo (loaded from public/clips, gitignored for
storage/CDN). Mapped walk/squat/bicycle-crunch/jab-cross/jumping-jacks/shuffling in the
playground; squat/jumping-jacks/jab-cross/bicycle-crunch opt in with clip "".
Verified: squat plays the retargeted Back Squat clip (clipActive=true), no console errors.
---
.gitignore | 5 +++++
playground/src/main.ts | 13 ++++++++++++-
spec/examples/bicycle-crunch.posecode | 1 +
spec/examples/jab-cross.posecode | 1 +
spec/examples/jumping-jacks.posecode | 1 +
spec/examples/squat.posecode | 1 +
6 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 3208869..1c4bebf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,8 @@ coverage/
.vite/
.vercel
.claude/worktrees/
+
+# Mocap clip binaries: sourced from Mixamo, loaded from storage/CDN, not committed.
+playground/public/clips/*.fbx
+playground/public/clips/*.glb
+playground/public/clips/.DS_Store
diff --git a/playground/src/main.ts b/playground/src/main.ts
index 47fbccb..1b221b6 100644
--- a/playground/src/main.ts
+++ b/playground/src/main.ts
@@ -548,7 +548,18 @@ void import("posecode-render").then(({ createViewer }) => {
// clips.ts). Only fetched when a loaded movement names the clip, so this
// never slows the default page. Disabled with the classic figure, which has
// no skinned mesh to retarget onto.
- ...(classicFigure ? {} : { clips: { walk: "/clips/walk.fbx" } }),
+ ...(classicFigure
+ ? {}
+ : {
+ clips: {
+ walk: "/clips/walk.fbx",
+ squat: "/clips/back-squat.fbx",
+ "bicycle-crunch": "/clips/bicycle-crunch.fbx",
+ "jab-cross": "/clips/jab-cross.fbx",
+ "jumping-jacks": "/clips/jumping-jacks.fbx",
+ shuffling: "/clips/shuffling.fbx",
+ },
+ }),
});
// Exposed for capture/e2e tooling (frame capture drives README GIFs).
(window as unknown as Record).__posecodeViewer = viewer;
diff --git a/spec/examples/bicycle-crunch.posecode b/spec/examples/bicycle-crunch.posecode
index 16fb155..3490708 100644
--- a/spec/examples/bicycle-crunch.posecode
+++ b/spec/examples/bicycle-crunch.posecode
@@ -1,5 +1,6 @@
posecode exercise "Bicycle crunch"
rig humanoid
+ clip "bicycle-crunch"
pose start = supine
step "Set" 0.8s ease-in-out:
diff --git a/spec/examples/jab-cross.posecode b/spec/examples/jab-cross.posecode
index d9ea3a2..3bf43c6 100644
--- a/spec/examples/jab-cross.posecode
+++ b/spec/examples/jab-cross.posecode
@@ -1,5 +1,6 @@
posecode exercise "Jab-cross"
rig humanoid
+ clip "jab-cross"
pose start = standing
step "Jab" 0.4s ease-out:
diff --git a/spec/examples/jumping-jacks.posecode b/spec/examples/jumping-jacks.posecode
index 6656a02..9612439 100644
--- a/spec/examples/jumping-jacks.posecode
+++ b/spec/examples/jumping-jacks.posecode
@@ -1,5 +1,6 @@
posecode exercise "Jumping jacks"
rig humanoid
+ clip "jumping-jacks"
pose start = standing
step "Out" 0.5s ease-out:
diff --git a/spec/examples/squat.posecode b/spec/examples/squat.posecode
index 5699bf9..a4def3f 100644
--- a/spec/examples/squat.posecode
+++ b/spec/examples/squat.posecode
@@ -1,5 +1,6 @@
posecode exercise "Body-weight squat"
rig humanoid
+ clip "squat"
pose start = standing
step "Descend" 1.6s settle: