Skip to content

Commit d9b7cac

Browse files
committed
Add spatial choreography: turn & travel primitives
The figure could bend, reach, hinge, and grip but was bolted to one spot facing one direction — so pirouettes, grapevines, box-steps, traveling combos, and walk cycles were all impossible, the biggest ceiling for dance and locomotion. This adds two per-phase primitives that let the figure turn and move across the floor: - `turn: <deg>` rotates the figure's facing (yaw about vertical). - `travel: <x> <z>` moves it across the floor (world metres from the load spot). Both are absolute targets carried forward across phases (like joint angles) and both return home on the loop wrap, so a box-step traces a square back to start and a pirouette spins a clean full turn. Plumbing mirrors the existing reach/pin primitives end to end: parser (parseStepChild + AstStep), schema, IR (Phase.turnDeg / Phase.travel, travel clamped to a ±3m studio footprint), timeline (yaw/pos accumulate alongside joints and interpolate with the same eased param), and the renderer, which layers the sampled yaw and X/Z offset onto the base root each frame before ground-lock. It composes cleanly because the feet ground-lock only corrects the root's *vertical* height, never X/Z — so the figure turns and steps while its feet still rest on the floor. Loop seamlessness: the wrap keyframe returns facing to the NEAREST FULL TURN to the final yaw rather than to 0, so a completed 360° pirouette holds its facing through the reset (360°≡0° at the loop boundary) instead of visibly un-spinning backward. Scope: standing poses only — combining turn/travel with lying/seated bases (whose root is already tilted) is documented as out of scope, like the existing seated+hinge limit. New movements (7): pirouette, box-step, grapevine, waltz-box, chasse (Dance) and walk-cycle (a walk-out/about-face/walk-back) + quarter-turns (Locomotion). Camera framing widens by the movement's travel extent so a traveling figure stays in view. Verified: 152/152 tests pass (13 new parser/render tests for turn/travel sampling, clamping, and the home default); every example parses ROM-clean; a full Playwright sweep of all 72 movements confirms the figures actually turn and travel (live pelvis X/Z + root yaw telemetry) and that grounding holds with zero floor-clipping — the additive change regressed none of the existing 65 movements.
1 parent d0735ef commit d9b7cac

20 files changed

Lines changed: 550 additions & 12 deletions

File tree

ROADMAP.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ domain needs — so contributions land where they unlock the most.
1717
| **Fitness / strength** | Body-weight and free-form movement coaching | ✅ Core today (squat, curl, raise). Barbell/dumbbell/machine work needs props + grip. |
1818
| **Functional / elderly care** | Sit-to-stand, balance, gentle ROM, fall-prevention drills | 🟡 Partial — sit-to-stand works; reaching/balance need reach-IK + props. |
1919
| **Sports technique** | Golf swing, tennis serve, throwing, kicking | 🟡 Partial — needs trunk rotation fidelity, weight shift, and implements (club/racket/ball). |
20-
| **Dance / choreography** | Notating sequences, port de bras, simple phrases | 🟡 Simple gestures work; precise reach + partner work are future. |
20+
| **Dance / choreography** | Notating sequences, port de bras, phrases that turn & travel | ✅ Phrases, port de bras, pirouettes, and traveling combos (box-step, grapevine, chassé) work via `turn`/`travel`; partner work is future. |
2121
| **Martial arts** | Stances, strikes, basic forms | 🟡 Stances/strikes partly work; contact and weapons are future. |
2222
| **Sign language / gesture** | Finger-spelling, signs, expressive gesture | ⛔ Needs a hand/finger rig (the rig currently ends at the wrist). |
2323

