Skip to content

Commit 63d9a2a

Browse files
Merge pull request #25 from posecode-dev/feat/humanized-hero-and-readme-gifs
2 parents 18bcaac + 15ba5a1 commit 63d9a2a

5 files changed

Lines changed: 129 additions & 23 deletions

File tree

packages/posecode-eval/src/probe.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ export function probeMovement(source: string): ProbeResult {
6868
const baseRootPos = m.root.position.clone();
6969
const baseRootQuat = m.root.quaternion.clone();
7070

71+
// Mirror Viewer.captureGroundTargets(): the grounded base-pose effector
72+
// positions are the anchors horizontal foot planting holds feet to.
73+
const groundTargets = new Map<string, THREE.Vector3>();
74+
for (const ids of Object.values(m.effectors)) {
75+
for (const id of ids) {
76+
const node = m.bones.get(id);
77+
if (node) groundTargets.set(id, node.getWorldPosition(new THREE.Vector3()));
78+
}
79+
}
80+
7181
// Sample the end of each phase, applying the viewer's per-frame root
7282
// pipeline: base root → yaw/travel → ground-lock → floor safety clamp.
7383
const yawQ = new THREE.Quaternion();
@@ -82,7 +92,19 @@ export function probeMovement(source: string): ProbeResult {
8292
m.root.position.x += info.rootOffset.x;
8393
m.root.position.z += info.rootOffset.z;
8494
m.root.updateMatrixWorld(true);
85-
applyGroundLock(m, info.groundLock);
95+
// Mirror the viewer's per-frame anchors: captured targets carried along
96+
// by this phase's yaw/travel so planting composes with choreography.
97+
const anchors = new Map<string, THREE.Vector3>();
98+
for (const [id, captured] of groundTargets) {
99+
const v = captured.clone();
100+
if (info.rootYaw !== 0) {
101+
v.sub(baseRootPos).applyAxisAngle(WORLD_Y, info.rootYaw).add(baseRootPos);
102+
}
103+
v.x += info.rootOffset.x;
104+
v.z += info.rootOffset.z;
105+
anchors.set(id, v);
106+
}
107+
applyGroundLock(m, info.groundLock, anchors);
86108
// Viewer safety net: never leave the lowest mesh point below the floor.
87109
m.root.updateMatrixWorld(true);
88110
const box = new THREE.Box3().setFromObject(m.root);

packages/posecode-render/src/groundlock.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
* - **Feet only (squat / hinge / roll-down):** drop the body vertically so the
1616
* feet stay planted while the legs keep their authored FK bend: the pelvis
1717
* lowers. Legs are never CCD-solved (that would overwrite the pose).
18+
* With `anchors`, grounded feet are also held HORIZONTALLY: FK leg motion
19+
* (hip/knee) displaces the feet relative to the root, and without the
20+
* correction the feet skate across the floor while the pelvis stays put —
21+
* backwards from real movement, where planted feet stay fixed and the
22+
* pelvis travels (a squat sits the hips back, a hinge shifts them behind
23+
* the heels). Only feet near the floor anchor (a swing leg in a curl or
24+
* march must stay free), and only the average delta is corrected so
25+
* symmetric spreads (jumping jacks) don't fight the lock.
1826
*
1927
* Both paths ground the visible MESH (bounding boxes), not just bone origins:
2028
* an ankle bone sits ~0.04m above the sole, so anchoring bones alone left the
@@ -71,8 +79,19 @@ function rotateRootAboutPivot(m: Mannequin, pivot: THREE.Vector3, angle: number)
7179
m.root.updateMatrixWorld(true);
7280
}
7381

74-
/** Apply ground-lock for the phase's active effector groups (see module doc). */
75-
export function applyGroundLock(m: Mannequin, active: string[]): void {
82+
/** A foot whose mesh bottom is within this height counts as planted. */
83+
const PLANTED_MAX_Y = 0.05;
84+
85+
/**
86+
* Apply ground-lock for the phase's active effector groups (see module doc).
87+
* `anchors` (optional) maps effector bone ids to the world position each
88+
* planted foot should hold, already transformed by the phase's yaw/travel.
89+
*/
90+
export function applyGroundLock(
91+
m: Mannequin,
92+
active: string[],
93+
anchors?: ReadonlyMap<string, THREE.Vector3>,
94+
): void {
7695
if (active.length === 0) return;
7796
const ids = activeEffectorIds(m, active);
7897
const hands = ids.filter((id) => id.startsWith("wrist"));
@@ -120,5 +139,38 @@ export function applyGroundLock(m: Mannequin, active: string[]): void {
120139
m.root.position.y -= minY;
121140
m.root.updateMatrixWorld(true);
122141
}
142+
if (anchors) plantFeetHorizontally(m, feet, anchors);
143+
}
144+
}
145+
146+
/**
147+
* Translate the root in X/Z so grounded feet return to their anchors (see
148+
* module doc). Runs after vertical grounding so "near the floor" is judged in
149+
* the final vertical placement.
150+
*/
151+
function plantFeetHorizontally(
152+
m: Mannequin,
153+
feet: string[],
154+
anchors: ReadonlyMap<string, THREE.Vector3>,
155+
): void {
156+
const p = new THREE.Vector3();
157+
let dx = 0;
158+
let dz = 0;
159+
let n = 0;
160+
for (const id of feet) {
161+
const anchor = anchors.get(id);
162+
const node = m.bones.get(id);
163+
if (!anchor || !node) continue;
164+
const box = new THREE.Box3().setFromObject(node);
165+
if (!Number.isFinite(box.min.y) || box.min.y > PLANTED_MAX_Y) continue; // swing foot
166+
node.getWorldPosition(p);
167+
dx += anchor.x - p.x;
168+
dz += anchor.z - p.z;
169+
n++;
170+
}
171+
if (n > 0) {
172+
m.root.position.x += dx / n;
173+
m.root.position.z += dz / n;
174+
m.root.updateMatrixWorld(true);
123175
}
124176
}

packages/posecode-render/src/index.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,30 +145,31 @@ export function createViewer(
145145
scene.add(mannequin.root);
146146

147147
// --- Life layer: breathing + blinking so the figure reads as alive even
148-
// when the movement is paused. Breathing is a tiny additive sagittal
149-
// rotation layered onto the sampled pose each frame; it can never drift
150-
// because timeline.sample() rewrites those quaternions every frame.
148+
// when the movement is paused. Both are MESH-only effects. Breathing must
149+
// never rotate skeleton bones: an earlier version breathed via tiny
150+
// chest/spine rotations, but those ran before ground-lock/pin solving,
151+
// which translated the whole figure to re-plant the displaced hands/feet,
152+
// so every movement visibly swayed and the head bobbed. Swelling the
153+
// ribcage mesh cannot disturb any joint, so authored poses stay exact.
151154
const BREATH_PERIOD = 3.8; // seconds per breath cycle
152155
const BLINK_DURATION = 0.13;
153-
const LIFE_AXIS = new THREE.Vector3(1, 0, 0);
154-
const LIFE_Q = new THREE.Quaternion();
155156
const eyes = ["eye_left", "eye_right"]
156157
.map((n) => mannequin.root.getObjectByName(n))
157158
.filter((o): o is THREE.Object3D => Boolean(o));
159+
const ribcage = mannequin.root.getObjectByName("ribcage");
160+
const ribcageRestScale = ribcage ? ribcage.scale.clone() : null;
158161
let nextBlink = performance.now() / 1000 + 2;
159162

160-
function breatheBone(boneId: string, angle: number): void {
161-
const bone = mannequin.bones.get(boneId);
162-
if (!bone) return;
163-
LIFE_Q.setFromAxisAngle(LIFE_AXIS, angle);
164-
bone.quaternion.multiply(LIFE_Q);
165-
}
166-
167163
function applyLife(nowSec: number): void {
168-
const breath = Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD);
169-
breatheBone("chest", breath * 0.022);
170-
breatheBone("spine", breath * 0.012);
171-
breatheBone("neck", breath * -0.014); // counter-rotate: head stays level
164+
if (ribcage && ribcageRestScale) {
165+
// 0..1 inhale fraction; the chest swells mostly front-to-back.
166+
const breath = 0.5 + 0.5 * Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD);
167+
ribcage.scale.set(
168+
ribcageRestScale.x * (1 + breath * 0.015),
169+
ribcageRestScale.y * (1 + breath * 0.01),
170+
ribcageRestScale.z * (1 + breath * 0.05),
171+
);
172+
}
172173
if (nowSec >= nextBlink + BLINK_DURATION) {
173174
nextBlink = nowSec + 2.5 + Math.random() * 3;
174175
}
@@ -224,6 +225,7 @@ export function createViewer(
224225
// "Ground-lock" means HOLD the effector where the grounded base pose placed
225226
// it, not drag it to y=0. groundFigure() already set the floor contact.
226227
groundTargets = new Map();
228+
frameAnchorMap.clear(); // drop anchors for effectors no longer captured
227229
for (const ids of Object.values(mannequin.effectors)) {
228230
for (const id of ids) {
229231
const node = mannequin.bones.get(id);
@@ -236,6 +238,30 @@ export function createViewer(
236238
const WORLD_Y = new THREE.Vector3(0, 1, 0);
237239
const YAW_Q = new THREE.Quaternion();
238240

241+
// Per-frame ground anchors: the captured load-time effector positions,
242+
// carried along by the phase's yaw/travel so horizontal foot planting
243+
// composes with choreography instead of fighting it. Values are mutated in
244+
// place each frame; the map is rebuilt on load (captureGroundTargets).
245+
const frameAnchorMap = new Map<string, THREE.Vector3>();
246+
function frameAnchors(rootYaw: number, rootOffset: { x: number; z: number }): Map<string, THREE.Vector3> {
247+
for (const [id, captured] of groundTargets) {
248+
let v = frameAnchorMap.get(id);
249+
if (!v) {
250+
v = new THREE.Vector3();
251+
frameAnchorMap.set(id, v);
252+
}
253+
v.copy(captured);
254+
if (rootYaw !== 0) {
255+
// Yaw spins the body about the vertical axis through the root, so the
256+
// anchors must pivot with it (a quarter-turn carries the feet around).
257+
v.sub(baseRootPos).applyAxisAngle(WORLD_Y, rootYaw).add(baseRootPos);
258+
}
259+
v.x += rootOffset.x;
260+
v.z += rootOffset.z;
261+
}
262+
return frameAnchorMap;
263+
}
264+
239265
// Friendly DSL effector aliases → the distal bone whose world position is
240266
// driven to the reach target.
241267
const EFFECTOR_BONE: Record<string, string> = {
@@ -406,7 +432,7 @@ export function createViewer(
406432
mannequin.root.position.x += info.rootOffset.x;
407433
mannequin.root.position.z += info.rootOffset.z;
408434
mannequin.root.updateMatrixWorld(true);
409-
applyGroundLockTo(mannequin, info.groundLock);
435+
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
410436
applyPins(info.pins);
411437
// Safety net: nothing above ever intentionally pushes part of the body
412438
// below the floor, so clamp the root up whenever the lowest point dips

packages/posecode-render/src/mannequin.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,11 @@ function addEllipsoid(
256256
function addTorso(bones: Map<string, THREE.Object3D>, mats: FigureMaterials): void {
257257
// Hips: wide, slightly flattened, dressed in shorts.
258258
addEllipsoid(bones.get("pelvis")!, 0.09, [1.4, 1.0, 1.05], [0, -0.02, 0], mats.shorts);
259-
// Ribcage: broad across the shoulders, shallow front-to-back.
260-
addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top);
259+
// Ribcage: broad across the shoulders, shallow front-to-back. Named so the
260+
// viewer's life layer can swell it for breathing (a mesh-only effect that
261+
// can never disturb the skeleton or the solved pose).
262+
const ribcage = addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top);
263+
ribcage.name = "ribcage";
261264
// Deltoids round off the shoulder line.
262265
addEllipsoid(bones.get("shoulder_left")!, 0.057, [1.02, 1.12, 1.02], [-0.006, -0.012, 0], mats.top);
263266
addEllipsoid(bones.get("shoulder_right")!, 0.057, [1.02, 1.12, 1.02], [0.006, -0.012, 0], mats.top);

playground/src/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,10 @@ void import("./editor.js").then(({ createPosecodeEditor }) => {
507507

508508
// Renderer: keep Three.js off the critical path, mirroring the landing page.
509509
void import("posecode-render").then(({ createViewer }) => {
510-
viewer = createViewer(canvas);
510+
// No idle camera orbit in the playground: the point here is judging the
511+
// movement itself, and a permanently rotating scene reads as the figure
512+
// swaying. The landing-page hero keeps its showcase orbit.
513+
viewer = createViewer(canvas, { autoRotate: false });
511514
// Exposed for capture/e2e tooling (frame capture drives README GIFs).
512515
(window as unknown as Record<string, unknown>).__posecodeViewer = viewer;
513516
wireViewer(viewer);

0 commit comments

Comments
 (0)