Skip to content

Commit c6960fe

Browse files
Merge pull request #10 from a-baran-orhan/claude/project-core-features-ob0d4l
Fix axial/ankle/hinge sagittal inversion, dead finger meshes, dip bars, and a face marker
2 parents 19d2b2c + 762d316 commit c6960fe

14 files changed

Lines changed: 325 additions & 94 deletions

File tree

ROADMAP.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ domain needs — so contributions land where they unlock the most.
1919
| **Sports technique** | Golf swing, tennis serve, throwing, kicking | 🟡 Partial — needs trunk rotation fidelity, weight shift, and implements (club/racket/ball). |
2020
| **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. |
22-
| **Sign language / gesture** | Finger-spelling, signs, expressive gesture | ⛔ Needs a hand/finger rig (the rig currently ends at the wrist). |
22+
| **Sign language / gesture** | Finger-spelling, signs, expressive gesture | 🟡 Partial — single-DOF finger curls render visibly (fist, pinch, wave, rough finger-spelling); exact sign language needs multi-joint fingers + wrist orientation. |
2323

2424
## Engine capabilities that widen the scope
2525

@@ -72,14 +72,15 @@ Each prop is a small scene object + an anchor type; movements then reference it
7272
| **Resistance band** | Band pull-aparts, banded rehab, mobility with tension |
7373
| **Dumbbell / barbell / kettlebell** | Loaded strength patterns (needs grip + load cues) |
7474
| **Ball (stability / medicine)** | Core work, balance, throws |
75-
| **Parallettes / dip station** | Dips, L-sits, push-up variations at height |
75+
| **Parallettes / dip station** | ✅ Dip bars shipped (`prop dip-bars` — triceps dips). Still future: L-sits, push-up variations at height |
7676
| **Foam roller** | Self-myofascial release, mobility drills |
7777

7878
## Current limitations (honest)
7979

8080
- One figure only; partner work and collision are still deferred.
81-
- A **starter** prop set (chair / wall / bar) — no bench, rings, bands, or loaded
82-
implements yet, and props sit at fixed default placements.
81+
- A **starter** prop set (chair / wall / bar / box / dip bars) — no bench,
82+
rings, bands, or loaded implements yet, and props sit at fixed default
83+
placements.
8384
- Props are visual + reach anchors (no physical sit/lean solve).
8485
- Fingers are **single-DOF** curls — good for grip and rough gesture, not exact
8586
sign language. The head has no facial articulation.

packages/movit-language/src/vocab.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const EFFECTORS = ["hands", "feet"];
1919

2020
/** Reach/pin effectors (groups + per-side aliases) — sourced from the parser. */
2121
export const REACH_EFFECTORS = EFFECTOR_NAMES;
22-
export const PROPS = ["chair", "wall", "bar", "box"];
22+
export const PROPS = ["chair", "wall", "bar", "box", "dip-bars"];
2323

