From 47dfd70d004f176029c4aa2d9a7d6510dc62f2b0 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Mon, 13 Jul 2026 00:22:38 +0300 Subject: [PATCH] Mark hand-contact movements as in development --- packages/posecode-render/src/contacts.ts | 28 ----------------- packages/posecode-render/src/index.ts | 14 +++++---- .../posecode-render/test/contacts.test.ts | 30 +------------------ playground/src/main.ts | 12 ++++++-- playground/src/presets.ts | 20 ++++++++----- playground/src/style.css | 18 +++++++++++ 6 files changed, 49 insertions(+), 73 deletions(-) diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 80f301f..964a794 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -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); diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 10ec7d1..2b7df5a 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -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; @@ -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); } @@ -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); }); } diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index 829dd43..d2245f4 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -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; @@ -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(); diff --git a/playground/src/main.ts b/playground/src/main.ts index b836a8e..d3ffeb2 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -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); diff --git a/playground/src/presets.ts b/playground/src/presets.ts index d50a5d2..865d454 100644 --- a/playground/src/presets.ts +++ b/playground/src/presets.ts @@ -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; } @@ -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 --- @@ -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 }, diff --git a/playground/src/style.css b/playground/src/style.css index 7f42690..7e9306d 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -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);