Skip to content

Commit 9a8f6da

Browse files
committed
feat(render): bar grip solve — two-point anchors, arm IK, finger wrap
1 parent 14793b6 commit 9a8f6da

6 files changed

Lines changed: 123 additions & 6 deletions

File tree

packages/posecode-eval/src/probe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export function probeMovement(source: string): ProbeResult {
176176
reaches: [...info.reaches],
177177
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
178178
rootYaw: info.rootYaw,
179-
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0,
179+
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
180180
bones: snapshotBones(m.bones),
181181
boneQuaternions: snapshotBoneQuaternions(m.bones),
182182
};

packages/posecode-render/src/contacts.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** Semantic contact-orientation helpers shared by viewer and eval. */
22
import * as THREE from "three";
3-
import { eulerRomFor, type PinTarget, type ReachTarget } from "posecode-parser";
3+
import { eulerRomFor, type PinTarget, type ReachTarget, type GripTarget } from "posecode-parser";
44
import type { Mannequin } from "./mannequin.js";
55

66
const DOWN = new THREE.Vector3(0, -1, 0);
@@ -103,3 +103,37 @@ export function levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string
103103
}
104104
if (changed) m.root.updateMatrixWorld(true);
105105
}
106+
107+
/** Finger curl (radians about the knuckle X axis) that wraps a gripping hand. */
108+
export const FINGER_CURL = 1.35;
109+
/** Thumb opposition curl (radians) toward the fingers. */
110+
export const THUMB_CURL = 0.9;
111+
const FINGERS = ["index", "middle", "ring", "pinky"] as const;
112+
113+
/**
114+
* Curl the fingers of each gripping hand around the bar. Grips are per-side
115+
* after resolution (`hand_left` / `hand_right`), so the side comes straight off
116+
* the effector. The four fingers flex at the knuckle and the thumb opposes,
117+
* turning the open reach pose into a closed grip on the bar.
118+
*/
119+
export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
120+
let changed = false;
121+
for (const g of grips) {
122+
const side = /_(left|right)$/.exec(g.effector)?.[1];
123+
if (!side) continue;
124+
for (const f of FINGERS) {
125+
const bone = m.bones.get(`${f}_${side}`);
126+
if (bone) {
127+
bone.rotation.set(FINGER_CURL, 0, 0);
128+
changed = true;
129+
}
130+
}
131+
const thumb = m.bones.get(`thumb_${side}`);
132+
if (thumb) {
133+
// Thumb wraps from the opposite side: curl plus a sideways opposition.
134+
thumb.rotation.set(THUMB_CURL, 0, side === "left" ? -THUMB_CURL : THUMB_CURL);
135+
changed = true;
136+
}
137+
}
138+
if (changed) m.root.updateMatrixWorld(true);
139+
}

packages/posecode-render/src/index.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import * as THREE from "three";
1212
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
1313
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
1414
import { eulerRomFor } from "posecode-parser";
15-
import type { PosecodeIR, ReachTarget, PinTarget } from "posecode-parser";
15+
import type { PosecodeIR, ReachTarget, PinTarget, GripTarget } from "posecode-parser";
1616
import { buildMannequin, type Mannequin } from "./mannequin.js";
1717
import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } from "./groundlock.js";
1818
import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js";
@@ -27,7 +27,7 @@ import {
2727
type ClipSource,
2828
} from "./clips.js";
2929
import { depenetrate } from "./depenetrate.js";
30-
import { alignFloorPalms, levelPlantedFeet } from "./contacts.js";
30+
import { alignFloorPalms, levelPlantedFeet, wrapGrip } from "./contacts.js";
3131

3232
const DEG = Math.PI / 180;
3333

@@ -482,6 +482,50 @@ export function createViewer(
482482
}
483483
}
484484

