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
71 changes: 69 additions & 2 deletions packages/posecode-eval/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,67 @@ export function probeMovement(source: string): ProbeResult {
}
}

// Precompute world positions of all effectors at start of each segment
const segmentStartEffectors: Map<string, THREE.Vector3>[] = [];
const tempYawQ = new THREE.Quaternion();

const getEffectorId = (eff: string) => {
if (eff === "hand_left") return "wrist_left";
if (eff === "hand_right") return "wrist_right";
if (eff === "foot_left") return "ankle_left";
if (eff === "foot_right") return "ankle_right";
return eff;
};

let prevEffectorsMap: Map<string, THREE.Vector3> | null = null;
let prevPins: typeof ir.phases[number]["pins"] = [];

for (let i = 0; i < tl.segments.length; i++) {
const seg = tl.segments[i]!;
for (const bone of m.bones.values()) bone.quaternion.identity();
const info = tl.sample(seg.start, m.bones);

const wasPinned = (id: string) => prevPins.some(p => getEffectorId(p.effector) === id && p.anchor === "floor");
const isPinned = (id: string) => info.pins.some(p => getEffectorId(p.effector) === id && p.anchor === "floor");

m.root.position.copy(baseRootPos);
m.root.quaternion.copy(baseRootQuat);
if (info.rootYaw !== 0) {
tempYawQ.setFromAxisAngle(WORLD_Y, info.rootYaw);
m.root.quaternion.premultiply(tempYawQ);
}
m.root.position.x += info.rootOffset.x;
m.root.position.z += info.rootOffset.z;
m.root.updateMatrixWorld(true);
depenetrate(m);

const effectorsMap = 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) {
if (i > 0 && wasPinned(id) && isPinned(id) && prevEffectorsMap && prevEffectorsMap.has(id)) {
effectorsMap.set(id, prevEffectorsMap.get(id)!);
} else {
effectorsMap.set(id, node.getWorldPosition(new THREE.Vector3()));
}
}
}
}
segmentStartEffectors.push(effectorsMap);
prevEffectorsMap = effectorsMap;
prevPins = info.pins;
}

// Restore initial state
for (const bone of m.bones.values()) bone.quaternion.identity();
tl.sample(0, m.bones);
m.root.position.copy(baseRootPos);
m.root.quaternion.copy(baseRootQuat);
m.root.updateMatrixWorld(true);
depenetrate(m);
groundFigure(m);

