Skip to content

Commit 762d316

Browse files
committed
Fix inverted axial/ankle/hinge sagittal directions, dead finger meshes, and chair dips
Three user reports (sign language a mess, cobra arching wrong, dips clipping through the chair) traced back to two systemic rig bugs plus a mis-fit prop: 1. Sagittal inversion across the axial chain. FLEXION_SIGN assumed every bone hangs downward like a limb, but the spine/chest/neck/head chain points UP — so spinal flexion leaned the torso BACKWARD, extension arched it forward, the pelvis hinge bowed deadlifts/bows backward, and dorsiflexion pointed the toes. Cobra pressed the head into the floor instead of arching up. An empirical direction matrix over every sagittal action confirmed all limb directions correct and all axial + ankle + hinge directions inverted; the sign table now distinguishes up-pointing bones (+X flexion) from down-pointing limbs (-X), and dorsiflex/plantarflex/hinge signs are corrected. The plank base pose carried the same inversion (it rendered belly-up) and is now face down. The whole example library was authored semantically, so every crunch, superman, chest-opener, forward fold, deadlift, and heel raise now renders as its cue describes. 2. Finger bones carried no meshes. Every digit capsule was parented to the WRIST, so curling a finger rotated an empty node — the hand never visibly moved, which is why finger-spelling looked broken. Finger bones now sit at the knuckle (the wrist-drawn segment becomes the rigid palm) and carry their own digit mesh, so curls fold visibly at the knuckle. The finger-spell demo also raises the hand to chest height instead of spelling at the hip. 3. Triceps dips on a chair seat clipped through the chair back. New `prop dip-bars` — parallel rails at support height with a `bars` grip anchor — and the example now presses up between the bars, lowers, and dismounts. Cobra is re-authored to plant the palms with ROM-constrained `reach: hands floor` while the back extends. Tests: 169 passing (6 new: direction regressions for cobra and the digit meshes, dip-bar anchor + dip-depth checks, plus updated hinge and ankle ROM sign expectations). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164eTtmmq6U8GQkAEmQsvV5
1 parent 71204b3 commit 762d316

14 files changed

Lines changed: 285 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: 29 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,21 +96,34 @@ 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);
106129
addFace(bones.get("head")!);

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