@@ -43,7 +43,14 @@ These are the unlocks, roughly in order of leverage:
4343
5. ~~**Hand / finger articulation**~~ — ✅ **shipped (single-DOF).** Per-finger
4444
curl bones + `fingers` group. Powers `make-a-fist`, `pinch-grip`, `hand-wave`,
4545
`finger-spell-demo`. Next: multi-joint fingers for accurate sign language.
46-
6. **Two-person + collision** — partner stretches, assisted rehab, contact sports
46+
6. ~~**Spatial choreography (turn & travel)**~~ — ✅ **shipped.** `turn: <deg>`
47+
rotates the figure's facing and `travel: <x> <z>` moves it across the floor,
48+
both absolute + carried across phases and returning home on the loop wrap.
49+
Powers `pirouette`, `box-step`, `grapevine`, `waltz-box`, `chasse`,
50+
`walk-cycle`, `quarter-turns` — pirouettes, traveling combos, and gait.
51+
Standing poses only. Next: footstep-locked travel (true gait), motion
52+
aliveness (velocity-continuous flow + weight shift).
53+
7. **Two-person + collision** — partner stretches, assisted rehab, contact sports
4754
(still deferred in the spec).
4855

4956
## Prop / equipment library (future)

packages/movit-language/src/vocab.ts

Lines changed: 3 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", "pin", "cue"];
28+
export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel", "cue"];
2929