485+
/**
486+
* Bar grips: unlike a pin (body translate only), a grip makes each hand hold
487+
* the bar. (1) Translate the body by the average wrist→anchor delta — the
488+
* authored elbow flex raises the wrists, so the body rises: the pull-up. (2)
489+
* Per-hand arm IK drives each wrist exactly onto its own two-point anchor
490+
* (`bar_left`/`bar_right`), so the hands grip shoulder-width and the arms angle
491+
* naturally instead of pointing straight up. (3) Wrap the fingers round the bar.
492+
*/
493+
function applyGrips(grips: GripTarget[]): void {
494+
if (grips.length === 0) return;
495+
const resolveGrip = (anchor: string, effector: THREE.Object3D): THREE.Vector3 | null =>
496+
resolveReachTarget(anchor, effector) ??
497+
resolveReachTarget(anchor.replace(/_(left|right)$/, ""), effector);
498+
// 1. Body translate (the vertical pull).
499+
const delta = new THREE.Vector3();
500+
let n = 0;
501+
for (const g of grips) {
502+
const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector;
503+
const effector = mannequin.bones.get(effectorBone);
504+
if (!effector) continue;
505+
const target = resolveGrip(g.anchor, effector);
506+
if (!target) continue;
507+
delta.add(target.clone().sub(effector.getWorldPosition(new THREE.Vector3())));
508+
n++;
509+
}
510+
if (n > 0) {
511+
mannequin.root.position.add(delta.multiplyScalar(1 / n));
512+
mannequin.root.updateMatrixWorld(true);
513+
}
514+
// 2. Per-hand arm IK onto each grip point (ROM-clamped via reachChain).
515+
for (const g of grips) {
516+
const effectorBone = EFFECTOR_BONE[g.effector] ?? g.effector;
517+
const effector = mannequin.bones.get(effectorBone);
518+
if (!effector) continue;
519+
const target = resolveGrip(g.anchor, effector);
520+
if (!target) continue;
521+
const { joints, limits } = reachChain(effectorBone);
522+
if (joints.length === 0) continue;
523+
solveCCD({ joints, limits, effector, target }, 12);
524+
}
525+
// 3. Finger wrap.
526+
wrapGrip(mannequin, grips);
527+
}
528+
485529
function frameCamera(): void {
486530
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
487531
// Include any scene prop too: a pull-up bar sits well above the figure's
@@ -535,6 +579,7 @@ export function createViewer(
535579
depenetrate(mannequin);
536580
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
537581
applyPins(info.pins);
582+
applyGrips(info.grips);
538583
// Reach-IK BEFORE the floor safety clamp. When authored FK pushes a
539584
// reaching limb through the floor (cobra: prone + shoulders flex 50),
540585
// the limb must bend to meet the floor. Running reaches after the clamp

packages/posecode-render/src/props.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen
5454
post.position.set(x, barH / 2, 0);
5555
group.add(post);
5656
}
57+
// Centre anchor (back-compat) plus two shoulder-width grip points so a
58+
// `grip: hands bar` lands each hand on its own spot instead of both at
59+
// centre. GRIP_HALF ≈ half a shoulder width.
60+
const GRIP_HALF = 0.18;
5761
anchors.set("bar", new THREE.Vector3(0, barH, 0));
62+
anchors.set("bar_left", new THREE.Vector3(GRIP_HALF, barH, 0));
63+
anchors.set("bar_right", new THREE.Vector3(-GRIP_HALF, barH, 0));
5864
} else if (type === "wall") {
5965
const wall = box(2.2, 2.6, 0.1, mat);
6066
wall.position.set(0, 1.3, -0.34);

packages/posecode-render/src/timeline.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import * as THREE from "three";
11-
import type { PosecodeIR, ReachTarget, PinTarget, TimingMode } from "posecode-parser";
11+
import type { PosecodeIR, ReachTarget, PinTarget, GripTarget, TimingMode } from "posecode-parser";
1212
import { poseFor, type PoseSpec } from "./poses.js";
1313
import { squad, squadControl } from "./squad.js";
1414

@@ -25,6 +25,7 @@ interface Keyframe {
2525
groundLock: string[];
2626
reaches: ReachTarget[];
2727
pins: PinTarget[];
28+
grips: GripTarget[];
2829
/** Root facing (yaw about world Y, radians) at this keyframe. */
2930
yaw: number;
3031
/** Root ground offset (world X/Z metres) from the load spot at this keyframe. */
@@ -56,6 +57,7 @@ export interface BuiltTimeline {
5657
groundLock: string[];
5758
reaches: ReachTarget[];
5859
pins: PinTarget[];
60+
grips: GripTarget[];
5961
/** Interpolated root facing (yaw about world Y, radians). */
6062
rootYaw: number;
6163
/** Interpolated root ground offset (world X/Z metres) from the load spot. */
@@ -117,6 +119,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
117119
groundLock: [],
118120
reaches: [],
119121
pins: [],
122+
grips: [],
120123
yaw: 0,
121124
pos: { x: 0, z: 0 },
122125
});
@@ -139,6 +142,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
139142
groundLock: phase.groundLock,
140143
reaches: phase.reaches,
141144
pins: phase.pins,
145+
grips: phase.grips,
142146
yaw: currYaw * DEG,
143147
pos: { ...currPos },
144148
});
@@ -160,6 +164,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
160164
groundLock: [],
161165
reaches: [],
162166
pins: [],
167+
grips: [],
163168
yaw: wrapYaw,
164169
pos: { x: 0, z: 0 },
165170
});
@@ -244,6 +249,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
244249
groundLock: b.groundLock,
245250
reaches: b.reaches,
246251
pins: b.pins,
252+
grips: b.grips,
247253
rootYaw,
248254
rootOffset,
249255
};

