diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 8dd3e6b..80f301f 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -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); diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 1c952a1..10ec7d1 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -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; @@ -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); } diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index 338d39e..829dd43 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -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; @@ -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)", () => {