Skip to content

Commit bce3202

Browse files
Merge pull request #64 from posecode-dev/agent/support-back-ground-lock
Support back ground lock
2 parents 4897c43 + 0c7af6f commit bce3202

18 files changed

Lines changed: 169 additions & 31 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ describe("metrics", () => {
7474

7575
const pullUp = movement("pull-up");
7676
expect(Math.min(...pullUp.phases.map((p) => headPropClearance(pullUp, p)))).toBeGreaterThan(-0.01);
77+
78+
// A dead bug moves opposite limbs while the torso remains planted. This
79+
// catches the old silent `ground-lock: back` no-op, where whole-figure
80+
// floor reconciliation could move the torso with whichever limb was lowest.
81+
const deadBug = movement("dead-bug");
82+
for (const id of ["pelvis", "chest"]) {
83+
const heights = deadBug.phases.map((p) => p.bones.get(id)![1]);
84+
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(0.01);
85+
}
7786
});
7887
});
7988

packages/posecode-language/src/vocab.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const KINDS = ["exercise", "stretch", "posture"];
2222
/** Recognised start poses (`pose start = ...`). */
2323
export const POSES = ["neutral", "standing", "plank", "supine", "prone", "seated"];
2424

25-
/** Effectors that can be ground-locked. */
25+
/** Floor contacts that can be ground-locked. */
2626
export const EFFECTORS = [...GROUND_LOCK_EFFECTOR_NAMES];
2727

2828
/** Reach/pin effectors (groups + per-side aliases), sourced from the parser. */
@@ -50,7 +50,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
5050
snap: "Timing mode: fast, near-immediate arrival — an accent.",
5151
linear: "Timing mode: constant velocity — intentionally mechanical.",
5252
repeat: "How many times the movement loops.",
53-
"ground-lock": "Pins grouped or per-side effectors to the floor: `feet`, `foot_left` (also written `left foot`), `hands`, or `forearms`. Planted feet auto-level unless the ankle is intentionally plantarflexed.",
53+
"ground-lock": "Keeps grouped, per-side, or axial contacts on the floor: `feet`, `foot_left` (also `left foot`), `hands`, `forearms`, or `back` for supine work. Planted feet auto-level unless intentionally plantarflexed.",
5454
reach:
5555
"Drives an effector to a target via ROM-constrained IK: `reach: hand_left ankle_left`, `reach: hands floor`.",
5656
pin: "Moves the body so an effector sits on an anchor: `pin: hands bar` (hang, pull up, step up, dip).",

