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
28 changes: 0 additions & 28 deletions packages/posecode-render/src/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,34 +245,6 @@ export function swingArms(
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);
Expand Down
14 changes: 8 additions & 6 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
type ClipSource,
} from "./clips.js";
import { depenetrate } from "./depenetrate.js";
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead, orientBarGrips } from "./contacts.js";
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js";

const DEG = Math.PI / 180;

Expand Down Expand Up @@ -549,9 +549,8 @@ export function createViewer(
if (joints.length === 0) continue;
solveCCD({ joints, limits, effector, target }, 12);
}
// 3. Resolve the wrist roll left underdetermined by positional arm IK.
orientBarGrips(mannequin, grips);
// 4. Finger wrap.
// 3. Finger wrap. Wrist orientation remains inherited until the semantic
// contact-frame solver can account for this character's calibrated axes.
wrapGrip(mannequin, grips);
}

Expand Down Expand Up @@ -869,10 +868,13 @@ export function createViewer(
if (lastIR) api.load(lastIR);
else char.sync(mannequin);
})
.catch(() => {
.catch((error: unknown) => {
// Character failed (offline embed, blocked/404 CDN): reveal the
// procedural figure we may have hidden, so the scene degrades to the
// working fallback instead of staying blank. Deliberately silent.
// working fallback instead of staying blank. Keep a developer-facing
// diagnostic because malformed or incompatible rigs otherwise look
// exactly like a network fallback and are impossible to calibrate.
console.warn("Posecode character load failed; using procedural fallback", error);
if (deferProceduralMeshes) setMeshVisibility(mannequin.root, true);
});
}
Expand Down
30 changes: 1 addition & 29 deletions packages/posecode-render/test/contacts.test.ts
Original file line number Diff line number Diff line change
@@ -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, orientBarGrips } from "../src/contacts.js";
import { levelPlantedFeet, relaxHands, swingArms, aimHead } from "../src/contacts.js";
import { groundFigure } from "../src/groundlock.js";

const DEG = Math.PI / 180;
Expand Down Expand Up @@ -127,34 +127,6 @@ describe("swingArms (L4.2)", () => {
});
});

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)", () => {
it("turns the head toward a focus point (face +Z tracks the target)", () => {
const m = buildMannequin();
Expand Down
12 changes: 10 additions & 2 deletions playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,18 @@ function renderLibraryList(): void {
if (p.id === currentPresetId) item.setAttribute("aria-current", "true");
const name = document.createElement("span");
name.className = "li-name";
name.textContent = p.label;
const label = document.createElement("span");
label.textContent = p.label;
name.append(label);
if (p.status === "development") {
const status = document.createElement("span");
status.className = "li-status";
status.textContent = "In development";
name.append(status);
}
const meta = document.createElement("span");
meta.className = "li-meta";
meta.textContent = `${p.target} · ${p.equipment} · ${p.difficulty}`;
meta.textContent = p.developmentNote ?? `${p.target} · ${p.equipment} · ${p.difficulty}`;
item.append(name, meta);
item.addEventListener("click", () => {
loadPreset(p.id);
Expand Down
20 changes: 12 additions & 8 deletions playground/src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export interface Preset {
/** What it renders with: Body weight, Chair, Wall, or Bar. */
equipment: string;
difficulty: Difficulty;
/** Optional product-readiness marker shown in the movement library. */
status?: "development";
/** Short user-facing explanation of the unfinished animation area. */
developmentNote?: string;
source: string;
}

Expand All @@ -138,9 +142,9 @@ export const PRESETS: Preset[] = [
{ id: "calf-raise", label: "Single-leg calf raise", domain: "Fitness", bodyPart: "Lower legs", target: "Calves", equipment: "Body weight", difficulty: "Intermediate", source: calfRaise },
{ id: "jumping-jacks", label: "Jumping jacks", domain: "Warm-up", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: jumpingJacks },
{ 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", source: pullUp },
{ 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", source: tricepsDips },
{ 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: "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 Expand Up @@ -198,14 +202,14 @@ export const PRESETS: Preset[] = [
{ id: "sit-to-stand", label: "Sit to stand (chair)", domain: "Functional", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Chair", difficulty: "Beginner", source: sitToStand },
{ id: "box-squat", label: "Box squat (chair)", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Chair", difficulty: "Beginner", source: boxSquat },
{ id: "wall-sit", label: "Wall sit (wall)", domain: "Fitness", bodyPart: "Upper legs", target: "Quadriceps", equipment: "Wall", difficulty: "Beginner", source: wallSit },
{ id: "dead-hang", label: "Dead hang (bar)", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Beginner", source: deadHang },
{ id: "hanging-knee-raise", label: "Hanging knee raise (bar)", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Bar", difficulty: "Intermediate", source: hangingKneeRaise },
{ id: "dead-hang", label: "Dead hang (bar)", domain: "Fitness", bodyPart: "Back", target: "Lats", equipment: "Bar", difficulty: "Beginner", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: deadHang },
{ id: "hanging-knee-raise", label: "Hanging knee raise (bar)", domain: "Fitness", bodyPart: "Core", target: "Abdominals", equipment: "Bar", difficulty: "Intermediate", status: "development", developmentNote: "Hand grip and wrist contact are still being refined", source: hangingKneeRaise },

// --- Hand / finger rig ---
{ id: "make-a-fist", label: "Make a fist", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", source: makeAFist },
{ id: "pinch-grip", label: "Pinch grip", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", source: pinchGrip },
{ id: "finger-spell", label: "Finger-spelling (approx.)", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", source: fingerSpell },
{ id: "hand-wave", label: "Hand wave", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", source: handWave },
{ id: "make-a-fist", label: "Make a fist", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: makeAFist },
{ id: "pinch-grip", label: "Pinch grip", domain: "Hand therapy", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: pinchGrip },
{ id: "finger-spell", label: "Finger-spelling (approx.)", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Finger articulation is still being refined", source: fingerSpell },
{ id: "hand-wave", label: "Hand wave", domain: "Sign language", bodyPart: "Hands", target: "Forearms", equipment: "Body weight", difficulty: "Beginner", status: "development", developmentNote: "Wrist and finger articulation are still being refined", source: handWave },
{ id: "pirouette", label: "Pirouette (full turn)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Intermediate", source: pirouette },
{ id: "box-step", label: "Box step (travels)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: boxStep },
{ id: "grapevine", label: "Grapevine (travels)", domain: "Dance", bodyPart: "Full body", target: "Full body", equipment: "Body weight", difficulty: "Beginner", source: grapevine },
Expand Down
18 changes: 18 additions & 0 deletions playground/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1119,11 +1119,29 @@ select:hover {
box-shadow: inset 2px 0 0 0 var(--accent);
}
.lib-item .li-name {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
font-family: var(--sans);
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.lib-item .li-status {
flex: none;
font-family: var(--mono);
font-size: 9px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.45px;
text-transform: uppercase;
color: #f0bd68;
background: rgba(240, 189, 104, 0.1);
border: 1px solid rgba(240, 189, 104, 0.3);
border-radius: 999px;
padding: 4px 6px 3px;
}
.lib-item .li-meta {
font-size: 11.5px;
color: var(--muted);
Expand Down
Loading