Skip to content

Commit c085558

Browse files
committed
ROM-constrain reach-IK and add hands/feet group effectors
Reach-IK previously ran unconstrained CCD, so a solved arm or leg could exceed the healthy joint limits that authored angles are hard-clamped to — a documented hole in the project's core safety promise, and the roadmap's stated next unlock for reach. - movit-parser: new `eulerRomFor(boneId)` expresses each bone's ROM as a signed per-axis Euler box in the renderer's local frame, derived from the clinical ROM table with the exact action→axis/sign/mirror mapping the clamp pass uses. Axes with no ROM entry lock to [0, 0], so a knee or elbow solves as a true hinge. - movit-render: `solveCCD` accepts optional per-joint limits and clamps every chain joint back into its box each iteration; the viewer feeds reach chains their ROM boxes, widened to admit the authored FK start angle so IK never fights rig mechanics like the hip-hinge counter-rotation. - reach/pin effectors now accept `hands` / `feet` groups (expand to both sides), and unknown effector names are line-anchored parse errors instead of being silently ignored by the renderer. - language tooling: reach/pin effector completions, vocab sourced from the parser; spec, authoring guide, README, and roadmap updated. - fix the root `typecheck` script (pointed at a nonexistent solution tsconfig) and a `window: never` narrowing error in the landing page. Tests: 163 passing (11 new), including a sanity check that unconstrained CCD does violate the shoulder ceiling on the same target the constrained solve keeps safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164eTtmmq6U8GQkAEmQsvV5
1 parent 44e689e commit c085558

22 files changed

Lines changed: 449 additions & 65 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ For where Movit spreads fastest and the per-domain go-to-market plan, see
7676
## Scope (v0.1)
7777

7878
✅ Single-person movement across fitness, physio, desk, dance, education & rehab ·
79-
Mermaid-style DSL · ROM safety clamping · forward kinematics · ground-lock **and
80-
reach-to-target IK** · hip-hinge · lying/seated poses · scene props (chair/wall/
81-
bar) · a single-DOF hand rig · live playground.
79+
Mermaid-style DSL · ROM safety clamping (authored **and IK-solved** angles) ·
80+
forward kinematics · ground-lock **and ROM-constrained reach-to-target IK** ·
81+
hip-hinge · lying/seated poses · scene props (chair/wall/bar) · a single-DOF
82+
hand rig · live playground.
8283

8384
⏳ Deferred: two-person / partner movements + collision detection, deeper props
8485
(load, bands, rings), multi-joint fingers, FBX/GLB export, hosted SaaS editor and

