Skip to content

Commit 87ed5d0

Browse files
Add contact-pin primitive: pull-ups, step-ups, dips, true hangs (#3)
The root-translation capability that step-ups / dips / pull-ups needed. A plain "lift the body" scalar wouldn't do them — those need a CONTACT to stay pinned (foot on box, hands on bar) while the body moves relative to it. So this adds a `pin: <effector> <anchor>` directive: the renderer translates the whole figure so the pinned effector sits on its anchor, letting the body hang, climb, or lower as the joints work. Engine: - IR: Phase.pins threaded through types/parser/schema/clamp/timeline (mirrors reaches). New `pin: <effector> <anchor>` step-child. - Renderer: applyPins() translates the root so pinned effectors meet anchors; runs after ground-lock, before reach-IK. Also fixed a latent root-state issue — the frame loop now resets the root to its grounded base each frame so ground-lock/pin/reach never accumulate (and the body returns to base when a pin ends). - props: raised the pull-up bar to 2.3 m (above standing reach) with support posts, so a pinned grip genuinely hangs the body below it. Movements (all verified against the real rig): pull-up (pelvis climbs ~0.34 m, feet leave the floor), step-up (body rises ~0.32 m onto the box), triceps-dips (hips lower), and dead-hang / hanging-knee-raise now truly suspended. Docs + vocab: `pin` registered in SPEC, llm-authoring, vocab; coverage-gap updated. Tests: pin parsing + a pull-up rise assertion; 140 passing, build green. Claude-Session: https://claude.ai/code/session_018WCktDrKYZpJjCb2apbqWp Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f65ee1 commit 87ed5d0

19 files changed

Lines changed: 316 additions & 51 deletions

File tree

docs/coverage-gap.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,24 @@ Ordered by gap size × how cleanly the engine renders it:
5151
These ~10 movements roughly **double our chest/core/back coverage** — all on
5252
today's engine, no new primitives required.
5353

54-
**Front box prop (added):** a `prop box` placed in front of the figure now powers
54+
**Front box prop (added):** a `prop box` placed in front of the figure powers
5555
`box-step-taps` (the lead foot taps the box top — verified landing on the anchor).
5656

57-
**Still deferred:** a true `step-up` and `triceps-dips`. Beyond prop placement,
58-
both need the figure's whole body to **translate vertically** (rise onto the box /
59-
lower into the dip), and the rig has no authorable root-height channel yet — the
60-
pelvis is fixed and only ground-lock nudges it. So a step-up reads as a step-*tap*
61-
and a dip can't lower. These return with a root-translation primitive (a `lift` /
62-
body-height channel) — a clean roadmap item, not a forced low-fidelity render.
57+
**Contact pins (added) — the root-translation primitive.** `pin: <effector>
58+
<anchor>` translates the whole body so a pinned hand/foot stays on its anchor
59+
while the limbs work, which is exactly the vertical body motion that was missing.
60+
This unlocked, all verified through the real rig:
61+
62+
- `pull-up` — hang from the bar (feet ~0.38 m off the floor), elbows flex → pelvis
63+
climbs ~0.34 m toward the bar.
64+
- `step-up` — lead foot pinned to the box top; the leg straightens → the body
65+
rises ~0.32 m onto the box, trailing foot lifting off.
66+
- `triceps-dips` — hands pinned to the chair seat; elbows bend → hips lower.
67+
- `dead-hang` / `hanging-knee-raise` — now genuinely suspended from the bar.
68+
69+
**Still deferred:** free-flight moves (jumps, burpees) where the body leaves *all*
70+
contacts — those want an authored whole-body `lift` channel (no anchor), a small
71+
follow-on to the pin work.
6372

6473
## Metadata gap (drives the catalogue work)
6574

packages/movit-language/src/vocab.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const PROPS = ["chair", "wall", "bar", "box"];
2525
export const TOP_KEYWORDS = ["rig", "prop", "pose", "step", "repeat"];
2626