packages/movit-render/test/render.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,125 @@ describe("face marker", () => {
507507
}
508508
});
509509
});
510+
511+
describe("hand rig articulation", () => {
512+
it("finger bones carry their own digit meshes, so curls are visible", () => {
513+
const m = buildMannequin();
514+
m.root.updateMatrixWorld(true);
515+
const index = m.bones.get("index_right")!;
516+
expect(index.children.length).toBeGreaterThan(0); // the digit capsule
517+
518+
// Curl the finger 90° and check the digit's far end actually moves.
519+
const digit = index.children[0]!;
520+
const tipBefore = digit.getWorldPosition(new THREE.Vector3());
521+
index.rotation.x = -90 * DEG;
522+
m.root.updateMatrixWorld(true);
523+
const tipAfter = digit.getWorldPosition(new THREE.Vector3());
524+
expect(tipBefore.distanceTo(tipAfter)).toBeGreaterThan(0.015);
525+
});
526+
});
527+
528+
describe("dip bars prop", () => {
529+
it("exposes a `bars` grip anchor at support height", () => {
530+
const { anchors, group } = buildProps(["dip-bars"]);
531+
expect(anchors.has("bars")).toBe(true);
532+
const grip = anchors.get("bars")!;
533+
expect(grip.y).toBeGreaterThan(0.9); // high enough that feet clear the floor
534+
expect(grip.y).toBeLessThan(1.6); // but well below the pull-up bar
535+
expect(group.children.length).toBeGreaterThan(0);
536+
});
537+
538+
// Same root-translation logic as the viewer's applyPins (see contact pins).
539+
function pelvisYAt(source: string, t: number, anchors: Map<string, THREE.Vector3>): number {
540+
const BONE: Record<string, string> = {
541+
hand_left: "wrist_left",
542+
hand_right: "wrist_right",
543+
foot_left: "ankle_left",
544+
foot_right: "ankle_right",
545+
};
546+
const { ir } = parse(source);
547+
const tl = buildTimeline(ir!);
548+
const m = buildMannequin();
549+
const info = tl.sample(t, m.bones);
550+
m.root.updateMatrixWorld(true);
551+
const delta = new THREE.Vector3();
552+
let n = 0;
553+
for (const pin of info.pins) {
554+
const eff = m.bones.get(BONE[pin.effector] ?? pin.effector);
555+
const a = anchors.get(pin.anchor);
556+
if (!eff || !a) continue;
557+
delta.add(a.clone().sub(eff.getWorldPosition(new THREE.Vector3())));
558+
n++;
559+
}
560+
if (n > 0) {
561+
m.root.position.add(delta.multiplyScalar(1 / n));
562+
m.root.updateMatrixWorld(true);
563+
}
564+
return m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()).y;
565+
}
566+
567+
it("lowers the body between the bars as the elbows flex (dip bottom)", () => {
568+
const dips = [
569+
'movit exercise "Dips"',
570+
" rig humanoid",
571+
" prop dip-bars",
572+
" pose start = standing",
573+
' step "Support" 1s ease-out:',
574+
" elbows: flex 5",
575+
" knees: flex 70",
576+
" pin: hands bars",
577+
' step "Lower" 1s ease-in-out:',
578+
" shoulders: extend 30",
579+
" elbows: flex 90",
580+
" knees: flex 70",
581+
" pin: hands bars",
582+
" repeat 4",
583+
].join("\n");
584+
const anchors = buildProps(["dip-bars"]).anchors;
585+
const support = pelvisYAt(dips, 0.99, anchors);
586+
const bottom = pelvisYAt(dips, 1.99, anchors);
587+
expect(support).toBeGreaterThan(0.9); // hips held up at bar height
588+
expect(bottom).toBeLessThan(support - 0.08); // body sinks into the dip
589+
});
590+
});
591+
592+
describe("cobra", () => {
593+
it("arches the chest and head up off the floor during the Lift", () => {
594+
const src = [
595+
'movit stretch "Cobra"',
596+
" rig humanoid",
597+
" pose start = prone",
598+
' step "Lift" 2.5s ease-in-out:',
599+
" spine: extend 30",
600+
" chest: extend 15",
601+
" neck: extend 25",
602+
" shoulders: flex 50",
603+
" elbows: flex 25",
604+
" reach: hands floor",
605+
" repeat 1",
606+
].join("\n");
607+
const { ir, errors, warnings } = parse(src);
608+
expect(errors).toEqual([]);
609+
expect(warnings).toEqual([]);
610+
const tl = buildTimeline(ir!);
611+
const m = buildMannequin();
612+
613+
// Apply the prone base root like the viewer does.
614+
const base = tl.basePose.root!;
615+
m.root.rotation.set(...(base.rotationDeg!.map((d) => d * DEG) as [number, number, number]));
616+
617+
tl.sample(0, m.bones);
618+
m.root.updateMatrixWorld(true);
619+
const headFlat = m.bones.get("head")!.getWorldPosition(new THREE.Vector3()).y;
620+
const pelvisFlat = m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()).y;
621+
622+
tl.sample(2.5, m.bones);
623+
m.root.updateMatrixWorld(true);
624+
const headUp = m.bones.get("head")!.getWorldPosition(new THREE.Vector3()).y;
625+
const pelvisUp = m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()).y;
626+
627+
// The head rises well above its flat height while the pelvis stays put.
628+
expect(headUp - headFlat).toBeGreaterThan(0.2);
629+
expect(Math.abs(pelvisUp - pelvisFlat)).toBeLessThan(0.05);
630+
});
631+
});

0 commit comments

Comments
 (0)