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
24 changes: 23 additions & 1 deletion packages/posecode-eval/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ export function probeMovement(source: string): ProbeResult {
const baseRootPos = m.root.position.clone();
const baseRootQuat = m.root.quaternion.clone();

// Mirror Viewer.captureGroundTargets(): the grounded base-pose effector
// positions are the anchors horizontal foot planting holds feet to.
const groundTargets = new Map<string, THREE.Vector3>();
for (const ids of Object.values(m.effectors)) {
for (const id of ids) {
const node = m.bones.get(id);
if (node) groundTargets.set(id, node.getWorldPosition(new THREE.Vector3()));
}
}

// Sample the end of each phase, applying the viewer's per-frame root
// pipeline: base root → yaw/travel → ground-lock → floor safety clamp.
const yawQ = new THREE.Quaternion();
Expand All @@ -82,7 +92,19 @@ export function probeMovement(source: string): ProbeResult {
m.root.position.x += info.rootOffset.x;
m.root.position.z += info.rootOffset.z;
m.root.updateMatrixWorld(true);
applyGroundLock(m, info.groundLock);
// Mirror the viewer's per-frame anchors: captured targets carried along
// by this phase's yaw/travel so planting composes with choreography.
const anchors = new Map<string, THREE.Vector3>();
for (const [id, captured] of groundTargets) {
const v = captured.clone();
if (info.rootYaw !== 0) {
v.sub(baseRootPos).applyAxisAngle(WORLD_Y, info.rootYaw).add(baseRootPos);
}
v.x += info.rootOffset.x;
v.z += info.rootOffset.z;
anchors.set(id, v);
}
applyGroundLock(m, info.groundLock, anchors);
// Viewer safety net: never leave the lowest mesh point below the floor.
m.root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(m.root);
Expand Down
56 changes: 54 additions & 2 deletions packages/posecode-render/src/groundlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
* - **Feet only (squat / hinge / roll-down):** drop the body vertically so the
* feet stay planted while the legs keep their authored FK bend: the pelvis
* lowers. Legs are never CCD-solved (that would overwrite the pose).
* With `anchors`, grounded feet are also held HORIZONTALLY: FK leg motion
* (hip/knee) displaces the feet relative to the root, and without the
* correction the feet skate across the floor while the pelvis stays put —
* backwards from real movement, where planted feet stay fixed and the
* pelvis travels (a squat sits the hips back, a hinge shifts them behind
* the heels). Only feet near the floor anchor (a swing leg in a curl or
* march must stay free), and only the average delta is corrected so
* symmetric spreads (jumping jacks) don't fight the lock.
*
* Both paths ground the visible MESH (bounding boxes), not just bone origins:
* an ankle bone sits ~0.04m above the sole, so anchoring bones alone left the
Expand Down Expand Up @@ -71,8 +79,19 @@ function rotateRootAboutPivot(m: Mannequin, pivot: THREE.Vector3, angle: number)
m.root.updateMatrixWorld(true);
}

/** Apply ground-lock for the phase's active effector groups (see module doc). */
export function applyGroundLock(m: Mannequin, active: string[]): void {
/** A foot whose mesh bottom is within this height counts as planted. */
const PLANTED_MAX_Y = 0.05;

/**
* Apply ground-lock for the phase's active effector groups (see module doc).
* `anchors` (optional) maps effector bone ids to the world position each
* planted foot should hold, already transformed by the phase's yaw/travel.
*/
export function applyGroundLock(
m: Mannequin,
active: string[],
anchors?: ReadonlyMap<string, THREE.Vector3>,
): void {
if (active.length === 0) return;
const ids = activeEffectorIds(m, active);
const hands = ids.filter((id) => id.startsWith("wrist"));
Expand Down Expand Up @@ -120,5 +139,38 @@ export function applyGroundLock(m: Mannequin, active: string[]): void {
m.root.position.y -= minY;
m.root.updateMatrixWorld(true);
}
if (anchors) plantFeetHorizontally(m, feet, anchors);
}
}

/**
* Translate the root in X/Z so grounded feet return to their anchors (see
* module doc). Runs after vertical grounding so "near the floor" is judged in
* the final vertical placement.
*/
function plantFeetHorizontally(
m: Mannequin,
feet: string[],
anchors: ReadonlyMap<string, THREE.Vector3>,
): void {
const p = new THREE.Vector3();
let dx = 0;
let dz = 0;
let n = 0;
for (const id of feet) {
const anchor = anchors.get(id);
const node = m.bones.get(id);
if (!anchor || !node) continue;
const box = new THREE.Box3().setFromObject(node);
if (!Number.isFinite(box.min.y) || box.min.y > PLANTED_MAX_Y) continue; // swing foot
node.getWorldPosition(p);
dx += anchor.x - p.x;
dz += anchor.z - p.z;
n++;
}
if (n > 0) {
m.root.position.x += dx / n;
m.root.position.z += dz / n;
m.root.updateMatrixWorld(true);
}
}
60 changes: 43 additions & 17 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,30 +145,31 @@ export function createViewer(
scene.add(mannequin.root);

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

function breatheBone(boneId: string, angle: number): void {
const bone = mannequin.bones.get(boneId);
if (!bone) return;
LIFE_Q.setFromAxisAngle(LIFE_AXIS, angle);
bone.quaternion.multiply(LIFE_Q);
}

function applyLife(nowSec: number): void {
const breath = Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD);
breatheBone("chest", breath * 0.022);
breatheBone("spine", breath * 0.012);
breatheBone("neck", breath * -0.014); // counter-rotate: head stays level
if (ribcage && ribcageRestScale) {
// 0..1 inhale fraction; the chest swells mostly front-to-back.
const breath = 0.5 + 0.5 * Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD);
ribcage.scale.set(
ribcageRestScale.x * (1 + breath * 0.015),
ribcageRestScale.y * (1 + breath * 0.01),
ribcageRestScale.z * (1 + breath * 0.05),
);
}
if (nowSec >= nextBlink + BLINK_DURATION) {
nextBlink = nowSec + 2.5 + Math.random() * 3;
}
Expand Down Expand Up @@ -224,6 +225,7 @@ export function createViewer(
// "Ground-lock" means HOLD the effector where the grounded base pose placed
// it, not drag it to y=0. groundFigure() already set the floor contact.
groundTargets = new Map();
frameAnchorMap.clear(); // drop anchors for effectors no longer captured
for (const ids of Object.values(mannequin.effectors)) {
for (const id of ids) {
const node = mannequin.bones.get(id);
Expand All @@ -236,6 +238,30 @@ export function createViewer(
const WORLD_Y = new THREE.Vector3(0, 1, 0);
const YAW_Q = new THREE.Quaternion();

// Per-frame ground anchors: the captured load-time effector positions,
// carried along by the phase's yaw/travel so horizontal foot planting
// composes with choreography instead of fighting it. Values are mutated in
// place each frame; the map is rebuilt on load (captureGroundTargets).
const frameAnchorMap = new Map<string, THREE.Vector3>();
function frameAnchors(rootYaw: number, rootOffset: { x: number; z: number }): Map<string, THREE.Vector3> {
for (const [id, captured] of groundTargets) {
let v = frameAnchorMap.get(id);
if (!v) {
v = new THREE.Vector3();
frameAnchorMap.set(id, v);
}
v.copy(captured);
if (rootYaw !== 0) {
// Yaw spins the body about the vertical axis through the root, so the
// anchors must pivot with it (a quarter-turn carries the feet around).
v.sub(baseRootPos).applyAxisAngle(WORLD_Y, rootYaw).add(baseRootPos);
}
v.x += rootOffset.x;
v.z += rootOffset.z;
}
return frameAnchorMap;
}

// Friendly DSL effector aliases → the distal bone whose world position is
// driven to the reach target.
const EFFECTOR_BONE: Record<string, string> = {
Expand Down Expand Up @@ -406,7 +432,7 @@ export function createViewer(
mannequin.root.position.x += info.rootOffset.x;
mannequin.root.position.z += info.rootOffset.z;
mannequin.root.updateMatrixWorld(true);
applyGroundLockTo(mannequin, info.groundLock);
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
applyPins(info.pins);
// Safety net: nothing above ever intentionally pushes part of the body
// below the floor, so clamp the root up whenever the lowest point dips
Expand Down
7 changes: 5 additions & 2 deletions packages/posecode-render/src/mannequin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,11 @@ function addEllipsoid(
function addTorso(bones: Map<string, THREE.Object3D>, mats: FigureMaterials): void {
// Hips: wide, slightly flattened, dressed in shorts.
addEllipsoid(bones.get("pelvis")!, 0.09, [1.4, 1.0, 1.05], [0, -0.02, 0], mats.shorts);
// Ribcage: broad across the shoulders, shallow front-to-back.
addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top);
// Ribcage: broad across the shoulders, shallow front-to-back. Named so the
// viewer's life layer can swell it for breathing (a mesh-only effect that
// can never disturb the skeleton or the solved pose).
const ribcage = addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top);
ribcage.name = "ribcage";
// Deltoids round off the shoulder line.
addEllipsoid(bones.get("shoulder_left")!, 0.057, [1.02, 1.12, 1.02], [-0.006, -0.012, 0], mats.top);
addEllipsoid(bones.get("shoulder_right")!, 0.057, [1.02, 1.12, 1.02], [0.006, -0.012, 0], mats.top);
Expand Down
5 changes: 4 additions & 1 deletion playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,10 @@ void import("./editor.js").then(({ createPosecodeEditor }) => {

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