packages/posecode-language/test/language.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,15 @@ describe("getCompletions", () => {
7575

7676
it("suggests effectors after `ground-lock: `", () => {
7777
expect(onLine(" ground-lock: ", 17)).toEqual(
78-
expect.arrayContaining(["hands", "feet", "hand_left", "hand_right", "foot_left", "foot_right"]),
78+
expect.arrayContaining([
79+
"hands",
80+
"feet",
81+
"hand_left",
82+
"hand_right",
83+
"foot_left",
84+
"foot_right",
85+
"back",
86+
]),
7987
);
8088
});
8189

packages/posecode-mcp/src/guide.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ posecode <kind> "<Name>" # kind = exercise | stretch | posture
3737
pose start = <pose> # neutral | standing | plank
3838
step "<Phase>" <Ns> <mode>: # mode = flow | settle | drive | snap | linear
3939
<joint>: <action> <degrees>
40-
ground-lock: <effectors> # feet/hands/forearms, or one side: foot_left / left foot
40+
ground-lock: <contacts> # feet/hands/forearms/back, or foot_left / left foot
4141
cue "<short coaching cue>"
4242
repeat <count>
4343
\`\`\`
@@ -47,4 +47,7 @@ wrists hips knees ankles. Actions (degrees are absolute targets): flex/extend,
4747
abduct/adduct, rotate-in/rotate-out, dorsiflex/plantarflex, hold neutral, and
4848
hinge (hips only, closed-chain hip flexion: torso tips over planted feet with
4949
a neutral spine; use for deadlift / forward fold instead of hips: flex).
50-
Stay within healthy range of motion; the renderer hard-clamps anything beyond.`;
50+
Stay within healthy range of motion; the renderer hard-clamps anything beyond.
51+
Use ground-lock: feet when standing, hands and feet in a high plank, forearms
52+
and feet in a forearm plank, and back for supine floor work such as a dead bug.
53+
Do not invent other contact names.`;

packages/posecode-parser/src/joints.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,10 @@ const EFFECTOR_GROUPS: Record<string, string[]> = {
121121
export const EFFECTOR_NAMES = [...Object.keys(EFFECTOR_GROUPS), ...EFFECTOR_SIDES];
122122

123123
/**
124-
* Effectors accepted by `ground-lock:`. Ground locking has historically
124+
* Contacts accepted by `ground-lock:`. Ground locking has historically
125125
* supported the symmetric hand/forearm/foot groups; per-side aliases let a
126126
* movement keep one support planted while the opposite limb moves freely.
127+
* `back` is an axial surface contact for supine floor work.
127128
*/
128129
export const GROUND_LOCK_EFFECTOR_NAMES = [
129130
"hands",
@@ -135,6 +136,7 @@ export const GROUND_LOCK_EFFECTOR_NAMES = [
135136
"feet",
136137
"foot_left",
137138
"foot_right",
139+
"back",
138140
] as const;
139141

140142
const GROUND_LOCK_EFFECTOR_SET = new Set<string>(GROUND_LOCK_EFFECTOR_NAMES);

packages/posecode-parser/src/parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
232232
if (head === "ground-lock") {
233233
if (!current) return { line: ln.line, message: "`ground-lock` outside of a step" };
234234
if (t[1]?.type !== "colon") {
235-
return { line: ln.line, message: "expected `ground-lock: <effectors>`" };
235+
return { line: ln.line, message: "expected `ground-lock: <contacts>`" };
236236
}
237237
const words = t.slice(2).filter((tok) => tok.type === "word").map((tok) => tok.value);
238238
const effectors: string[] = [];
@@ -255,7 +255,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
255255
effectors.push(value);
256256
}
257257
if (effectors.length === 0) {
258-
return { line: ln.line, message: "`ground-lock` requires at least one effector" };
258+
return { line: ln.line, message: "`ground-lock` requires at least one contact" };
259259
}
260260
current.groundLock = effectors;
261261
current.groundLockLine = ln.line;

packages/posecode-parser/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export interface Phase {
6767
durationSec: number;
6868
easing: Easing;
6969
targets: JointTarget[];
70-
/** Grouped or per-side floor effectors pinned for this phase, e.g. ["feet"] or ["foot_right"]. */
70+
/** Grouped, per-side, or axial floor contacts held this phase, e.g. ["foot_right"] or ["back"]. */
7171
groundLock: string[];
7272
/** Reach-IK goals active during this phase. */
7373
reaches: ReachTarget[];

packages/posecode-parser/test/parse.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,29 @@ describe("reach/pin effectors", () => {
263263
});
264264
});
265265

266+
describe("ground-lock contacts", () => {
267+
it("accepts back as a supine floor contact", () => {
268+
const result = parse(`posecode exercise "Dead bug"
269+
rig humanoid
270+
pose start = supine
271+
step "Extend" 1s settle:
272+
ground-lock: back`);
273+
expect(result.errors).toEqual([]);
274+
expect(result.ir?.phases[0]?.groundLock).toEqual(["back"]);
275+
});
276+
277+
it("rejects unknown contacts instead of silently ignoring them", () => {
278+
const result = parse(`posecode posture "Floor"
279+
rig humanoid
280+
step "Hold" 1s linear:
281+
ground-lock: shoulders`);
282+
expect(result.ir).toBeNull();
283+
expect(result.errors).toEqual([
284+
expect.objectContaining({ line: 4, message: expect.stringContaining("shoulders") }),
285+
]);
286+
});
287+
});
288+
266289
describe("clip directive", () => {
267290
const doc = (clipLine: string): string =>
268291
[

packages/posecode-render/src/groundlock.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* render loop and the headless eval harness (posecode-eval) use the identical
44
* solver.
55
*
6-
* "Ground-lock" = keep the declared grouped or per-side effectors planted while the
7-
* body moves, tuned per support type:
6+
* "Ground-lock" = keep the declared grouped, per-side, or axial contacts
7+
* planted while the body moves, tuned per support type:
88
*
99
* - **Hands + feet (push-up / plank):** pivot the whole rigid body about the
1010
* foot line (the toes stay planted) until the hands reach the floor. As the
@@ -23,6 +23,8 @@
2323
* the heels). Only feet near the floor anchor (a swing leg in a curl or
2424
* march must stay free), and only the average delta is corrected so
2525
* symmetric spreads (jumping jacks) don't fight the lock.
26+
* - **Back (dead bug / supine floor work):** translate the body vertically so
27+
* the pelvis-to-ribcage surface stays on the floor while the limbs move.
2628
*
2729
* Both paths ground the visible MESH (bounding boxes), not just bone origins:
2830
* an ankle bone sits ~0.04m above the sole, so anchoring bones alone left the
@@ -97,8 +99,14 @@ export function applyGroundLock(
9799
const hands = ids.filter((id) => id.startsWith("wrist"));
98100
const forearms = ids.filter((id) => id.startsWith("elbow"));
99101
const feet = ids.filter((id) => id.startsWith("ankle"));
102+
const back = ids.filter((id) => id === "pelvis" || id === "spine" || id === "chest");
100103
const upperSupports = forearms.length > 0 ? forearms : hands;
101104

105+
if (back.length > 0) {
106+
dropOwnMeshesToFloor(m, back);
107+
return;
108+
}
109+
102110
if (upperSupports.length > 0 && feet.length > 0) {
103111
// Plant the feet FIRST: drop the body so the foot mesh rests on the floor,
104112
// so the pivot the body then rotates about is itself at floor level. The
@@ -146,6 +154,37 @@ export function applyGroundLock(
146154
}
147155
}
148156

157+
/**
158+
* Drop only the meshes owned by the selected bones onto the floor. `Box3` on
159+
* a torso bone would include its child limbs, so an overhead arm could
160+
* otherwise lift a supine person's back. Bone child subtrees are deliberately
161+
* excluded; non-bone groups (capsules/ellipsoids) remain part of the surface.
162+
*/
163+
function dropOwnMeshesToFloor(m: Mannequin, boneIds: string[]): void {
164+
const boneNodes = new Set(m.bones.values());
165+
const box = new THREE.Box3();
166+
const childBox = new THREE.Box3();
167+
let found = false;
168+
169+
for (const id of boneIds) {
170+
const bone = m.bones.get(id);
171+
if (!bone) continue;
172+
for (const child of bone.children) {
173+
if (boneNodes.has(child)) continue;
174+
childBox.setFromObject(child);
175+
if (!childBox.isEmpty()) {
176+
box.union(childBox);
177+
found = true;
178+
}
179+
}
180+
}
181+
182+
if (found && Number.isFinite(box.min.y)) {
183+
m.root.position.y -= box.min.y;
184+
m.root.updateMatrixWorld(true);
185+
}
186+
}
187+
149188
/**
150189
* Drop the whole body vertically so the lowest FOOT-mesh point rests on the
151190
* floor. Grounds the visible sole (bounding box), not the ankle bone, which

packages/posecode-render/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
* `createViewer(canvas)` sets up a Three.js studio scene with the procedural
55
* mannequin and returns a controller. `load(ir)` builds a timeline from a parsed
66
* PosecodeIR; the render loop applies forward kinematics each frame, then keeps
7-
* ground-locked contacts (hands/feet) planted via floating-root solving. The
8-
* camera auto-frames the figure and eases smoothly when a new movement loads.
7+
* ground-locked contacts (hands/forearms/feet/back) planted via floating-root
8+
* solving. The camera auto-frames the figure and eases smoothly when a new
9+
* movement loads.
910
*/
1011

1112
import * as THREE from "three";
@@ -1055,6 +1056,11 @@ function contactBoneIds(info: {
10551056
ids.add("elbow_left");
10561057
ids.add("elbow_right");
10571058
}
1059+
if (effector === "back") {
1060+
ids.add("pelvis");
1061+
ids.add("spine");
1062+
ids.add("chest");
1063+
}
10581064
};
10591065
for (const group of info.groundLock) addEffector(group);
10601066
for (const contact of [...info.grips, ...info.pins]) addEffector(contact.effector);

0 commit comments

Comments
 (0)