|
| 1 | +# L3.1 — Foot-Flat (Plantigrade) Correction (Design) |
| 2 | + |
| 3 | +**Date:** 2026-07-11 |
| 4 | +**Status:** Approved (design) |
| 5 | +**Sub-project:** Layer 3, slice 1 of the 5-layer animation-naturalness program |
| 6 | +**Branch:** `feat/l3-post-ik` (stacked on `feat/l2-spline-interpolation`) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 1. Context and motivation |
| 11 | + |
| 12 | +Posecode builds a base pose from procedural FK (soon also mocap clips), then a stack of |
| 13 | +contact solvers in the viewer frame loop makes it touch the world |
| 14 | +(`packages/posecode-render/src/index.ts`, `frame()`): `depenetrate → applyGroundLock → |
| 15 | +applyPins → applyReaches → alignFloorPalms → floor clamp`. |
| 16 | + |
| 17 | +Ground-lock (`groundlock.ts`) plants feet by resting the **lowest mesh point** on `y=0`. |
| 18 | +When knee/hip flex tilts the rigidly-attached foot toe-down (a squat, lunge, sit-to-stand, |
| 19 | +deadlift), the ball of the foot becomes the lowest point, so the figure **balances on its |
| 20 | +toes** — the reported squat-on-toes bug. Nothing keeps the sole plane parallel to the floor. |
| 21 | + |
| 22 | +Real feet stay plantigrade (flat) while the shin travels over them; in mocap this is baked in. |
| 23 | +Posecode needs a procedural equivalent. |
| 24 | + |
| 25 | +**Goal:** keep planted soles level with the floor regardless of shin angle, so grounded |
| 26 | +lower-body movements rest flat — without disturbing authored leg angles, tiptoe moves, or |
| 27 | +swing feet. |
| 28 | + |
| 29 | +### Non-goals (deferred) |
| 30 | + |
| 31 | +- Bar grip / two-point anchors / finger wrap — that is **L3.2**. |
| 32 | +- Look-at — folds into **L4**. |
| 33 | +- Full leg re-IK (ground-lock deliberately never CCD-solves legs; we keep that). |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## 2. Approach |
| 38 | + |
| 39 | +### 2.1 Mechanism — an ankle-orientation correction (analog of `alignFloorPalms`) |
| 40 | + |
| 41 | +`contacts.ts` already has `alignFloorPalms`, which rotates a floor-contacting wrist so the |
| 42 | +palm normal points into the floor (`DOWN`). Foot-flat is the direct analog for feet: a new |
| 43 | +`levelPlantedFeet` that rotates each planted ankle so the **sole normal points world-down**, |
| 44 | +which lays the whole sole flat. It preserves the foot's yaw (toe direction) and the leg's |
| 45 | +authored hip/knee flex — it only removes the pitch/roll the leg chain induced in the foot. |
| 46 | + |
| 47 | +The sole sits at ankle local `-Y` (see `addShoe` in `mannequin.ts`: shoe box at local |
| 48 | +`(0,-0.036,0.05)`), so the sole-down direction is the ankle's local `-Y`. Leveling aligns |
| 49 | +that local `-Y` to world `DOWN` with the minimal rotation (`setFromUnitVectors`), exactly as |
| 50 | +`alignFloorPalms` aligns the palm normal — the minimal rotation leaves yaw intact. |
| 51 | + |
| 52 | +### 2.2 Planted-ness soft blend (the natural, better-rendering variant) |
| 53 | + |
| 54 | +A hard snap would force-level a foot that is legitimately lifting (swing foot in a lunge, |
| 55 | +marching knee raise, the airborne leg of a kick). Instead the correction is **weighted by how |
| 56 | +planted the foot is**, mirroring the research's distance-based IK blending: |
| 57 | + |
| 58 | +- Compute each foot's mesh-bottom height `y` (bbox min, as ground-lock does). |
| 59 | +- `weight = clamp01((PLANT_FADE - y) / PLANT_FADE)` — `1` when the sole is on the floor, |
| 60 | + fading to `0` as it rises past `PLANT_FADE` (a swing foot is left alone). |
| 61 | +- Apply the leveling rotation `slerp`ed by `weight`, so a lifting foot smoothly relaxes back |
| 62 | + to its authored orientation. |
| 63 | + |
| 64 | +### 2.3 Tiptoe opt-out |
| 65 | + |
| 66 | +Some moves are deliberately on the toes: relevé, calf-raise, demi-plié, plantarflex dance |
| 67 | +phases. Foot-flat must not flatten those. Rule: **skip leveling when the ankle carries a |
| 68 | +meaningful authored plantarflex angle.** Plantarflexion rotates the ankle about local X in the |
| 69 | +toe-down direction; at frame time we read the ankle bone's local Euler X and, if it exceeds |
| 70 | +`PLANTARFLEX_SKIP` (toe-down beyond a small threshold), leave the foot as authored. So |
| 71 | +`ankles: plantarflex 30` opts out naturally, a squat that never plantarflexes gets leveled — |
| 72 | +**no new DSL keyword, no library rewrite required.** |
| 73 | + |
| 74 | +### 2.4 Frame-loop placement |
| 75 | + |
| 76 | +`levelPlantedFeet` runs **after** `applyGroundLock`/`applyPins`/`applyReaches` (so the foot is |
| 77 | +in its final planted spot and the legs hold their solved pose) and **before** the final |
| 78 | +vertical floor clamp (so the now-level sole is what gets rested on `y=0`). It sits next to the |
| 79 | +existing `alignFloorPalms` call in `frame()`, and is also invoked once in `load()` so the |
| 80 | +initial captured pose is already flat. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## 3. Components and boundaries |
| 85 | + |
| 86 | +### 3.1 `packages/posecode-render/src/contacts.ts` (modified) |
| 87 | + |
| 88 | +New exported function, no new file (it is the same concern as `alignFloorPalms`, ~40 lines): |
| 89 | + |
| 90 | +```ts |
| 91 | +export function levelPlantedFeet( |
| 92 | + m: Mannequin, |
| 93 | + activeGroundLock: readonly string[], |
| 94 | +): void |
| 95 | +``` |
| 96 | + |
| 97 | +- **Purpose:** for each ground-locked foot, rotate the ankle so the sole is horizontal, |
| 98 | + weighted by planted-ness, skipped on authored plantarflex. |
| 99 | +- **Depends on:** `three`, `Mannequin`. Reuses module constants. |
| 100 | +- **Constants (named, exported for tests):** `PLANT_FADE = 0.06` (m), `PLANTARFLEX_SKIP` |
| 101 | + (radians, ~`15°`), sole-normal local axis `(0,-1,0)`. |
| 102 | + |
| 103 | +### 3.2 `packages/posecode-render/src/index.ts` (modified) |
| 104 | + |
| 105 | +- Import `levelPlantedFeet`; call it in `frame()` after `alignFloorPalms(...)` and before the |
| 106 | + final bbox floor clamp, passing `info.groundLock`. |
| 107 | +- Call it once in `load()` after `groundFigureOf(mannequin)` so the captured base is flat. |
| 108 | + |
| 109 | +### 3.3 Editor discoverability (DSL side, no new syntax) |
| 110 | + |
| 111 | +- `packages/posecode-language/src/vocab.ts`: extend the `ground-lock` `KEYWORD_DOCS` entry to |
| 112 | + note that planted feet auto-level to the floor unless the ankle is plantarflexed (so the |
| 113 | + behavior is discoverable on hover/completion). |
| 114 | + |
| 115 | +### 3.4 Documents |
| 116 | + |
| 117 | +- Fix `spec/examples/squat.posecode`: the authored `ankles: plantarflex 50` forces tiptoe and |
| 118 | + is biomechanically wrong for a squat (the shin dorsiflexes over a flat foot). Remove it / |
| 119 | + set to a small dorsiflexion so foot-flat lands the sole. Keep it as the demo. |
| 120 | +- Verify relevé / calf-raise still tiptoe (their authored plantarflex opts out). |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +## 4. Data flow |
| 125 | + |
| 126 | +``` |
| 127 | +frame(): |
| 128 | + base pose (squad FK) → depenetrate → applyGroundLock (plant feet) |
| 129 | + → applyPins → applyReaches → alignFloorPalms |
| 130 | + → levelPlantedFeet(m, info.groundLock) ← NEW: level each planted sole (weighted, opt-out) |
| 131 | + → floor clamp (rest the flat sole on y=0) |
| 132 | +``` |
| 133 | +
|
| 134 | +## 5. Error handling |
| 135 | +
|
| 136 | +- Missing ankle bone / no ground-locked feet → no-op. |
| 137 | +- Degenerate rotation (sole already vertical, cross ~0) → `setFromUnitVectors` handles |
| 138 | + antiparallel; guard NaN and fall back to identity (no correction) as `alignFloorPalms` does. |
| 139 | +- Swing foot (weight ~0) → correction ~identity, foot keeps authored orientation. |
| 140 | +
|
| 141 | +## 6. Testing (TDD) |
| 142 | +
|
| 143 | +Write first, watch fail, implement: |
| 144 | +
|
| 145 | +1. **Levels a tilted planted foot:** author a squat-like pose (knee/hip flex, foot tilted); |
| 146 | + after `levelPlantedFeet`, the sole normal is within tolerance of world-up (`0,1,0`). |
| 147 | +2. **Plantarflex opt-out:** a foot with authored `ankles: plantarflex 30` is left unchanged. |
| 148 | +3. **Swing foot unaffected:** a foot lifted above `PLANT_FADE` keeps its authored orientation. |
| 149 | +4. **Squat rests flat end-to-end:** load the squat IR, sample the descend keyframe, run the |
| 150 | + frame solve; the foot mesh bbox min.y ≈ 0 and the sole is level (not ball-only contact). |
| 151 | +5. **Relevé still on toes:** the relevé example keeps a plantarflexed, non-level foot. |
| 152 | +6. **Existing suites stay green** (render, eval invariants, parser, language). |
| 153 | +
|
| 154 | +Manual: browser-verify squat rests flat, relevé stays on toes, no console errors. |
| 155 | +
|
| 156 | +## 7. Risks |
| 157 | +
|
| 158 | +- **Ankle over-rotation past ROM:** leveling could push the ankle beyond healthy ROM on an |
| 159 | + extreme knee bend. Mitigation: clamp the corrected ankle Euler to the ankle ROM |
| 160 | + (`eulerRomFor("ankle_*")`) after leveling, widened to admit the authored angle (same pattern |
| 161 | + as reach-IK's `jointLimitsFor`). |
| 162 | +- **Interaction with `alignFloorPalms` ordering:** feet and palms are independent bones; no |
| 163 | + conflict. Both run before the clamp. |
| 164 | +- **Plantarflex threshold tuning:** `PLANTARFLEX_SKIP` chosen so relevé/calf-raise opt out but |
| 165 | + a near-zero incidental ankle angle in a squat still levels; verified against the library. |
| 166 | +
|
| 167 | +## 8. Definition of done |
| 168 | +
|
| 169 | +- `levelPlantedFeet` implemented + wired into `frame()` and `load()`. |
| 170 | +- Tests 1–5 pass; all existing suites green; typecheck clean. |
| 171 | +- Squat demo rests flat; relevé/calf-raise still tiptoe; browser-verified, no console errors. |
0 commit comments