Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/posecode-render/src/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,44 @@ export function swingArms(
HIP_EULER.setFromQuaternion(contraHip.quaternion, "XYZ");
if (Math.abs(HIP_EULER.x) < 1e-3) continue; // legs still → no swing
SWING_EULER.setFromQuaternion(shoulder.quaternion, "XYZ");
SWING_EULER.x += HIP_EULER.x * SWING_GAIN;
// This pass runs every render frame. Assign the procedural channel instead
// of adding to last frame's result, otherwise an unauthored shoulder keeps
// accumulating rotation (most visibly the left arm in a forward lunge).
SWING_EULER.x = HIP_EULER.x * SWING_GAIN;
shoulder.quaternion.setFromEuler(SWING_EULER);
changed = true;
}
if (changed) m.root.updateMatrixWorld(true);
}

const GRIP_FRAME = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
Math.PI,
);

/**
* Give gripping wrists a complete overhand contact frame. Arm IK only
* constrains wrist position, leaving hand orientation underdetermined; without
* this pass the fingers inherit the forearm direction and can point above the
* bar. In root space the palm faces back (-Z), the fingers extend down (-Y),
* and the wrist remains exactly on its solved anchor.
*/
export function orientBarGrips(m: Mannequin, grips: readonly GripTarget[]): void {
let changed = false;
for (const g of grips) {
const side = /_(left|right)$/.exec(g.effector)?.[1];
if (!side) continue;
const wrist = m.bones.get(`wrist_${side}`);
if (!wrist?.parent) continue;
const rootWorld = m.root.getWorldQuaternion(new THREE.Quaternion());
const desiredWorld = rootWorld.multiply(GRIP_FRAME);
const parentWorld = wrist.parent.getWorldQuaternion(new THREE.Quaternion());
wrist.quaternion.copy(parentWorld.invert().multiply(desiredWorld));
changed = true;
}
if (changed) m.root.updateMatrixWorld(true);
}

/** Max head turn toward a look target (radians) so the neck never over-rotates. */
export const MAX_LOOK = 55 * (Math.PI / 180);
const LOOK_FWD = new THREE.Vector3(0, 0, 1);
Expand Down
6 changes: 4 additions & 2 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
type ClipSource,
} from "./clips.js";
import { depenetrate } from "./depenetrate.js";
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js";
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead, orientBarGrips } from "./contacts.js";

const DEG = Math.PI / 180;

Expand Down Expand Up @@ -549,7 +549,9 @@ export function createViewer(
if (joints.length === 0) continue;
solveCCD({ joints, limits, effector, target }, 12);
}
// 3. Finger wrap.
// 3. Resolve the wrist roll left underdetermined by positional arm IK.
orientBarGrips(mannequin, grips);
// 4. Finger wrap.
wrapGrip(mannequin, grips);
}

Expand Down
39 changes: 38 additions & 1 deletion packages/posecode-render/test/contacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import * as THREE from "three";
import { buildMannequin } from "../src/mannequin.js";
import { levelPlantedFeet, relaxHands, swingArms, aimHead } from "../src/contacts.js";
import { levelPlantedFeet, relaxHands, swingArms, aimHead, orientBarGrips } from "../src/contacts.js";
import { groundFigure } from "../src/groundlock.js";

const DEG = Math.PI / 180;
Expand Down Expand Up @@ -116,6 +116,43 @@ describe("swingArms (L4.2)", () => {
swingArms(m, new Set(), new Set(["left"]));
expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5);
});

it("is idempotent across render frames instead of accumulating a spin", () => {
const m = buildMannequin();
m.bones.get("hip_right")!.rotation.x = -0.6;
swingArms(m, new Set(), new Set());
const once = m.bones.get("shoulder_left")!.quaternion.clone();
for (let frame = 0; frame < 120; frame++) swingArms(m, new Set(), new Set());
expect(m.bones.get("shoulder_left")!.quaternion.angleTo(once)).toBeLessThan(1e-6);
});
});

describe("orientBarGrips", () => {
it("resolves wrist roll without moving a hand off its grip point", () => {
const m = buildMannequin();
const wrist = m.bones.get("wrist_left")!;
wrist.rotation.y = 1.7;
m.root.updateMatrixWorld(true);
const position = wrist.getWorldPosition(new THREE.Vector3()).clone();
orientBarGrips(m, [{ effector: "hand_left", anchor: "bar_left" }]);
expect(wrist.getWorldPosition(new THREE.Vector3()).distanceTo(position)).toBeLessThan(1e-6);

const palm = new THREE.Vector3(0, 0, 1)
.applyQuaternion(wrist.getWorldQuaternion(new THREE.Quaternion()));
const fingers = new THREE.Vector3(0, -1, 0)
.applyQuaternion(wrist.getWorldQuaternion(new THREE.Quaternion()));
expect(palm.dot(new THREE.Vector3(0, 0, -1))).toBeGreaterThan(0.999);
expect(fingers.dot(new THREE.Vector3(0, -1, 0))).toBeGreaterThan(0.999);
});

it("is stable when applied repeatedly", () => {
const m = buildMannequin();
const grips = [{ effector: "hand_right", anchor: "bar_right" }];
orientBarGrips(m, grips);
const once = m.bones.get("wrist_right")!.quaternion.clone();
for (let frame = 0; frame < 60; frame++) orientBarGrips(m, grips);
expect(m.bones.get("wrist_right")!.quaternion.angleTo(once)).toBeLessThan(1e-6);
});
});

describe("aimHead (L4.3 look-at)", () => {
Expand Down
Loading