Skip to content

Commit dda0bd4

Browse files
committed
Fix landing preview and planted movement
1 parent da0fb02 commit dda0bd4

17 files changed

Lines changed: 158 additions & 48 deletions

File tree

packages/posecode-eval/src/checks.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,33 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
326326
},
327327
],
328328
},
329+
{
330+
// A demi-plié keeps the turnout established at the hips while both feet
331+
// remain planted. Checking each ankle independently catches the symmetric
332+
// outward/inward skate that a center-only ground-lock metric cannot see.
333+
movement: "demi-plie",
334+
checks: [
335+
(result) => {
336+
if (result.phases.length < 2) {
337+
return { id: "demi-plie-feet-stay-planted", pass: false, detail: "plié phases missing" };
338+
}
339+
let maxDrift = 0;
340+
for (let i = 1; i < result.phases.length; i++) {
341+
for (const side of ["left", "right"] as const) {
342+
maxDrift = Math.max(
343+
maxDrift,
344+
footSkateDistance(result.phases[i - 1]!, result.phases[i]!, side),
345+
);
346+
}
347+
}
348+
return {
349+
id: "demi-plie-feet-stay-planted",
350+
pass: maxDrift < 0.015,
351+
detail: `${maxDrift.toFixed(3)}m maximum ankle drift (want < 0.015m)`,
352+
};
353+
},
354+
],
355+
},
329356
{
330357
// Three-point landing: the front sole, rear knee, and opposite fist must
331358
// form distinct supports while the torso stays above the floor. Contact
@@ -339,6 +366,13 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
339366
(v) => v >= 65 && v <= 100,
340367
"65–100° character-forward pitch",
341368
),
369+
phaseCheck(
370+
"planted-fist-palm-inward",
371+
"Hold the landing",
372+
(p) => palmInwardAngleDeg(p, "left"),
373+
(v) => v < 30,
374+
"< 30° from inward",
375+
),
342376
(result) => {
343377
const p = phase(result, "Hold the landing");
344378
if (!p) return { id: "exact-three-supports", pass: false, detail: "phase \"Hold the landing\" not found" };

packages/posecode-eval/test/eval.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,14 @@ describe("fixture scorecard", () => {
286286
expect(movement.passed).toBeLessThan(movement.total);
287287
expect(failed.some((check) => check.id.startsWith("contact-position:"))).toBe(true);
288288
});
289+
290+
it("rejects a superhero landing whose planted fist faces away from the body", () => {
291+
const fixtures = loadFixtures(examplesDir);
292+
const source = fixtures.find((fixture) => fixture.movement === "superhero-landing")!.source;
293+
const outward = source.replace("elbow_left: pronate 80", "elbow_left: supinate 80");
294+
const check = runEval([{ movement: "superhero-landing", source: outward }])
295+
.movements[0]!.checks.find((item) => item.id === "planted-fist-palm-inward");
296+
297+
expect(check).toEqual(expect.objectContaining({ pass: false }));
298+
});
289299
});

packages/posecode-language/src/vocab.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
5555
posecode: 'Document header: `posecode <kind> "<name>"`.',
5656
rig: "Selects the rig (currently `humanoid`).",
5757
prop: "Adds a scene object: `prop chair | wall | bar | box | dip-bars`. Supplies declared reach, pin, and grip anchors.",
58-
pose: "Sets the starting pose: `pose start = standing | neutral | plank | supine | prone | seated`.",
58+
pose: "Sets the starting pose: `pose start = standing | first-position | neutral | plank | supine | prone | seated`.",
5959
start: "Used in `pose start = <pose>`.",
6060
clip: 'Optional mocap clip: `clip "walk"`. A renderer with a matching retargeted animation plays it crossfaded over the procedural pose; others ignore it.',
6161
step: 'A movement phase: `step "<name>" <Ns> <mode>:` where mode is flow | settle | drive | snap | linear.',

packages/posecode-mcp/src/guide.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ say that Posecode cannot yet represent the missing capability.
4848
posecode <kind> "<Name>" # kind = exercise | stretch | posture
4949
rig humanoid
5050
prop <type> # optional: chair | wall | bar | box | dip-bars
51-
pose start = <pose> # neutral | standing | plank | supine | prone | seated
51+
pose start = <pose> # neutral | standing | first-position | plank | supine | prone | seated
5252
step "<Phase>" <Ns> <mode>: # mode = flow | settle | drive | snap | linear
5353
<joint>: <action> <degrees>
5454
ground-lock: <contacts> # repeat feet/hands/forearms/back or side-specific supports