packages/posecode-render/test/render.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { solveCCD } from "../src/ik.js";
66
import { poseFor } from "../src/poses.js";
77
import { buildProps } from "../src/props.js";
88
import { applyGroundLock, groundFigure } from "../src/groundlock.js";
9-
import { levelPlantedFeet } from "../src/contacts.js";
9+
import { levelPlantedFeet, wrapGrip } from "../src/contacts.js";
1010
import { parse, eulerRomFor } from "posecode-parser";
1111

1212
const DEG = Math.PI / 180;
@@ -787,3 +787,29 @@ describe("foot-flat correction (L3.1)", () => {
787787
expect(soleDot()).toBeGreaterThan(0.9);
788788
});
789789
});
790+
791+
describe("bar grip (L3.2)", () => {
792+
it("exposes two-point bar grip anchors shoulder-width apart", () => {
793+
const { anchors } = buildProps(["bar"]);
794+
const l = anchors.get("bar_left");
795+
const r = anchors.get("bar_right");
796+
expect(l).toBeDefined();
797+
expect(r).toBeDefined();
798+
expect(l!.x).toBeGreaterThan(0); // left hand grips the +X side
799+
expect(r!.x).toBeLessThan(0);
800+
expect(Math.abs(l!.x - r!.x)).toBeCloseTo(0.36, 2);
801+
expect(l!.y).toBeCloseTo(r!.y, 5); // same bar height
802+
});
803+
804+
it("wraps the fingers of a gripping hand into a curl", () => {
805+
const m = buildMannequin();
806+
const restIndex = m.bones.get("index_left")!.rotation.x;
807+
const restThumb = m.bones.get("thumb_left")!.rotation.x;
808+
wrapGrip(m, [{ effector: "hand_left", anchor: "bar_left" }]);
809+
expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(restIndex + 0.5);
810+
expect(m.bones.get("middle_left")!.rotation.x).toBeGreaterThan(0.5);
811+
expect(m.bones.get("thumb_left")!.rotation.x).toBeGreaterThan(restThumb + 0.3);
812+
// the un-gripped right hand is untouched
813+
expect(m.bones.get("index_right")!.rotation.x).toBeCloseTo(0, 5);
814+
});
815+
});

0 commit comments

Comments
 (0)