2727
/** Keywords valid as step children. */
28-
export const CHILD_KEYWORDS = ["ground-lock", "reach", "cue"];
28+
export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "cue"];
2929

3030
/** Short docs surfaced on hover and as completion detail. */
3131
export const KEYWORD_DOCS: Record<string, string> = {
@@ -38,6 +38,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
3838
repeat: "How many times the movement loops.",
3939
"ground-lock": "Pins effectors (hands / feet) to the floor for this phase.",
4040
reach: "Drives an effector to a target via IK — `reach: hand_left ankle_left`.",
41+
pin: "Moves the body so an effector sits on an anchor — `pin: hand_left bar` (hang, pull up, step up, dip).",
4142
cue: "A short coaching cue shown while this phase plays.",
4243
hold: "Keep the joint at its neutral / rest angle.",
4344
};

packages/movit-parser/src/clamp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ function resolveStep(
121121
targets,
122122
groundLock: step.groundLock,
123123
reaches: step.reaches,
124+
pins: step.pins,
124125
...(step.cue ? { cue: step.cue } : {}),
125126
};
126127
}

packages/movit-parser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type {
3737
EulerDeg,
3838
JointTarget,
3939
ReachTarget,
40+
PinTarget,
4041
Phase,
4142
MovitIR,
4243
Warning,

packages/movit-parser/src/parser.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@ export interface AstReach {
2121
target: string;
2222
}
2323

24+
export interface AstPin {
25+
effector: string;
26+
anchor: string;
27+
}
28+
2429
export interface AstStep {
2530
name: string;
2631
durationSec: number;
2732
easing: string;
2833
targets: AstJointTarget[];
2934
groundLock: string[];
3035
reaches: AstReach[];
36+
pins: AstPin[];
3137
cue?: string;
3238
line: number;
3339
}
@@ -152,6 +158,7 @@ export function parseToAst(source: string): ParseAstResult {
152158
targets: [],
153159
groundLock: [],
154160
reaches: [],
161+
pins: [],
155162
line: ln.line,
156163
};
157164
doc.steps.push(current);
@@ -205,6 +212,18 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
205212
return null;
206213
}
207214