packages/posecode-parser/src/protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type RigName = (typeof RIG_NAMES)[number];
99
export const START_POSE_NAMES = [
1010
"neutral",
1111
"standing",
12+
"first-position",
1213
"plank",
1314
"supine",
1415
"prone",

packages/posecode-render/src/poses.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ const STANDING: PoseSpec = {
5252
joints: { ...RELAXED_FOREARMS },
5353
};
5454

55+
// Ballet first position: legs externally rotated from the hips while the
56+
// straight-leg chain stays vertical. Seeding turnout in the base pose means a
57+
// demi-plié can keep it constant instead of visibly twisting planted feet on
58+
// every descent and rise.
59+
const FIRST_POSITION: PoseSpec = {
60+
root: { position: [0, 0, 0], rotationDeg: [0, 0, 0] },
61+
joints: {
62+
...RELAXED_FOREARMS,
63+
hip_left: [0, 30, 0],
64+
hip_right: [0, -30, 0],
65+
},
66+
};
67+
5568
// Lying face-up. Rotating the standing figure -90° about X lays it on its back:
5669
// the original front (+Z) ends up facing the ceiling (+Y) and the head points
5770
// toward -Z. groundFigure() (bounding-box drop) then rests the back on the floor.
@@ -81,6 +94,7 @@ const SEATED: PoseSpec = {
8194
const POSES: Record<string, PoseSpec> = {
8295
neutral: NEUTRAL,
8396
standing: STANDING,
97+
"first-position": FIRST_POSITION,
8498
plank: PLANK,
8599
supine: SUPINE,
86100
prone: PRONE,

packages/posecode-render/test/render.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from "vitest";
2+
import fs from "node:fs";
23
import * as THREE from "three";
34
import { buildMannequin } from "../src/mannequin.js";
45
import { buildTimeline } from "../src/timeline.js";
@@ -473,6 +474,49 @@ describe("ground-lock (shared solver)", () => {
473474
return m;
474475
}
475476

477+
it("keeps the canonical demi-plié turned out without foot friction", () => {
478+
const source = fs.readFileSync(
479+
new URL("../../../spec/examples/demi-plie.posecode", import.meta.url),
480+
"utf8",
481+
);
482+
const { ir, errors } = parse(source);
483+
expect(errors).toEqual([]);
484+
const timeline = buildTimeline(ir!);
485+
const m = buildMannequin();
486+
487+
expect(timeline.basePose.joints?.hip_left?.[1]).toBe(30);
488+
expect(timeline.basePose.joints?.hip_right?.[1]).toBe(-30);
489+
490+
timeline.sample(0, m.bones);
491+
groundFigure(m);
492+
const basePosition = m.root.position.clone();
493+
const baseRotation = m.root.quaternion.clone();
494+
const anchors = new Map(
495+
["ankle_left", "ankle_right"].map((id) => [
496+
id,
497+
m.bones.get(id)!.getWorldPosition(new THREE.Vector3()),
498+
]),
499+
);
500+
501+
let maxDrift = 0;
502+
for (let t = 0; t < timeline.duration; t += 0.025) {
503+
m.root.position.copy(basePosition);
504+
m.root.quaternion.copy(baseRotation);
505+
const info = timeline.sample(t, m.bones);
506+
m.root.updateMatrixWorld(true);
507+
applyGroundLock(m, info.groundLock, anchors);
508+
levelPlantedFeet(m, info.groundLock);
509+
m.root.updateMatrixWorld(true);
510+
for (const id of ["ankle_left", "ankle_right"]) {
511+
const point = m.bones.get(id)!.getWorldPosition(new THREE.Vector3());
512+
const anchor = anchors.get(id)!;
513+
maxDrift = Math.max(maxDrift, Math.hypot(point.x - anchor.x, point.z - anchor.z));
514+
}
515+
}
516+
517+
expect(maxDrift).toBeLessThan(0.01);
518+
});
519+
476520
it("drops the body and plants the foot mesh for a feet-only squat", () => {
477521
const m = posedRaw(
478522
[

playground/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,14 @@ <h1 class="headline">
177177
<div class="studio-hud"><span id="hero-phase">Drop into the landing</span></div>
178178
</div>
179179

180-
<!-- Overlapping code chip: an excerpt from the contact phase on screen -->
180+
<!-- Contact-phase excerpt, kept below the viewer so it never hides the motion. -->
181181
<pre class="code-chip" aria-hidden="true"># contact-phase excerpt
182182
<span class="t-kw">posecode</span> <span class="t-kind">posture</span> <span class="t-str">"Superhero Three-Point Landing"</span>
183183
<span class="t-kw">pose</span> start <span class="t-punct">=</span> <span class="t-atom">standing</span>
184184

185185
<span class="t-kw">step</span> <span class="t-str">"Make three-point contact"</span> <span class="t-num">0.3s</span> <span class="t-atom">settle</span><span class="t-punct">:</span>
186-
<span class="t-kw">pin</span><span class="t-punct">:</span> <span class="t-atom">knee_left floor</span>
187-
<span class="t-kw">reach</span><span class="t-punct">:</span> <span class="t-atom">foot_right floor</span>
186+
<span class="t-kw">ground-lock</span><span class="t-punct">:</span> <span class="t-atom">foot_right</span>
187+
<span class="t-kw">reach</span><span class="t-punct">:</span> <span class="t-atom">knee_left floor</span>
188188
<span class="t-kw">reach</span><span class="t-punct">:</span> <span class="t-atom">fist_left floor</span>
189189
<span class="t-kw">cue</span> <span class="t-str">"Set all three contacts"</span></pre>
190190
</div>

playground/public/llm-guide.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ <h2>Grammar</h2>
129129
<pre class="code-block"><code>posecode &lt;kind&gt; &quot;&lt;Name&gt;&quot; # kind = exercise | stretch | posture
130130
rig humanoid
131131
prop &lt;type&gt; # optional: chair | wall | bar | box | dip-bars (repeatable)
132-
pose start = &lt;pose&gt; # neutral | standing | plank | supine | prone | seated
132+
pose start = &lt;pose&gt; # neutral | standing | first-position | plank | supine | prone | seated
133133
step &quot;&lt;Phase name&gt;&quot; &lt;Ns&gt; &lt;mode&gt;: # mode = flow | settle | drive | snap | linear
134134
&lt;joint&gt;: &lt;action&gt; &lt;degrees&gt;
135135
reach: &lt;effector&gt; &lt;target&gt; # limb IK to a landmark, floor, or declared prop anchor
@@ -209,7 +209,7 @@ <h2>Reaching, props, lying poses &amp; hands</h2>
209209
ground-lock: feet
210210
cue &quot;Hinge and reach toward the ankles&quot;</code></pre>
211211
<p>A hand or fist sent to the floor is also oriented onto its palm or knuckles. That explicit surface contact can adjust forearm/wrist roll within ROM, so do not fight it with a contradictory palm-facing cue.</p>
212-
<ul><li><strong>Props</strong>: <code>prop chair | wall | bar | box | dip-bars</code> (top level). The chair sits behind the figure (sit-to-stand, box squat), the wall behind that (wall sit), the bar overhead, the box in front (step-ups), and the dip bars either side at hip-press height (<code>grip: hands bars</code> + elbow flex = triceps dips).</li><li><strong>Pins</strong>: <code>pin: &lt;effector&gt; &lt;anchor&gt;</code> moves the whole BODY so the effector sits on the anchor (vs <code>reach</code>, which moves just the limb). Use one primary pin for body translation: <code>pin: foot_right box</code> can support a step-up, and <code>pin: pelvis floor</code> can keep the pelvis on the mat. Use <code>grip</code>, not several simultaneous hand pins, for a bar or rails.</li><li><strong>Grips</strong>: <code>grip: hands bar</code> or <code>grip: hands bars</code> is the dedicated two-hand prop contact. It assigns separate left/right anchors, solves both arms, and closes the fingers. Declare <code>prop bar</code> or <code>prop dip-bars</code> first. Prefer this to multiple pins for a pull-up, hang, or dip.</li><li><strong>Lying / seated</strong>: <code>pose start = supine | prone | seated</code> for floor and mat work (glute bridge, dead bug, cobra, seated forward fold). In a supine exercise whose torso stays down, add <code>ground-lock: back</code> to each phase.</li><li><strong>Hands</strong>: <code>fingers: flex 80</code> makes a fist; curl individual fingers for shapes (<code>index_right: flex 95</code>). Single-DOF per finger, good for grip and rough gesture, not exact sign language.</li></ul>
212+
<ul><li><strong>Props</strong>: <code>prop chair | wall | bar | box | dip-bars</code> (top level). The chair sits behind the figure (sit-to-stand, box squat), the wall behind that (wall sit), the bar overhead, the box in front (step-ups), and the dip bars either side at hip-press height (<code>grip: hands bars</code> + elbow flex = triceps dips).</li><li><strong>Pins</strong>: <code>pin: &lt;effector&gt; &lt;anchor&gt;</code> moves the whole BODY so the effector sits on the anchor (vs <code>reach</code>, which moves just the limb). Use one primary pin for body translation: <code>pin: foot_right box</code> can support a step-up, and <code>pin: pelvis floor</code> can keep the pelvis on the mat. Use <code>grip</code>, not several simultaneous hand pins, for a bar or rails.</li><li><strong>Grips</strong>: <code>grip: hands bar</code> or <code>grip: hands bars</code> is the dedicated two-hand prop contact. It assigns separate left/right anchors, solves both arms, and closes the fingers. Declare <code>prop bar</code> or <code>prop dip-bars</code> first. Prefer this to multiple pins for a pull-up, hang, or dip.</li><li><strong>Dance turnout</strong>: <code>pose start = first-position</code> for ballet movements that must keep the legs externally rotated without twisting planted feet</li><li><strong>Lying / seated</strong>: <code>pose start = supine | prone | seated</code> for floor and mat work (glute bridge, dead bug, cobra, seated forward fold). In a supine exercise whose torso stays down, add <code>ground-lock: back</code> to each phase.</li><li><strong>Hands</strong>: <code>fingers: flex 80</code> makes a fist; curl individual fingers for shapes (<code>index_right: flex 95</code>). Single-DOF per finger, good for grip and rough gesture, not exact sign language.</li></ul>
213213
<h2>Three-point superhero landing</h2>
214214
<p>A landing is defined by its contacts, not by a dramatic cue. Keep the existing front-foot support planted while reach constraints blend the rear knee and same-side fist down. Once the knee has arrived, hand the whole-body anchor to that knee and solve the foot and fist independently. Repeat the three contacts through the hold; never combine a whole-root pin with <code>ground-lock</code> in one step.</p>
215215
<pre class="code-block" data-lang="posecode"><code>posecode posture &quot;Superhero Three-Point Landing&quot;

playground/public/moves/demi-plie.html

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,11 @@ <h2>Movement phases</h2>
137137
<ol class="steps">
138138
<li>
139139
<span class="step-name">Plié<span class="step-dur">2.2s · settle</span></span>
140-
<span class="step-cue">Turn out from the hips and bend the knees over the toes: heels down</span>
140+
<span class="step-cue">Lower straight down with the knees tracking the turned-out toes and both heels anchored</span>
141141
</li>
142142
<li>
143143
<span class="step-name">Straighten<span class="step-dur">2.2s · settle</span></span>
144-
<span class="step-cue">Press the floor away and rise to a tall first position</span>
144+
<span class="step-cue">Press the floor away and rise without changing the turnout or moving the feet</span>
145145
</li>
146146
</ol>
147147

@@ -150,29 +150,29 @@ <h2>The .posecode source</h2>
150150
angles, not 3D transforms.</p>
151151
<pre class="code-block"><code>posecode exercise &quot;Demi-plié&quot;
152152
rig humanoid
153-
pose start = standing
153+
pose start = first-position
154154

155155
step &quot;Plié&quot; 2.2s settle:
156-
hips: flex 20
157-
hips: rotate-out 30
156+
hips: flex 31.5
157+
hips: abduct 17
158158
knees: flex 55
159159
ankles: dorsiflex 15
160160
shoulders: abduct 70
161161
elbows: flex 15
162162
spine: extend 4
163163
ground-lock: feet
164-
cue &quot;Turn out from the hips and bend the knees over the toes: heels down&quot;
164+
cue &quot;Lower straight down with the knees tracking the turned-out toes and both heels anchored&quot;
165165

166166
step &quot;Straighten&quot; 2.2s settle:
167167
hips: flex 0
168-
hips: rotate-out 0
168+
hips: abduct 0
169169
knees: flex 0
170-
ankles: plantarflex 0
170+
ankles: dorsiflex 0
171171
shoulders: abduct 0
172172
elbows: flex 0
173-
spine: flex 0
173+
spine: extend 0
174174
ground-lock: feet
175-
cue &quot;Press the floor away and rise to a tall first position&quot;
175+
cue &quot;Press the floor away and rise without changing the turnout or moving the feet&quot;
176176

177177
repeat 4
178178
</code></pre>

0 commit comments

Comments
 (0)