// 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 Down Expand Up @@ -166,8 +227,14 @@ export function probeMovement(source: string): ProbeResult {
if (!effector) continue;
let target: THREE.Vector3 | null = null;
if (pin.anchor === "floor") {
target = effector.getWorldPosition(new THREE.Vector3());
target.y = 0;
const startPos = segmentStartEffectors[phaseIndex]?.get(effectorId);
if (startPos) {
target = startPos.clone();
target.y = 0;
} else {
target = effector.getWorldPosition(new THREE.Vector3());
target.y = 0;
}
} else if (propScene.anchors.has(pin.anchor)) {
target = propScene.anchors.get(pin.anchor)!.clone();
} else {
Expand Down
89 changes: 88 additions & 1 deletion packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface Viewer {
getTimeline(): TimelineInfo | null;
/** Precise visible world bounds; intended for audits and deterministic export. */
getVisibleBounds(): THREE.Box3;
getMannequin(): any;
getCharacter(): any;
/**
* Render the current time synchronously and return the frame as a PNG data
* URL. Works without preserveDrawingBuffer because the read happens in the
Expand Down Expand Up @@ -305,6 +307,7 @@ export function createViewer(
// ground anchors when the character (with its own proportions) arrives.
let lastIR: PosecodeIR | null = null;
let groundTargets = new Map<string, THREE.Vector3>();
const segmentStartEffectors: Map<string, THREE.Vector3>[] = [];
// World-space anchor points contributed by scene props (chair seat, bar grip,
// wall surface). Populated when a doc declares props; empty otherwise.
let propAnchors = new Map<string, THREE.Vector3>();
Expand All @@ -322,6 +325,7 @@ export function createViewer(
let tickCb: (time: number, duration: number) => void = () => {};
let loopCb: () => void = () => {};
let lastPhaseName = "";
let activeSegIndex = 0;

// Camera easing targets.
const desiredTarget = new THREE.Vector3(0, 0.9, 0);
Expand Down Expand Up @@ -505,7 +509,20 @@ export function createViewer(
const effectorBone = EFFECTOR_BONE[p.effector] ?? p.effector;
const effector = mannequin.bones.get(effectorBone);
if (!effector) continue;
const anchor = resolveReachTarget(p.anchor, effector);
let anchor: THREE.Vector3 | null = null;
if (p.anchor === "floor") {
const startPos = segmentStartEffectors[activeSegIndex]?.get(effectorBone);
if (startPos) {
anchor = startPos.clone();
const isFoot = effectorBone.startsWith("ankle") || effectorBone.startsWith("foot");
const drop = isFoot ? (character?.proportions.soleDrop ?? 0.042) : 0;
anchor.y = drop;
} else {
anchor = resolveReachTarget(p.anchor, effector);
}
} else {
anchor = resolveReachTarget(p.anchor, effector);
}
if (!anchor) continue;
delta.add(anchor.sub(effector.getWorldPosition(new THREE.Vector3())));
n++;
Expand Down Expand Up @@ -631,6 +648,15 @@ export function createViewer(
if (timeline) {
const info = timeline.sample(time, mannequin.bones);
solvedInfo = info;
activeSegIndex = 0;
const tt = timeline.duration > 0 ? ((time % timeline.duration) + timeline.duration) % timeline.duration : 0;
for (let k = 0; k < timeline.segments.length; k++) {
const seg = timeline.segments[k]!;
if (tt >= seg.start && tt <= seg.end) {
activeSegIndex = k;
break;
}
}
// Life layer rides on wall-clock time (not timeline time) so the figure
// keeps breathing and blinking while paused or scrubbing.
applyLife(performance.now() / 1000);
Expand Down Expand Up @@ -823,6 +849,61 @@ export function createViewer(
captureGroundTargets();
baseRootPos.copy(mannequin.root.position);
baseRootQuat.copy(mannequin.root.quaternion);

// Precompute world positions of all effectors at start of each segment
segmentStartEffectors.length = 0;
if (timeline) {
let prevEffectorsMap: Map<string, THREE.Vector3> | null = null;
let prevPins: PinTarget[] = [];
for (let i = 0; i < timeline.segments.length; i++) {
const seg = timeline.segments[i]!;
for (const bone of mannequin.bones.values()) bone.quaternion.identity();
const info = timeline.sample(seg.start, mannequin.bones);

const wasPinned = (id: string) => prevPins.some(p => (EFFECTOR_BONE[p.effector] ?? p.effector) === id && p.anchor === "floor");
const isPinned = (id: string) => info.pins.some(p => (EFFECTOR_BONE[p.effector] ?? p.effector) === id && p.anchor === "floor");

mannequin.root.position.copy(baseRootPos);
mannequin.root.quaternion.copy(baseRootQuat);
if (info.rootYaw !== 0) {
const yawQ = new THREE.Quaternion().setFromAxisAngle(WORLD_Y, info.rootYaw);
mannequin.root.quaternion.premultiply(yawQ);
}
mannequin.root.position.x += info.rootOffset.x;
mannequin.root.position.z += info.rootOffset.z;
mannequin.root.updateMatrixWorld(true);
depenetrate(mannequin);

const effectorsMap = new Map<string, THREE.Vector3>();
for (const ids of Object.values(mannequin.effectors)) {
for (const id of ids) {
const node = mannequin.bones.get(id);
if (node) {
if (i > 0 && wasPinned(id) && isPinned(id) && prevEffectorsMap && prevEffectorsMap.has(id)) {
effectorsMap.set(id, prevEffectorsMap.get(id)!);
} else {
effectorsMap.set(id, node.getWorldPosition(new THREE.Vector3()));
}
}
}
}
segmentStartEffectors.push(effectorsMap);
prevEffectorsMap = effectorsMap;
prevPins = info.pins;
}

// Restore initial pose
for (const bone of mannequin.bones.values()) bone.quaternion.identity();
applyBaseRoot();
timeline.sample(0, mannequin.bones);
mannequin.root.position.copy(baseRootPos);
mannequin.root.quaternion.copy(baseRootQuat);
mannequin.root.updateMatrixWorld(true);
depenetrate(mannequin);
groundFigureOf(mannequin);
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
}

requestClip(ir);
frameCamera();
},
Expand Down Expand Up @@ -874,6 +955,12 @@ export function createViewer(
getVisibleBounds() {
return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root);
},
getMannequin() {
return mannequin;
},
getCharacter() {
return character;
},
captureFrame() {
frame();
return renderer.domElement.toDataURL("image/png");
Expand Down
2 changes: 1 addition & 1 deletion playground/src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const PRESETS: Preset[] = [
{ id: "box-step-taps", label: "Box step taps", domain: "Warm-up", bodyPart: "Upper legs", target: "Hip flexors", equipment: "Box", difficulty: "Beginner", source: boxStepTaps },
{ id: "pull-up", label: "Pull-up", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Advanced", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: pullUp },
{ id: "step-up", label: "Step-up (box)", domain: "Functional", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Box", difficulty: "Intermediate", source: stepUp },
{ id: "triceps-dips", label: "Triceps dips (chair)", domain: "Fitness", bodyPart: "Upper arms", target: "Triceps", equipment: "Chair", difficulty: "Intermediate", status: "development", developmentNote: "Hand support and wrist contact are still being refined", source: tricepsDips },
{ id: "triceps-dips", label: "Triceps dips (bars)", domain: "Fitness", bodyPart: "Upper arms", target: "Triceps", equipment: "Bars", difficulty: "Intermediate", status: "development", developmentNote: "Hand support and wrist contact are still being refined", source: tricepsDips },
{ id: "quad-stretch", label: "Standing quad stretch", domain: "Mobility", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Body weight", difficulty: "Beginner", source: quadStretch },

// --- Education / anatomy: single-joint ROM demos ---
Expand Down
4 changes: 2 additions & 2 deletions spec/examples/forward-lunge.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ posecode exercise "Forward lunge"
knee_right: flex 95
hip_left: extend 15
knee_left: flex 80
ankle_right: plantarflex 50
ankle_left: plantarflex 50
spine: extend 4
travel: 0 0.3
pin: foot_left floor
Expand All @@ -19,7 +19,7 @@ posecode exercise "Forward lunge"
knee_right: flex 0
hip_left: extend 0
knee_left: flex 0
ankle_right: plantarflex 0
ankle_left: plantarflex 0
spine: flex 0
travel: 0 0
ground-lock: feet
Expand Down
4 changes: 2 additions & 2 deletions spec/examples/superman.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ posecode exercise "Superman"
pose start = prone

step "Lift" 1.5s settle:
shoulders: abduct 130
shoulders: flex 140
spine: extend 20
chest: extend 15
neck: extend 25
Expand All @@ -12,7 +12,7 @@ posecode exercise "Superman"
cue "Lift the arms, chest, and legs off the floor"

step "Lower" 1.5s drive:
shoulders: abduct 0
shoulders: flex 0
spine: flex 0
chest: flex 0
neck: flex 0
Expand Down
Loading