3030
/** Short docs surfaced on hover and as completion detail. */
3131
export const KEYWORD_DOCS: Record<string, string> = {
@@ -39,6 +39,8 @@ export const KEYWORD_DOCS: Record<string, string> = {
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`.",
4141
pin: "Moves the body so an effector sits on an anchor — `pin: hand_left bar` (hang, pull up, step up, dip).",
42+
turn: "Turns the figure to face a new direction — `turn: 360` (degrees, yaw about vertical). Absolute, carried across phases. Standing poses only.",
43+
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.",
4244
cue: "A short coaching cue shown while this phase plays.",
4345
hold: "Keep the joint at its neutral / rest angle.",
4446
};

packages/movit-parser/src/clamp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ function resolveStep(
114114
euler,
115115
}));
116116

117+
// Travel is clamped to a sane studio footprint (±TRAVEL_MAX m) so a stray
118+
// large value can't fling the figure off the ground plane / out of frame.
119+
const travel = step.travel
120+
? {
121+
x: clampNum(step.travel.x, -TRAVEL_MAX, TRAVEL_MAX),
122+
z: clampNum(step.travel.z, -TRAVEL_MAX, TRAVEL_MAX),
123+
}
124+
: undefined;
125+
117126
return {
118127
name: step.name,
119128
durationSec: step.durationSec,
@@ -122,10 +131,19 @@ function resolveStep(
122131
groundLock: step.groundLock,
123132
reaches: step.reaches,
124133
pins: step.pins,
134+
...(step.turn !== undefined ? { turnDeg: step.turn } : {}),
135+
...(travel ? { travel } : {}),
125136
...(step.cue ? { cue: step.cue } : {}),
126137
};
127138
}
128139

140+
/** Max travel offset from the load spot, metres, in any single axis. */
141+
const TRAVEL_MAX = 3;
142+
143+
function clampNum(v: number, min: number, max: number): number {
144+
return Math.min(max, Math.max(min, v));
145+
}
146+
129147
function ensure(map: Map<string, EulerDeg>, bone: string): EulerDeg {
130148
let euler = map.get(bone);
131149
if (!euler) {

packages/movit-parser/src/parser.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export interface AstStep {
3434
groundLock: string[];
3535
reaches: AstReach[];
3636
pins: AstPin[];
37+
/** Root facing (yaw about world Y, degrees) at the end of this phase. */
38+
turn?: number;
39+
/** Root ground position (world X/Z metres) at the end of this phase. */
40+
travel?: { x: number; z: number };
3741
cue?: string;
3842
line: number;
3943
}
@@ -224,6 +228,28 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
224228
return null;
225229
}
226230

231+
if (head === "turn") {
232+
// `turn: <degrees>` — the figure's facing (root yaw about world Y) at the
233+
// end of this phase. Absolute, accumulated forward like a joint target.
234+
if (!current) return { line: ln.line, message: "`turn` outside of a step" };
235+
if (t[1]?.type !== "colon" || t[2]?.type !== "num") {
236+
return { line: ln.line, message: "expected `turn: <degrees>`" };
237+
}
238+
current.turn = Number(t[2].value);
239+
return null;
240+
}
241+
242+
if (head === "travel") {
243+
// `travel: <x> <z>` — the figure's ground position (world X/Z metres) at the
244+
// end of this phase. Absolute offset from the load spot, accumulated forward.
245+
if (!current) return { line: ln.line, message: "`travel` outside of a step" };
246+
if (t[1]?.type !== "colon" || t[2]?.type !== "num" || t[3]?.type !== "num") {
247+
return { line: ln.line, message: "expected `travel: <x> <z>`" };
248+
}
249+
current.travel = { x: Number(t[2].value), z: Number(t[3].value) };
250+
return null;
251+
}
252+
227253
// Joint target: `<joint>: <action> [<degrees>]`
228254
if (!current) {
229255
return { line: ln.line, message: "joint target outside of a step" };

packages/movit-parser/src/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const stepSchema = z.object({
3838
groundLock: z.array(z.string()),
3939
reaches: z.array(reachSchema),
4040
pins: z.array(pinSchema),
41+
turn: z.number().optional(),
42+
travel: z.object({ x: z.number(), z: z.number() }).optional(),
4143
cue: z.string().optional(),
4244
line: z.number(),
4345
});

packages/movit-parser/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ export interface Phase {
5959
reaches: ReachTarget[];
6060
/** Contact pins active during this phase (translate the body to the anchor). */
6161
pins: PinTarget[];
62+
/**
63+
* Root facing (yaw about world Y, degrees) at the end of this phase — an
64+
* absolute target carried forward across phases. Powers turns / pirouettes.
65+
*/
66+
turnDeg?: number;
67+
/**
68+
* Root ground position (world X/Z metres, offset from the load spot) at the
69+
* end of this phase — absolute, carried forward. Powers travel / locomotion.
70+
*/
71+
travel?: { x: number; z: number };
6272
cue?: string;
6373
}
6474

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,45 @@ describe("parse", () => {
124124
const { errors } = parse(src);
125125
expect(errors.some((e) => /easing/i.test(e.message))).toBe(true);
126126
});
127+
128+
it("parses turn and travel into the phase IR", () => {
129+
const src = [
130+
'movit exercise "Spin & step"',
131+
" rig humanoid",
132+
" pose start = standing",
133+
' step "Spin" 1s ease-in-out:',
134+
" turn: 360",
135+
" travel: -0.4 0.5",
136+
" ground-lock: feet",
137+
" repeat 2",
138+
].join("\n");
139+
const { ir, errors, warnings } = parse(src);
140+
expect(errors).toEqual([]);
141+
expect(warnings).toEqual([]);
142+
const phase = ir!.phases[0]!;
143+
expect(phase.turnDeg).toBe(360);
144+
expect(phase.travel).toEqual({ x: -0.4, z: 0.5 });
145+
});
146+
147+
it("clamps travel to the studio footprint", () => {
148+
const src = [
149+
'movit exercise "Runaway"',
150+
" rig humanoid",
151+
' step "Go" 1s linear:',
152+
" travel: 99 -99",
153+
].join("\n");
154+
const { ir } = parse(src);
155+
expect(ir!.phases[0]!.travel).toEqual({ x: 3, z: -3 });
156+
});
157+
158+
it("errors on malformed turn/travel", () => {
159+
const src = [
160+
'movit exercise "Bad"',
161+
" rig humanoid",
162+
' step "Go" 1s linear:',
163+
" travel: 0.4",
164+
].join("\n");
165+
const { errors } = parse(src);
166+
expect(errors.some((e) => /travel/i.test(e.message))).toBe(true);
167+
});
127168
});

packages/movit-render/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ export function createViewer(
217217
}
218218

219219
const ROOT_X = new THREE.Vector3(1, 0, 0);
220+
// Reused scratch for the per-frame facing rotation (yaw about world Y).
221+
const WORLD_Y = new THREE.Vector3(0, 1, 0);
222+
const YAW_Q = new THREE.Quaternion();
220223

221224
/** Rotate the whole figure about a world-space pivot (axis through pivot). */
222225
function rotateRootAboutPivot(pivot: THREE.Vector3, angle: number): void {
@@ -385,8 +388,11 @@ export function createViewer(
385388
const center = box.getCenter(new THREE.Vector3());
386389
const size = box.getSize(new THREE.Vector3());
387390
// Frame against a ~1.8m standing height floor so short poses (squat,
388-
// plank) don't zoom in awkwardly; fill most of the viewport.
389-
const radius = Math.max(size.x, size.y, size.z, 1.8) * 0.5;
391+
// plank) don't zoom in awkwardly; fill most of the viewport. Traveling
392+
// movements (turn/travel) roam across the floor, so widen the frame by the
393+
// movement's travel extent to keep the figure in view the whole loop.
394+
const travel = timeline?.travelExtent ?? 0;
395+
const radius = Math.max(size.x, size.y, size.z, 1.8) * 0.5 + travel;
390396
const dist = (radius / Math.sin((camera.fov * DEG) / 2)) * 1.15 + 0.3;
391397

392398
desiredTarget.copy(center);
@@ -405,6 +411,17 @@ export function createViewer(
405411
// Recompute root contact from the grounded base each frame (no drift).
406412
mannequin.root.position.copy(baseRootPos);
407413
mannequin.root.quaternion.copy(baseRootQuat);
414+
// Spatial choreography: layer the phase's facing (yaw about world Y) and
415+
// ground travel (world X/Z) onto the base root BEFORE ground-lock. The
416+
// feet-only ground-lock only corrects the root's Y, so it composes with
417+
// travel (X/Z) and yaw without fighting them; the figure turns and steps
418+
// across the floor while its feet still rest on it.
419+
if (info.rootYaw !== 0) {
420+
YAW_Q.setFromAxisAngle(WORLD_Y, info.rootYaw);
421+
mannequin.root.quaternion.premultiply(YAW_Q);
422+
}
423+
mannequin.root.position.x += info.rootOffset.x;
424+
mannequin.root.position.z += info.rootOffset.z;
408425
mannequin.root.updateMatrixWorld(true);
409426
applyGroundLock(info.groundLock);
410427
applyPins(info.pins);

packages/movit-render/src/timeline.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ interface Keyframe {
2525
groundLock: string[];
2626
reaches: ReachTarget[];
2727
pins: PinTarget[];
28+
/** Root facing (yaw about world Y, radians) at this keyframe. */
29+
yaw: number;
30+
/** Root ground offset (world X/Z metres) from the load spot at this keyframe. */
31+
pos: { x: number; z: number };
2832
}
2933

3034
/** A phase as a time span on the timeline, for scrubber markers / ribbon. */
@@ -52,7 +56,13 @@ export interface BuiltTimeline {
5256
groundLock: string[];
5357
reaches: ReachTarget[];
5458
pins: PinTarget[];
59+
/** Interpolated root facing (yaw about world Y, radians). */
60+
rootYaw: number;
61+
/** Interpolated root ground offset (world X/Z metres) from the load spot. */
62+
rootOffset: { x: number; z: number };
5563
};
64+
/** Largest travel offset magnitude reached (metres) — for camera framing. */
65+
travelExtent: number;
5666
}
5767

5868
function eulerToQuat([x, y, z]: EulerDegTuple): THREE.Quaternion {
@@ -76,6 +86,11 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
7686

7787
// Accumulating current joint angles (degrees).
7888
const curr = new Map<string, EulerDegTuple>(baseJoints);
89+
// Accumulating root facing (yaw, degrees) and ground offset (metres), both
90+
// carried forward across phases like joints and seeded at home (0).
91+
let currYaw = 0;
92+
let currPos = { x: 0, z: 0 };
93+
let travelExtent = 0;
7994

8095
const keyframes: Keyframe[] = [];
8196
keyframes.push({
@@ -86,13 +101,18 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
86101
groundLock: [],
87102
reaches: [],
88103
pins: [],
104+
yaw: 0,
105+
pos: { x: 0, z: 0 },
89106
});
90107

91108
let t = 0;
92109
for (const phase of ir.phases) {
93110
for (const target of phase.targets) {
94111
curr.set(target.boneId, [target.euler.x, target.euler.y, target.euler.z]);
95112
}
113+
if (phase.turnDeg !== undefined) currYaw = phase.turnDeg;
114+
if (phase.travel) currPos = { x: phase.travel.x, z: phase.travel.z };
115+
travelExtent = Math.max(travelExtent, Math.hypot(currPos.x, currPos.z));
96116
t += phase.durationSec;
97117
keyframes.push({
98118
time: t,
@@ -103,11 +123,18 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
103123
groundLock: phase.groundLock,
104124
reaches: phase.reaches,
105125
pins: phase.pins,
126+
yaw: currYaw * DEG,
127+
pos: { ...currPos },
106128
});
107129
}
108130

109-
// Wrap back to the base pose for a seamless loop.
131+
// Wrap back to the base pose (and home position) for a seamless loop. Facing
132+
// wraps to the NEAREST FULL TURN to the final yaw, not to 0: a completed 360°
133+
// pirouette then holds its facing through the reset and the loop boundary
134+
// (360°≡0°) is seamless, instead of visibly un-spinning backward. A partial
135+
// turn (e.g. 90°) rounds to 0 and rotates back to front during the reset.
110136
const wrap = ir.phases[0]?.durationSec ?? 1;
137+
const wrapYaw = Math.round(currYaw / 360) * 360 * DEG;
111138
t += wrap;
112139
keyframes.push({
113140
time: t,
@@ -117,6 +144,8 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
117144
groundLock: [],
118145
reaches: [],
119146
pins: [],
147+
yaw: wrapYaw,
148+
pos: { x: 0, z: 0 },
120149
});
121150

122151
// Fill every keyframe with the full bone set (missing → identity).
@@ -147,6 +176,7 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
147176
basePose,
148177
bonesUsed,
149178
segments,
179+
travelExtent,
150180
sample(time, bones) {
151181
const tt = duration > 0 ? ((time % duration) + duration) % duration : 0;
152182
let a = keyframes[0]!;
@@ -167,12 +197,22 @@ export function buildTimeline(ir: MovitIR): BuiltTimeline {
167197
if (!node) continue;
168198
node.quaternion.slerpQuaternions(a.quats.get(bone)!, b.quats.get(bone)!, eased);
169199
}
200+
// Root facing/position: linear interpolation of the raw values so a large
201+
// turn (e.g. 360°) sweeps the whole way round rather than taking a short
202+
// arc. Uses the same eased param as the joints so everything moves as one.
203+
const rootYaw = a.yaw + (b.yaw - a.yaw) * eased;
204+
const rootOffset = {
205+
x: a.pos.x + (b.pos.x - a.pos.x) * eased,
206+
z: a.pos.z + (b.pos.z - a.pos.z) * eased,
207+
};
170208
return {
171209
phaseName: b.name,
172210
...(b.cue ? { cue: b.cue } : {}),
173211
groundLock: b.groundLock,
174212
reaches: b.reaches,
175213
pins: b.pins,
214+
rootYaw,
215+
rootOffset,
176216
};
177217
},
178218
};

0 commit comments

Comments
 (0)