ROADMAP.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ These are the unlocks, roughly in order of leverage:
2929
tips the torso forward over the hips while the legs stay planted (the renderer
3030
counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,
3131
and `bow`. Next: hinge with a loaded-bar prop.
32-
2. ~~**Reach-IK (reach a world target)**~~ — ✅ **shipped.** `reach: <effector>
33-
<target>` drives a hand/foot to a body landmark, the `floor`, or a prop anchor
34-
via CCD. Powers `touch-toes`, `cross-body-reach`, `seated-forward-fold`, and
35-
prop grips. Next: ROM-constrained reach + dual-hand targets.
32+
2. ~~**Reach-IK (reach a world target)**~~ — ✅ **shipped, now ROM-constrained.**
33+
`reach: <effector> <target>` drives a hand/foot to a body landmark, the
34+
`floor`, or a prop anchor via CCD. Powers `touch-toes`, `cross-body-reach`,
35+
`seated-forward-fold`, and prop grips. The solve clamps every chain joint
36+
into its Range-of-Motion box each iteration — solved angles obey the same
37+
hard limits as authored ones — and `hands` / `feet` reach or pin both sides
38+
in one line. Next: two-bone analytic solve for straighter elbows/knees.
3639
3. ~~**Scene props with contact anchors**~~ — ✅ **shipped (starter set).** `prop
3740
chair|wall|bar` adds a scene object with named anchors (`seat`, `wall`, `bar`).
3841
Powers `sit-to-stand`, `box-squat`, `wall-sit`, `dead-hang`, `hanging-knee-raise`.
@@ -77,8 +80,7 @@ Each prop is a small scene object + an anchor type; movements then reference it
7780
- One figure only; partner work and collision are still deferred.
7881
- A **starter** prop set (chair / wall / bar) — no bench, rings, bands, or loaded
7982
implements yet, and props sit at fixed default placements.
80-
- Reach-IK is **unconstrained** (no ROM limits on the solved chain) and props are
81-
visual + reach anchors (no physical sit/lean solve).
83+
- Props are visual + reach anchors (no physical sit/lean solve).
8284
- Fingers are **single-DOF** curls — good for grip and rough gesture, not exact
8385
sign language. The head has no facial articulation.
8486

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"coverage": "vitest run --coverage",
1616
"dev": "npm run dev -w playground",
1717
"build": "npm run build -w playground",
18-
"typecheck": "tsc -b --pretty"
18+
"typecheck": "for p in packages/*/tsconfig.json playground/tsconfig.json editors/*/tsconfig.json; do tsc --noEmit -p \"$p\" || exit 1; done"
1919
},
2020
"devDependencies": {
2121
"@vitest/coverage-v8": "^2.1.8",

packages/movit-language/src/completion.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
KINDS,
1010
POSES,
1111
EFFECTORS,
12+
REACH_EFFECTORS,
1213
EASINGS,
1314
JOINT_NAMES,
1415
ACTION_NAMES,
@@ -37,6 +38,7 @@ type Context =
3738
| "pose"
3839
| "easing"
3940
| "effector"
41+
| "reach-effector"
4042
| "action"
4143
| "joint"
4244
| "top"
@@ -49,6 +51,7 @@ function contextFor(prefix: string, line: number): Context {
4951
if (/^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose";
5052
if (/^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing";
5153
if (/^\s*ground-lock\s*:\s*[\w,\s-]*$/.test(prefix)) return "effector";
54+
if (/^\s*(reach|pin)\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector";
5255
if (/^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action";
5356

5457
if (/^\s*[\w-]*$/.test(prefix)) {
@@ -80,6 +83,8 @@ export function getCompletions(
8083
return EASINGS.map((e) => item(e, "easing"));
8184
case "effector":
8285
return EFFECTORS.map((e) => item(e, "effector"));
86+
case "reach-effector":
87+
return REACH_EFFECTORS.map((e) => item(e, "effector"));
8388
case "action":
8489
return [...ACTION_NAMES, "hold"].map((a) => item(a, "action"));
8590
case "joint":

packages/movit-language/src/vocab.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* drift from what the language actually accepts.
55
*/
66

7-
import { JOINT_NAMES, ACTION_NAMES, EASINGS } from "movit-parser";
7+
import { JOINT_NAMES, ACTION_NAMES, EASINGS, EFFECTOR_NAMES } from "movit-parser";
88

99
export { JOINT_NAMES, ACTION_NAMES, EASINGS };
1010

@@ -17,8 +17,8 @@ export const POSES = ["neutral", "standing", "plank", "supine", "prone", "seated
1717
/** Effectors that can be ground-locked. */
1818
export const EFFECTORS = ["hands", "feet"];
1919

20-
/** Reach effectors (friendly aliases) and the scene props that supply anchors. */
21-
export const REACH_EFFECTORS = ["hand_left", "hand_right", "foot_left", "foot_right"];
20+
/** Reach/pin effectors (groups + per-side aliases) — sourced from the parser. */
21+
export const REACH_EFFECTORS = EFFECTOR_NAMES;
2222
export const PROPS = ["chair", "wall", "bar", "box"];
2323

2424
/** Top-level directives (excluding the `movit` header keyword). */
@@ -37,8 +37,9 @@ export const KEYWORD_DOCS: Record<string, string> = {
3737
step: 'A movement phase — `step "<name>" <Ns> <easing>:`.',
3838
repeat: "How many times the movement loops.",
3939
"ground-lock": "Pins effectors (hands / feet) to the floor for this phase.",
40-
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).",
40+
reach:
41+
"Drives an effector to a target via ROM-constrained IK — `reach: hand_left ankle_left`, `reach: hands floor`.",
42+
pin: "Moves the body so an effector sits on an anchor — `pin: hands bar` (hang, pull up, step up, dip).",
4243
turn: "Turns the figure to face a new direction — `turn: 360` (degrees, yaw about vertical). Absolute, carried across phases. Standing poses only.",
4344
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.",
4445
cue: "A short coaching cue shown while this phase plays.",

packages/movit-language/test/language.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ describe("getCompletions", () => {
7878
expect.arrayContaining(["hands", "feet"]),
7979
);
8080
});
81+
82+
it("suggests reach effectors (groups + sides) after `reach: ` and `pin: `", () => {
83+
expect(onLine(" reach: ", 11)).toEqual(
84+
expect.arrayContaining(["hands", "hand_left", "foot_right"]),
85+
);
86+
expect(onLine(" pin: ", 9)).toEqual(expect.arrayContaining(["feet"]));
87+
});
8188
});
8289

8390
describe("getHover", () => {

packages/movit-parser/src/clamp.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ import type {
1313
MovitIR,
1414
ParseError,
1515
Phase,
16+
PinTarget,
17+
ReachTarget,
1618
Warning,
1719
} from "./types.js";
1820
import { MOVIT_VERSION } from "./types.js";
1921
import type { AstDoc, AstStep } from "./parser.js";
20-
import { actionAxis, boneType, expandJoint, flexionSign, isLeft } from "./joints.js";
22+
import {
23+
actionAxis,
24+
boneType,
25+
expandEffector,
26+
expandJoint,
27+
flexionSign,
28+
isLeft,
29+
} from "./joints.js";
2130
import { clampAngle, romFor } from "./rom.js";
2231

2332
export interface ResolveResult {
@@ -114,6 +123,28 @@ function resolveStep(
114123
euler,
115124
}));
116125

126+
// Reach / pin effectors: expand symmetric groups (`hands` → both hands) and
127+
// reject unknown names — a typo'd effector would otherwise be silently
128+
// ignored by the renderer, invisible to the authoring LLM.
129+
const reaches: ReachTarget[] = [];
130+
for (const r of step.reaches) {
131+
const sides = expandEffector(r.effector);
132+
if (sides.length === 0) {
133+
errors.push({ line: r.line, message: `unknown reach effector: "${r.effector}"` });
134+
continue;
135+
}
136+
for (const effector of sides) reaches.push({ effector, target: r.target });
137+
}
138+
const pins: PinTarget[] = [];
139+
for (const p of step.pins) {
140+
const sides = expandEffector(p.effector);
141+
if (sides.length === 0) {
142+
errors.push({ line: p.line, message: `unknown pin effector: "${p.effector}"` });
143+
continue;
144+
}
145+
for (const effector of sides) pins.push({ effector, anchor: p.anchor });
146+
}
147+
117148
// Travel is clamped to a sane studio footprint (±TRAVEL_MAX m) so a stray
118149
// large value can't fling the figure off the ground plane / out of frame.
119150
const travel = step.travel
@@ -129,8 +160,8 @@ function resolveStep(
129160
easing: step.easing as Phase["easing"],
130161
targets,
131162
groundLock: step.groundLock,
132-
reaches: step.reaches,
133-
pins: step.pins,
163+
reaches,
164+
pins,
134165
...(step.turn !== undefined ? { turnDeg: step.turn } : {}),
135166
...(travel ? { travel } : {}),
136167
...(step.cue ? { cue: step.cue } : {}),

packages/movit-parser/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ export {
5050
JOINT_GROUP_NAMES,
5151
JOINT_NAMES,
5252
ACTION_NAMES,
53+
EFFECTOR_NAMES,
5354
expandJoint,
55+
expandEffector,
5456
actionAxis,
5557
boneType,
5658
} from "./joints.js";
57-
export { romFor, clampAngle, type RomLimit } from "./rom.js";
59+
export { romFor, clampAngle, eulerRomFor, type RomLimit, type EulerRom } from "./rom.js";
5860
export { EASINGS } from "./schema.js";

packages/movit-parser/src/joints.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ export function expandJoint(name: string): string[] {
9595
return [];
9696
}
9797

98+
/** Per-side reach/pin effectors (friendly aliases the renderer maps to bones). */
99+
const EFFECTOR_SIDES = [
100+
"hand_left",
101+
"hand_right",
102+
"foot_left",
103+
"foot_right",
104+
] as const;
105+
106+
/** Symmetric effector groups → the per-side effectors they expand to. */
107+
const EFFECTOR_GROUPS: Record<string, string[]> = {
108+
hands: ["hand_left", "hand_right"],
109+
feet: ["foot_left", "foot_right"],
110+
};
111+
112+
/** Every effector name `reach:` / `pin:` accept: groups + per-side aliases. */
113+
export const EFFECTOR_NAMES = [...Object.keys(EFFECTOR_GROUPS), ...EFFECTOR_SIDES];
114+
115+
const EFFECTOR_SIDE_SET = new Set<string>(EFFECTOR_SIDES);
116+
117+
/**
118+
* Resolve a reach/pin effector name into per-side effectors (`hands` →
119+
* both hands). Returns an empty array if the name is unknown (caller errors).
120+
*/
121+
export function expandEffector(name: string): string[] {
122+
const group = EFFECTOR_GROUPS[name];
123+
if (group) return [...group];
124+
if (EFFECTOR_SIDE_SET.has(name)) return [name];
125+
return [];
126+
}
127+
98128
export interface ActionAxis {
99129
axis: Axis;
100130
/** +1 or -1 in the bone's local frame (before left/right mirroring). */

packages/movit-parser/src/parser.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ export interface AstJointTarget {
1919
export interface AstReach {
2020
effector: string;
2121
target: string;
22+
line: number;
2223
}
2324

2425
export interface AstPin {
2526
effector: string;
2627
anchor: string;
28+
line: number;
2729
}
2830

2931
export interface AstStep {
@@ -212,7 +214,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
212214
if (t[1]?.type !== "colon" || !effector || !target) {
213215
return { line: ln.line, message: "expected `reach: <effector> <target>`" };
214216
}
215-
current.reaches.push({ effector, target });
217+
current.reaches.push({ effector, target, line: ln.line });
216218
return null;
217219
}
218220

@@ -224,7 +226,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
224226
if (t[1]?.type !== "colon" || !effector || !anchor) {
225227
return { line: ln.line, message: "expected `pin: <effector> <anchor>`" };
226228
}
227-
current.pins.push({ effector, anchor });
229+
current.pins.push({ effector, anchor, line: ln.line });
228230
return null;
229231
}
230232

0 commit comments

Comments
 (0)