215+
if (head === "pin") {
216+
// `pin: <effector> <anchor>` — translate the body so the effector sits there.
217+
if (!current) return { line: ln.line, message: "`pin` outside of a step" };
218+
const effector = t[2]?.type === "word" ? t[2].value : null;
219+
const anchor = t[3]?.type === "word" ? t[3].value : null;
220+
if (t[1]?.type !== "colon" || !effector || !anchor) {
221+
return { line: ln.line, message: "expected `pin: <effector> <anchor>`" };
222+
}
223+
current.pins.push({ effector, anchor });
224+
return null;
225+
}
226+
208227
// Joint target: `<joint>: <action> [<degrees>]`
209228
if (!current) {
210229
return { line: ln.line, message: "joint target outside of a step" };

packages/movit-parser/src/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,19 @@ const reachSchema = z.object({
2525
target: z.string().min(1),
2626
});
2727

28+
const pinSchema = z.object({
29+
effector: z.string().min(1),
30+
anchor: z.string().min(1),
31+
});
32+
2833
const stepSchema = z.object({
2934
name: z.string(),
3035
durationSec: z.number().positive(),
3136
easing: z.enum(EASINGS),
3237
targets: z.array(jointTargetSchema),
3338
groundLock: z.array(z.string()),
3439
reaches: z.array(reachSchema),
40+
pins: z.array(pinSchema),
3541
cue: z.string().optional(),
3642
line: z.number(),
3743
});

packages/movit-parser/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ export interface ReachTarget {
3636
target: string;
3737
}
3838

39+
/**
40+
* A contact pin: translate the whole figure so `effector` sits on a fixed
41+
* `anchor` (a prop anchor, a landmark, or `floor`). Unlike a reach (which moves
42+
* the limb to a target) a pin moves the BODY, so the figure can hang from a bar,
43+
* rise onto a box, or lower into a dip while the contact stays put.
44+
*/
45+
export interface PinTarget {
46+
effector: string;
47+
anchor: string;
48+
}
49+
3950
/** One concurrent phase of a movement (e.g. "Lower" in a push-up). */
4051
export interface Phase {
4152
name: string;
@@ -46,6 +57,8 @@ export interface Phase {
4657
groundLock: string[];
4758
/** Reach-IK goals active during this phase. */
4859
reaches: ReachTarget[];
60+
/** Contact pins active during this phase (translate the body to the anchor). */
61+
pins: PinTarget[];
4962
cue?: string;
5063
}
5164

packages/movit-render/src/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import * as THREE from "three";
1212
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
13-
import type { MovitIR, ReachTarget } from "movit-parser";
13+
import type { MovitIR, ReachTarget, PinTarget } from "movit-parser";
1414
import { buildMannequin, type Mannequin } from "./mannequin.js";
1515
import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js";
1616
import { solveCCD } from "./ik.js";
@@ -132,6 +132,11 @@ export function createViewer(
132132
// wall surface). Populated when a doc declares props; empty otherwise.
133133
let propAnchors = new Map<string, THREE.Vector3>();
134134
let propScene: PropScene | null = null;
135+
// The grounded base transform captured at load. Each frame resets the root to
136+
// this before ground-lock / pins / reach recompute, so those root adjustments
137+
// never accumulate across frames (and the body returns to base when a pin ends).
138+
const baseRootPos = new THREE.Vector3();
139+
const baseRootQuat = new THREE.Quaternion();
135140
let time = 0;
136141
let speed = 1;
137142
let playing = false;
@@ -325,6 +330,33 @@ export function createViewer(
325330
}
326331
}
327332

333+
/**
334+
* Contact pins: translate the WHOLE figure so each pinned effector sits on its
335+
* anchor. Where ground-lock keeps a planted foot on the floor, a pin keeps a
336+
* hand on the bar or a foot on the box while the body moves relative to it —
337+
* so the figure hangs from a bar, pulls up toward it, rises onto a box, or
338+
* lowers into a dip as the limb joints work. Applied after ground-lock (which
339+
* pinned movements normally omit) and before reach-IK.
340+
*/
341+
function applyPins(pins: PinTarget[]): void {
342+
if (pins.length === 0) return;
343+
const delta = new THREE.Vector3();
344+
let n = 0;
345+
for (const p of pins) {
346+
const effectorBone = EFFECTOR_BONE[p.effector] ?? p.effector;
347+
const effector = mannequin.bones.get(effectorBone);
348+
if (!effector) continue;
349+
const anchor = resolveReachTarget(p.anchor, effector);
350+
if (!anchor) continue;
351+
delta.add(anchor.sub(effector.getWorldPosition(new THREE.Vector3())));
352+
n++;
353+
}
354+
if (n > 0) {
355+
mannequin.root.position.add(delta.multiplyScalar(1 / n));
356+
mannequin.root.updateMatrixWorld(true);
357+
}
358+
}
359+
328360
function frameCamera(): void {
329361
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
330362
const box = new THREE.Box3().setFromObject(mannequin.root);
@@ -349,8 +381,12 @@ export function createViewer(
349381
function frame(): void {
350382
if (timeline) {
351383
const info = timeline.sample(time, mannequin.bones);
384+
// Recompute root contact from the grounded base each frame (no drift).
385+
mannequin.root.position.copy(baseRootPos);
386+
mannequin.root.quaternion.copy(baseRootQuat);
352387
mannequin.root.updateMatrixWorld(true);
353388
applyGroundLock(info.groundLock);
389+
applyPins(info.pins);
354390
applyReaches(info.reaches);
355391
if (info.phaseName !== lastPhaseName) {
356392
lastPhaseName = info.phaseName;
@@ -416,6 +452,8 @@ export function createViewer(
416452
mannequin.root.updateMatrixWorld(true);
417453
groundFigure();
418454
captureGroundTargets();
455+
baseRootPos.copy(mannequin.root.position);
456+
baseRootQuat.copy(mannequin.root.quaternion);
419457
frameCamera();
420458
},
421459
play() {

packages/movit-render/src/props.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,21 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
3939
group.add(seat, back, leg(mat, 0.18, -0.0), leg(mat, -0.18, -0.0), leg(mat, 0.18, -0.32), leg(mat, -0.18, -0.32));
4040
anchors.set("seat", new THREE.Vector3(0, seatH + 0.03, -0.12));
4141
} else if (type === "bar") {
42-
const barH = 1.95;
42+
// Above standing reach, so a pinned grip genuinely hangs the body below it.
43+
const barH = 2.3;
4344
const bar = new THREE.Mesh(
4445
new THREE.CylinderGeometry(0.025, 0.025, 1.2, 12),
4546
mat,
4647
);
4748
bar.rotation.z = Math.PI / 2; // horizontal, along X
4849
bar.position.set(0, barH, 0);
4950
group.add(bar);
51+
// Posts down to the floor so the bar reads as a pull-up frame.
52+
for (const x of [-0.55, 0.55]) {
53+
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, barH, 10), mat);
54+
post.position.set(x, barH / 2, 0);
55+
group.add(post);
56+
}
5057
anchors.set("bar", new THREE.Vector3(0, barH, 0));
5158
} else if (type === "wall") {
5259
const wall = box(2.2, 2.6, 0.1, mat);

packages/movit-render/src/timeline.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import * as THREE from "three";
11-
import type { MovitIR, ReachTarget } from "movit-parser";
11+
import type { MovitIR, ReachTarget, PinTarget } from "movit-parser";
1212
import { poseFor, type PoseSpec } from "./poses.js";
1313

1414
const DEG = Math.PI / 180;
@@ -24,6 +24,7 @@ interface Keyframe {
2424
quats: Map<string, THREE.Quaternion>;
2525
groundLock: string[];
2626
reaches: ReachTarget[];
27+
pins: PinTarget[];
2728
}
2829

2930
/** A phase as a time span on the timeline, for scrubber markers / ribbon. */
@@ -45,7 +46,13 @@ export interface BuiltTimeline {
4546
sample(
4647
t: number,
4748
bones: Map<string, THREE.Object3D>,
48-
): { phaseName: string; cue?: string; groundLock: string[]; reaches: ReachTarget[] };
49+
): {
50+
phaseName: string;
51+
cue?: string;
52+
groundLock: string[];
53+
reaches: ReachTarget[];
54+
pins: PinTarget[];
55+
};
4956
}
5057

5158
function eulerToQuat([x, y, z]: EulerDegTuple): THREE.Quaternion {
@@ -78,6 +85,7 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
7885
quats: snapshot(curr),
7986
groundLock: [],
8087
reaches: [],
88+
pins: [],
8189
});
8290

8391
let t = 0;
@@ -94,6 +102,7 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
94102
quats: snapshot(curr),
95103
groundLock: phase.groundLock,
96104
reaches: phase.reaches,
105+
pins: phase.pins,
97106
});
98107
}
99108

@@ -107,6 +116,7 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
107116
quats: snapshot(new Map(baseJoints)),
108117
groundLock: [],
109118
reaches: [],
119+
pins: [],
110120
});
111121

112122
// Fill every keyframe with the full bone set (missing → identity).
@@ -162,6 +172,7 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
162172
...(b.cue ? { cue: b.cue } : {}),
163173
groundLock: b.groundLock,
164174
reaches: b.reaches,
175+
pins: b.pins,
165176
};
166177
},
167178
};

0 commit comments

Comments
 (0)