2424
/** Top-level directives (excluding the `movit` header keyword). */
2525
export const TOP_KEYWORDS = ["rig", "prop", "pose", "step", "repeat"];
@@ -31,7 +31,7 @@ export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel",
3131
export const KEYWORD_DOCS: Record<string, string> = {
3232
movit: 'Document header — `movit <kind> "<name>"`.',
3333
rig: "Selects the rig (currently `humanoid`).",
34-
prop: "Adds a scene object — `prop chair | wall | bar | box`. Supplies reach anchors.",
34+
prop: "Adds a scene object — `prop chair | wall | bar | box | dip-bars`. Supplies reach/pin anchors.",
3535
pose: "Sets the starting pose — `pose start = standing | neutral | plank | supine | prone | seated`.",
3636
start: "Used in `pose start = <pose>`.",
3737
step: 'A movement phase — `step "<name>" <Ns> <easing>:`.',

packages/movit-parser/src/joints.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,15 @@ const ACTIONS: Record<string, ActionAxis> = {
148148
"rotate-out": { axis: "y", sign: -1 },
149149
supinate: { axis: "y", sign: 1 },
150150
pronate: { axis: "y", sign: -1 },
151-
dorsiflex: { axis: "x", sign: 1 },
152-
plantarflex: { axis: "x", sign: -1 },
151+
// The foot points FORWARD (+Z): lifting the toes toward the shin
152+
// (dorsiflexion) is a -X rotation, pointing them is +X.
153+
dorsiflex: { axis: "x", sign: -1 },
154+
plantarflex: { axis: "x", sign: 1 },
153155
// Hip hinge: tip the torso forward over the hip line (deadlift, row, bow,
154-
// good-morning). Applied to the `pelvis`; sign -1 tips the figure forward
155-
// (same sagittal direction as spine flex). The renderer counter-rotates the
156-
// hips so the legs stay planted — see movit-render/src/timeline.ts.
157-
hinge: { axis: "x", sign: -1 },
156+
// good-morning). Applied to the `pelvis`, whose torso child points UP, so
157+
// forward is +X (like spine flexion). The renderer counter-rotates the hips
158+
// so the legs stay planted — see movit-render/src/timeline.ts.
159+
hinge: { axis: "x", sign: 1 },
158160
};
159161

160162
/** Every semantic action name the DSL accepts (e.g. "flex", "abduct"). */
@@ -166,13 +168,22 @@ export function actionAxis(action: string): ActionAxis | null {
166168
}
167169

168170
/**
169-
* Sagittal flexion direction differs by joint. With every bone resting along
170-
* -Y, most joints flex toward +Z (anatomically forward / up): hip, shoulder,
171-
* elbow, spine, neck. The KNEE is the exception — it flexes toward -Z (heel
172-
* toward the buttock). `extend` is the opposite of `flex`. Used by the resolver
173-
* to sign flex/extend per joint so a squat folds correctly instead of inverting.
171+
* Sagittal flexion direction differs by joint. Limb bones rest along -Y
172+
* (their child chain points DOWN), so flexing toward +Z (anatomically
173+
* forward: hip, shoulder, elbow, wrist, fingers) is a -X rotation. The AXIAL
174+
* chain (spine, chest, neck, head) points UP, so the same forward bend is +X.
175+
* The KNEE is the odd limb out — it flexes backward (heel toward the buttock),
176+
* +X. `extend` is the opposite of `flex`. Used by the resolver to sign
177+
* flex/extend per joint so a squat folds and a crunch curls forward instead
178+
* of inverting.
174179
*/
175-
const FLEXION_SIGN: Record<string, number> = { knee: 1 };
180+
const FLEXION_SIGN: Record<string, number> = {
181+
knee: 1,
182+
spine: 1,
183+
chest: 1,
184+
neck: 1,
185+
head: 1,
186+
};
176187

177188
export function flexionSign(boneType: string): number {
178189
return FLEXION_SIGN[boneType] ?? -1;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ describe("hip-hinge action", () => {
4646
expect(errors).toEqual([]);
4747
expect(warnings).toEqual([]);
4848
const pelvis = ir!.phases[0]!.targets.find((t) => t.boneId === "pelvis")!;
49-
// hinge tips the torso forward — same sagittal direction as spine flex (-X).
50-
expect(pelvis.euler.x).toBe(-80);
49+
// hinge tips the torso forward — same sagittal direction as spine flex (+X,
50+
// the axial chain points up).
51+
expect(pelvis.euler.x).toBe(80);
5152
});
5253

5354
it("clamps an over-deep hinge to the pelvis ROM ceiling", () => {
@@ -56,6 +57,6 @@ describe("hip-hinge action", () => {
5657
expect(warnings).toHaveLength(1);
5758
expect(warnings[0]!.clamped).toBe(120);
5859
const pelvis = ir!.phases[0]!.targets.find((t) => t.boneId === "pelvis")!;
59-
expect(pelvis.euler.x).toBe(-120);
60+
expect(pelvis.euler.x).toBe(120);
6061
});
6162
});

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ describe("euler ROM boxes (eulerRomFor)", () => {
7171
});
7272

7373
it("covers the ankle via dorsiflex/plantarflex and the pelvis via hinge", () => {
74-
expect(eulerRomFor("ankle_left")!.x).toEqual({ min: -50, max: 15 });
75-
expect(eulerRomFor("pelvis")!.x).toEqual({ min: -120, max: 0 });
74+
// Toes point +Z: dorsiflexion (toes up) is -X, plantarflexion +X.
75+
expect(eulerRomFor("ankle_left")!.x).toEqual({ min: -15, max: 50 });
76+
// The pelvis' torso child points up: the forward hinge is +X.
77+
expect(eulerRomFor("pelvis")!.x).toEqual({ min: 0, max: 120 });
7678
});
7779

7880
it("returns null for a bone without ROM data", () => {

packages/movit-render/src/mannequin.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ const SKELETON: BoneSpec[] = [
5252
{ id: "knee_right", parent: "hip_right", offset: [0, -0.45, 0], radius: 0.05 },
5353
{ id: "ankle_right", parent: "knee_right", offset: [0, -0.43, 0], radius: 0.04 },
5454

55-
// Fingers — short single-segment digits off each wrist, splayed in X and
56-
// angled slightly forward (+Z, palm-side). One curl DOF each (flex).
55+
// Fingers — one curl DOF each (flex), splayed in X and angled slightly
56+
// forward (+Z, palm-side). Offsets are the FINGERTIP position; the bone is
57+
// placed partway along at the knuckle (KNUCKLE_T) so the wrist-drawn segment
58+
// becomes the rigid palm/metacarpal and the bone carries its own digit mesh —
59+
// otherwise curling a finger rotates an empty node and the hand never moves.
5760
{ id: "thumb_left", parent: "wrist_left", offset: [0.035, -0.04, 0.025], radius: 0.014 },
5861
{ id: "index_left", parent: "wrist_left", offset: [0.025, -0.085, 0.012], radius: 0.013 },
5962
{ id: "middle_left", parent: "wrist_left", offset: [0.008, -0.092, 0.012], radius: 0.013 },
@@ -67,6 +70,13 @@ const SKELETON: BoneSpec[] = [
6770
{ id: "pinky_right", parent: "wrist_right", offset: [0.028, -0.075, 0.012], radius: 0.012 },
6871
];
6972

73+
/** Fraction of the wrist→fingertip span where the knuckle (finger bone) sits. */
74+
const KNUCKLE_T = 0.55;
75+
76+
function isFinger(id: string): boolean {
77+
return /^(thumb|index|middle|ring|pinky)_/.test(id);
78+
}
79+
7080
/** Build the mannequin. `material` lets the playground theme it. */
7181
export function buildMannequin(material?: THREE.Material): Mannequin {
7282
const mat =
@@ -86,23 +96,37 @@ export function buildMannequin(material?: THREE.Material): Mannequin {
8696
for (const spec of SKELETON) {
8797
const bone = new THREE.Object3D();
8898
bone.name = spec.id;
89-
bone.position.set(...spec.offset);
99+
// Finger bones sit at the knuckle; the offset names the fingertip.
100+
const offset = new THREE.Vector3(...spec.offset);
101+
if (isFinger(spec.id)) offset.multiplyScalar(KNUCKLE_T);
102+
bone.position.copy(offset);
90103

91104
const parent = spec.parent ? bones.get(spec.parent) : root;
92105
(parent ?? root).add(bone);
93106
bones.set(spec.id, bone);
94107

95108
// Draw the segment from the parent joint to this joint, on the parent.
96109
if (spec.parent && spec.radius) {
97-
const length = Math.hypot(...spec.offset);
98-
const seg = makeSegment(length, spec.radius, mat);
99-
orientSegment(seg, new THREE.Vector3(...spec.offset));
110+
const seg = makeSegment(offset.length(), spec.radius, mat);
111+
orientSegment(seg, offset);
100112
bones.get(spec.parent)!.add(seg);
101113
}
102114
}
103115

116+
// Digit meshes ON the finger bones, spanning knuckle → fingertip, so a
117+
// finger curl visibly folds at the knuckle.
118+
for (const spec of SKELETON) {
119+
if (!isFinger(spec.id) || !spec.radius) continue;
120+
const full = new THREE.Vector3(...spec.offset);
121+
const span = full.clone().multiplyScalar(1 - KNUCKLE_T);
122+
const digit = makeSegment(span.length(), spec.radius * 0.92, mat);
123+
orientSegment(digit, span);
124+
bones.get(spec.id)!.add(digit);
125+
}
126+
104127
// Head sphere + small hand/foot caps for readability.
105128
addBall(bones.get("head")!, 0.12, mat);
129+
addFace(bones.get("head")!);
106130
addBall(bones.get("wrist_left")!, 0.05, mat);
107131
addBall(bones.get("wrist_right")!, 0.05, mat);
108132
addFoot(bones.get("ankle_left")!, mat);
@@ -139,6 +163,32 @@ function addBall(bone: THREE.Object3D, diameter: number, mat: THREE.Material): v
139163
bone.add(new THREE.Mesh(geo, mat));
140164
}
141165

166+
/**
167+
* A minimal face — nose wedge + two eye studs — on the head's front (+Z).
168+
* The bare sphere hid which way the figure faces, making neck rotations and
169+
* turns unreadable; darker studs poke just past the head surface so facing
170+
* reads at a glance from any camera angle.
171+
*/
172+
function addFace(head: THREE.Object3D): void {
173+
const mat = new THREE.MeshStandardMaterial({ color: 0x39424e, roughness: 0.6 });
174+
const face = new THREE.Group();
175+
face.name = "face";
176+
177+
// Head ball radius is 0.06 (see addBall(head, 0.12)).
178+
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.013, 0.035, 10), mat);
179+
nose.rotation.x = Math.PI / 2; // cone +Y → +Z
180+
nose.position.set(0, -0.004, 0.064);
181+
face.add(nose);
182+
183+
for (const sx of [-1, 1]) {
184+
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.0095, 10, 8), mat);
185+
eye.position.set(sx * 0.024, 0.016, 0.054);
186+
face.add(eye);
187+
}
188+
189+
head.add(face);
190+
}
191+
142192
function addFoot(bone: THREE.Object3D, mat: THREE.Material): void {
143193
const geo = new THREE.BoxGeometry(0.07, 0.04, 0.16);
144194
const mesh = new THREE.Mesh(geo, mat);

packages/movit-render/src/poses.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@ const NEUTRAL: PoseSpec = {
2222
// Face-down support position: torso horizontal, arms reaching to the floor.
2323
// Ground-lock IK plants hands and feet; this just gets the gross posture right.
2424
const PLANK: PoseSpec = {
25-
// Face-down diagonal: rotating the standing figure by -72° about X tips it so
26-
// the head/shoulders stay HIGH and the legs point forward-and-down toward the
27-
// floor (a value past -90 would kick the feet up instead). Arms drop straight
28-
// down (shoulder flex 90); toes curl under (ankle). groundFigure() then drops
29-
// the whole body so the lowest contact rests on the floor.
30-
root: { position: [0, 0.6, 0], rotationDeg: [-72, 0, 0] },
25+
// Face-down diagonal: rotating the standing figure by +72° about X (the same
26+
// direction as prone's +90°) tips it chest-toward-the-floor with the
27+
// head/shoulders HIGH and the feet trailing low behind (a value past 90
28+
// would kick the feet up instead). Arms drop straight down toward the floor
29+
// (shoulder flex 90 = local -X); toes curl under (ankle dorsiflex = local
30+
// -X). groundFigure() then drops the body so the lowest contact rests on
31+
// the floor.
32+
root: { position: [0, 0.6, 0], rotationDeg: [72, 0, 0] },
3133
joints: {
32-
shoulder_left: [90, 0, 0],
33-
shoulder_right: [90, 0, 0],
34-
ankle_left: [25, 0, 0],
35-
ankle_right: [25, 0, 0],
34+
shoulder_left: [-90, 0, 0],
35+
shoulder_right: [-90, 0, 0],
36+
ankle_left: [-25, 0, 0],
37+
ankle_right: [-25, 0, 0],
3638
},
3739
};
3840

packages/movit-render/src/props.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface PropScene {
2020
anchors: Map<string, THREE.Vector3>;
2121
}
2222

23-
/** Build the declared props (`chair | wall | bar`). Unknown types are ignored. */
23+
/** Build the declared props (`chair | wall | bar | box | dip-bars`). Unknown types are ignored. */
2424
export function buildProps(types: string[], material?: THREE.Material): PropScene {
2525
const group = new THREE.Group();
2626
group.name = "movit-props";
@@ -60,6 +60,33 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
6060
wall.position.set(0, 1.3, -0.34);
6161
group.add(wall);
6262
anchors.set("wall", new THREE.Vector3(0, 0.9, -0.29));
63+
} else if (type === "dip-bars") {
64+
// Parallel dip bars either side of the figure, rails running along Z.
65+
// Rail height is set so a straight-arm support holds the feet clear of
66+
// the floor. The single `bars` grip anchor sits at the midpoint between
67+
// the rails at grip height: pins translate the BODY so the average hand
68+
// position meets the anchor, which leaves each authored hand over its
69+
// own rail.
70+
const railH = 1.1;
71+
const halfSpan = 0.22;
72+
for (const x of [-halfSpan, halfSpan]) {
73+
const rail = new THREE.Mesh(
74+
new THREE.CylinderGeometry(0.022, 0.022, 0.9, 12),
75+
mat,
76+
);
77+
rail.rotation.x = Math.PI / 2; // horizontal, along Z
78+
rail.position.set(x, railH, 0);
79+
group.add(rail);
80+
for (const z of [-0.35, 0.35]) {
81+
const post = new THREE.Mesh(
82+
new THREE.CylinderGeometry(0.026, 0.026, railH, 10),
83+
mat,
84+
);
85+
post.position.set(x, railH / 2, z);
86+
group.add(post);
87+
}
88+
}
89+
anchors.set("bars", new THREE.Vector3(0, railH, 0));
6390
} else if (type === "box") {
6491
// A low step/plateau placed IN FRONT of the figure (+Z) — the lead foot
6592
// steps forward and up onto it. Top surface at ~0.30 m; `box` anchor sits

0 commit comments

Comments
 (0)