Skip to content

Commit 44e689e

Browse files
Merge pull request #7 from a-baran-orhan/claude/market-research-expansion-3xwm6w
2 parents d0735ef + d9b7cac commit 44e689e

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)