cozyclay 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/LIVE-PROTOCOL.md +1 -1
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/otio.js +2 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scene-objects.js +37 -1
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +460 -1742
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { CSKEL27_PARENTS } from "../../../src/ardy/cskel27.js";
|
|
2
|
+
import { deriveBoneOffsets, forwardKinematics, globalRotations, matMul, matTranspose, quatToMat } from "../../../src/ardy/convert.js";
|
|
3
|
+
import { add, bodyOffsets, cloneMotion, jointsAt, localsAt, matVec, shiftFrame, smoothstep, sub, vec } from "./motion.mjs";
|
|
4
|
+
|
|
5
|
+
const FEET = [21, 22, 25, 26], HANDS = [10, 16];
|
|
6
|
+
const EPS = 1e-5;
|
|
7
|
+
|
|
8
|
+
export function validateBoxes(boxes) {
|
|
9
|
+
if (!Array.isArray(boxes) || boxes.some(b => ![b?.min, b?.max].every(v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite)) || b.min.some((v, k) => v >= b.max[k]))) {
|
|
10
|
+
throw new Error("scene: boxes must be {min:[x,y,z],max:[x,y,z]} with min < max");
|
|
11
|
+
}
|
|
12
|
+
return boxes;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Smallest translation of an interior POINT to a box face. Boundary is not
|
|
16
|
+
* penetration. Used for hand contact proximity; no skin radius is implied. */
|
|
17
|
+
export function boxPenetration(point, box) {
|
|
18
|
+
if (point.some((v, k) => v <= box.min[k] || v >= box.max[k])) return [0, 0, 0];
|
|
19
|
+
const candidates = box.min.flatMap((lo, k) => [[k, lo - point[k]], [k, box.max[k] - point[k]]]);
|
|
20
|
+
candidates.sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]));
|
|
21
|
+
const delta = [0, 0, 0]; delta[candidates[0][0]] = candidates[0][1];
|
|
22
|
+
return delta;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Open-interior segment/AABB slab intersection catches a bone crossing a
|
|
26
|
+
* box even when both of its joints lie outside (point-only tests miss it). */
|
|
27
|
+
export function segmentPenetratesBox(a, b, box) {
|
|
28
|
+
let near = 0, far = 1;
|
|
29
|
+
for (let k = 0; k < 3; k++) {
|
|
30
|
+
const d = b[k] - a[k];
|
|
31
|
+
if (Math.abs(d) < 1e-12) {
|
|
32
|
+
if (a[k] <= box.min[k] || a[k] >= box.max[k]) return false;
|
|
33
|
+
} else {
|
|
34
|
+
const t0 = (box.min[k] - a[k]) / d, t1 = (box.max[k] - a[k]) / d;
|
|
35
|
+
near = Math.max(near, Math.min(t0, t1)); far = Math.min(far, Math.max(t0, t1));
|
|
36
|
+
if (near >= far) return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return near < far;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function intersects(points, box) {
|
|
43
|
+
return CSKEL27_PARENTS.some((p, j) => p !== null && segmentPenetratesBox(points[p], points[j], box));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Conservative rigid skeleton projection. On collision, clear the whole
|
|
47
|
+
* skeleton along its nearest feasible separating axis, not independent
|
|
48
|
+
* joint pushes (which would stretch bones). Floor wins over downward pushes.
|
|
49
|
+
* This tests joint/bone segments, NOT mesh/capsule thickness or self-collision.
|
|
50
|
+
* Multiple mutually obstructing boxes fail explicitly instead of silently
|
|
51
|
+
* emitting penetration. Cube/mesh refinement is a separate experiment. */
|
|
52
|
+
export function resolveScene(points, boxes, { floorY = 0 } = {}) {
|
|
53
|
+
let placed = points.map(p => p.slice()), total = [0, 0, 0];
|
|
54
|
+
for (let pass = 0; pass < 32; pass++) {
|
|
55
|
+
const floorLift = Math.max(0, floorY + EPS - Math.min(...FEET.map(j => placed[j][1])));
|
|
56
|
+
if (floorLift) { const d = [0, floorLift, 0]; placed = placed.map(p => add(p, d)); total = add(total, d); }
|
|
57
|
+
const box = boxes.find(b => intersects(placed, b));
|
|
58
|
+
if (!box) return { delta: total, positions: placed };
|
|
59
|
+
const candidates = [];
|
|
60
|
+
for (let k = 0; k < 3; k++) {
|
|
61
|
+
const lo = Math.min(...placed.map(p => p[k])), hi = Math.max(...placed.map(p => p[k]));
|
|
62
|
+
for (const amount of [box.min[k] - hi - EPS, box.max[k] - lo + EPS]) {
|
|
63
|
+
if (k === 1 && Math.min(...FEET.map(j => placed[j][1])) + amount < floorY) continue;
|
|
64
|
+
const delta = [0, 0, 0]; delta[k] = amount; candidates.push(delta);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
candidates.sort((a, b) => Math.hypot(...a) - Math.hypot(...b));
|
|
68
|
+
placed = placed.map(p => add(p, candidates[0])); total = add(total, candidates[0]);
|
|
69
|
+
}
|
|
70
|
+
throw new Error("scene: no collision-free rigid placement after 32 projections; constraints conflict");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function penetrates(points, boxes, floorY = 0) {
|
|
74
|
+
return Math.min(...FEET.map(j => points[j][1])) < floorY - EPS || boxes.some(b => intersects(points, b));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Scene corrections that are smooth in time. Per frame: the smallest push
|
|
78
|
+
* that clears the skeleton (its nearest face may change between frames). Then
|
|
79
|
+
* each signed axis component
|
|
80
|
+
* is dilated by `radius` frames and Gaussian-smoothed (sigma = radius / 3):
|
|
81
|
+
* the result is at least the needed push at every frame, so it stays
|
|
82
|
+
* collision-free, and it ramps in and out instead of snapping. The per-frame
|
|
83
|
+
* solver picks each frame's nearest face from scratch and teleported the body
|
|
84
|
+
* whenever that face changed (fal stepup: 0.76 m in one frame). */
|
|
85
|
+
export function continuousSceneDeltas(motion, boxes, radius) {
|
|
86
|
+
const n = motion.frames, shifted = (f, delta) => jointsAt(motion, f).map(p => add(p, delta));
|
|
87
|
+
const blocked = (f, delta) => penetrates(shifted(f, delta), boxes);
|
|
88
|
+
const raw = Array.from({ length: n }, (_, f) => (blocked(f, [0, 0, 0]) ? resolveScene(shifted(f, [0, 0, 0]), boxes).delta : [0, 0, 0]));
|
|
89
|
+
const sigma = Math.max(1, radius / 3), kernel = Array.from({ length: 2 * radius + 1 }, (_, k) => Math.exp(-0.5 * ((k - radius) / sigma) ** 2));
|
|
90
|
+
const at = (x, f) => x[Math.min(n - 1, Math.max(0, f))];
|
|
91
|
+
const ramp = (x) => {
|
|
92
|
+
const dilated = x.map((_, f) => { let m = 0; for (let k = -radius; k <= radius; k++) m = Math.max(m, at(x, f + k)); return m; });
|
|
93
|
+
return dilated.map((_, f) => { let s = 0, t = 0; for (let k = -radius; k <= radius; k++) { s += kernel[k + radius] * at(dilated, f + k); t += kernel[k + radius]; } return s / t; });
|
|
94
|
+
};
|
|
95
|
+
const axes = [0, 1, 2].map(k => {
|
|
96
|
+
const up = ramp(raw.map(d => Math.max(0, d[k]))), down = ramp(raw.map(d => Math.max(0, -d[k])));
|
|
97
|
+
return up.map((v, f) => v - down[f]);
|
|
98
|
+
});
|
|
99
|
+
// Where opposite pushes on one axis overlap, the ramp can fall short: top it
|
|
100
|
+
// up by the smallest extra push (small, since the ramp already carries most).
|
|
101
|
+
return raw.map((_, f) => { const smooth = [axes[0][f], axes[1][f], axes[2][f]]; return blocked(f, smooth) ? add(smooth, resolveScene(shifted(f, smooth), boxes).delta) : smooth; });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Only these descendants can be moved without moving the torso/attachment.
|
|
105
|
+
// [hip/shoulder, knee/elbow, ankle/wrist, ...toe/finger joints]
|
|
106
|
+
const LIMBS = [[19, 20, 21, 22], [23, 24, 25, 26], [8, 9, 10, 11, 12], [14, 15, 16, 17, 18]];
|
|
107
|
+
const LIMB_BONES = new Set(LIMBS.flatMap(chain => chain.slice(1)));
|
|
108
|
+
const dot = (a, b) => a.reduce((s, v, k) => s + v * b[k], 0);
|
|
109
|
+
const scale = (v, s) => v.map(x => x * s);
|
|
110
|
+
const unit = v => scale(v, 1 / Math.hypot(...v));
|
|
111
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
112
|
+
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
|
|
113
|
+
|
|
114
|
+
/** Forbidden ray offsets for a whole BONE, not just its endpoints. Clip the
|
|
115
|
+
* convex polygon (t,s) with a + t*(b-a) + s*ray inside the six box planes,
|
|
116
|
+
* then project onto s. A small margin survives Float32 FK/writeback. */
|
|
117
|
+
function boneRayInterval(a, b, ray, box) {
|
|
118
|
+
const corners = Array.from({ length: 8 }, (_, i) => box.min.map((v, k) => i & (1 << k) ? box.max[k] : v));
|
|
119
|
+
const extent = 1 + Math.max(...corners.flatMap(p => [Math.hypot(...sub(p, a)), Math.hypot(...sub(p, b))]));
|
|
120
|
+
let polygon = [[0, -extent], [1, -extent], [1, extent], [0, extent]];
|
|
121
|
+
for (let k = 0; k < 3; k++) for (const sign of [-1, 1]) {
|
|
122
|
+
const face = sign > 0 ? box.max[k] + EPS : box.min[k] - EPS;
|
|
123
|
+
const distance = p => sign * (a[k] + p[0] * (b[k] - a[k]) + p[1] * ray[k] - face);
|
|
124
|
+
const clipped = [];
|
|
125
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
126
|
+
const p = polygon[i], q = polygon[(i + 1) % polygon.length], dp = distance(p), dq = distance(q);
|
|
127
|
+
if (dp <= 0) clipped.push(p);
|
|
128
|
+
if ((dp < 0 && dq > 0) || (dp > 0 && dq < 0)) clipped.push(p.map((v, j) => v + (q[j] - v) * dp / (dp - dq)));
|
|
129
|
+
}
|
|
130
|
+
polygon = clipped;
|
|
131
|
+
if (!polygon.length) return null;
|
|
132
|
+
}
|
|
133
|
+
return [Math.min(...polygon.map(p => p[1])), Math.max(...polygon.map(p => p[1]))];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Ray offsets s at which one frame's TORSO (non-limb bones) crosses a box.
|
|
137
|
+
* The torso MUST be clear: limb IK cannot repair its penetration. Swept
|
|
138
|
+
* interval endpoints avoid a depth grid missing a narrow feasible corridor. */
|
|
139
|
+
function torsoRayIntervals(points, ray, boxes) {
|
|
140
|
+
const intervals = [];
|
|
141
|
+
for (let j = 1; j < 27; j++) if (!LIMB_BONES.has(j)) for (const box of boxes) {
|
|
142
|
+
const interval = boneRayInterval(points[CSKEL27_PARENTS[j]], points[j], ray, box);
|
|
143
|
+
if (interval) intervals.push(interval);
|
|
144
|
+
}
|
|
145
|
+
return intervals;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Smallest t >= t0 (t = sign * s) outside every forbidden torso interval. */
|
|
149
|
+
function clearFrom(intervals, sign, t0) {
|
|
150
|
+
let t = t0;
|
|
151
|
+
for (let moved = true; moved;) {
|
|
152
|
+
moved = false;
|
|
153
|
+
for (const [lo, hi] of intervals) {
|
|
154
|
+
const a = sign > 0 ? lo : -hi, b = sign > 0 ? hi : -lo;
|
|
155
|
+
if (t >= a - EPS && t <= b + EPS) { t = b + 2 * EPS; moved = true; }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return t;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Exact upper envelope max_g (required[g] - path budget g..f): the smallest
|
|
162
|
+
* curve at or above `required` whose change on edge f-1..f is <= budget[f]. */
|
|
163
|
+
function slopeEnvelope(required, budget) {
|
|
164
|
+
const t = required.slice();
|
|
165
|
+
for (let f = 1; f < t.length; f++) t[f] = Math.max(t[f], t[f - 1] - budget[f]);
|
|
166
|
+
for (let f = t.length - 2; f >= 0; f--) t[f] = Math.max(t[f], t[f + 1] - budget[f + 1]);
|
|
167
|
+
return t;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Time-varying offset for one sign: per frame the minimal torso-clear
|
|
171
|
+
* t_req(f) >= 0 (0 when the frame needs none), then its slope-limited upper
|
|
172
|
+
* envelope, so t is 0 far from any collision and ramps in/out. The vector
|
|
173
|
+
* correction step |s_f r_f - s_{f-1} r_{f-1}| <= |ds| + |s| |dr|, so each
|
|
174
|
+
* edge's slope budget is rayMaxStepM minus what the turning ray already uses
|
|
175
|
+
* at the peak offset. An envelope value may land inside another torso
|
|
176
|
+
* interval; raise that frame's requirement past it and rebuild. Requirements
|
|
177
|
+
* only increase through a finite set of interval ends, so this terminates.
|
|
178
|
+
* Returns null when the sign cannot stay within the caps/budget. */
|
|
179
|
+
function signedRayOffsets(intervals, sign, caps, turns, rayMaxStepM) {
|
|
180
|
+
const required = intervals.map(i => clearFrom(i, sign, 0));
|
|
181
|
+
for (;;) {
|
|
182
|
+
const peak = Math.max(...required), budget = turns.map(d => rayMaxStepM - peak * d - 1e-9);
|
|
183
|
+
if (budget.some((b, f) => f && !(b > 0))) return null;
|
|
184
|
+
const t = slopeEnvelope(required, budget);
|
|
185
|
+
if (t.some((v, f) => v > caps[f])) return null;
|
|
186
|
+
let raised = false;
|
|
187
|
+
for (let f = 0; f < t.length; f++) {
|
|
188
|
+
const clear = clearFrom(intervals[f], sign, t[f]);
|
|
189
|
+
if (clear > t[f]) { required[f] = clear; raised = true; }
|
|
190
|
+
}
|
|
191
|
+
if (!raised) return t.map(v => sign * v);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function perpendicular(v) {
|
|
196
|
+
const axis = Math.abs(v[0]) < 0.8 ? [1, 0, 0] : [0, 1, 0];
|
|
197
|
+
return unit(sub(axis, scale(v, dot(axis, v))));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function swingRotation(from, to) {
|
|
201
|
+
const a = unit(from), b = unit(to), cosine = clamp(dot(a, b), -1, 1);
|
|
202
|
+
return cosine < -1 + 1e-10 ? quatToMat([0, ...perpendicular(a)]) : quatToMat([1 + cosine, ...cross(a, b)]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Analytic two-bone IK; only the chain changes. Preserve the effector's
|
|
206
|
+
* world orientation so a corrected ankle does not tip its toe into the floor
|
|
207
|
+
* (and a wrist does not swing its fingers back through the box). */
|
|
208
|
+
function limbPose(locals, points, globals, offsets, chain, target, poleAngle) {
|
|
209
|
+
const [a, b, c] = chain, origin = points[a], toward = sub(target, origin);
|
|
210
|
+
const l0 = Math.hypot(...offsets[b]), l1 = Math.hypot(...offsets[c]);
|
|
211
|
+
const distance = Math.hypot(...toward), direction = distance > EPS ? scale(toward, 1 / distance) : unit(sub(points[c], origin));
|
|
212
|
+
const reach = clamp(distance, Math.abs(l0 - l1) + EPS, l0 + l1 - EPS);
|
|
213
|
+
const along = (l0 * l0 + reach * reach - l1 * l1) / (2 * reach);
|
|
214
|
+
const height = Math.sqrt(Math.max(0, l0 * l0 - along * along));
|
|
215
|
+
const oldBend = sub(points[b], origin), projected = sub(oldBend, scale(direction, dot(oldBend, direction)));
|
|
216
|
+
const bend = Math.hypot(...projected) > EPS ? unit(projected) : perpendicular(direction);
|
|
217
|
+
const pole = add(scale(bend, Math.cos(poleAngle)), scale(cross(direction, bend), Math.sin(poleAngle)));
|
|
218
|
+
const elbow = add(origin, add(scale(direction, along), scale(pole, height)));
|
|
219
|
+
const end = add(origin, scale(direction, reach));
|
|
220
|
+
const upper = matMul(swingRotation(sub(points[b], origin), sub(elbow, origin)), globals[a]);
|
|
221
|
+
const movedLower = matMul(upper, locals[b]);
|
|
222
|
+
const lower = matMul(swingRotation(matVec(movedLower, offsets[c]), sub(end, elbow)), movedLower);
|
|
223
|
+
const solved = locals.slice();
|
|
224
|
+
solved[a] = matMul(matTranspose(globals[CSKEL27_PARENTS[a]]), upper);
|
|
225
|
+
solved[b] = matMul(matTranspose(upper), lower);
|
|
226
|
+
solved[c] = matMul(matTranspose(lower), globals[c]);
|
|
227
|
+
return solved;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function limbBlocked(points, chain, boxes) {
|
|
231
|
+
return chain.slice(1).some(j => (FEET.includes(j) && points[j][1] < -EPS) || boxes.some(box => segmentPenetratesBox(points[CSKEL27_PARENTS[j]], points[j], box)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clearLimb(locals, points, offsets, chain, boxes) {
|
|
235
|
+
const end = points[chain[2]], tips = chain.slice(2), globals = globalRotations(locals);
|
|
236
|
+
// Each face contributes an effector coordinate which clears ALL distal
|
|
237
|
+
// joints. Cartesian combinations include edges/corners and multiple boxes.
|
|
238
|
+
const coordinates = end.map((v, k) => {
|
|
239
|
+
const relative = tips.map(j => points[j][k] - end[k]);
|
|
240
|
+
return [v, ...boxes.flatMap(box => [box.min[k] - Math.max(...relative) - 4 * EPS, box.max[k] - Math.min(...relative) + 4 * EPS])];
|
|
241
|
+
});
|
|
242
|
+
const floor = FEET.includes(chain[2]) ? EPS - Math.min(...tips.map(j => points[j][1] - end[1])) : -Infinity;
|
|
243
|
+
coordinates[1] = [...new Set(coordinates[1].map(y => Math.max(y, floor)))];
|
|
244
|
+
const targets = coordinates[0].flatMap(x => coordinates[1].flatMap(y => coordinates[2].map(z => [x, y, z])));
|
|
245
|
+
targets.sort((a, b) => Math.hypot(...sub(a, end)) - Math.hypot(...sub(b, end)));
|
|
246
|
+
let best = null;
|
|
247
|
+
for (const target of targets) for (let i = 0; i < 16; i++) {
|
|
248
|
+
const solved = limbPose(locals, points, globals, offsets, chain, target, i * Math.PI / 8);
|
|
249
|
+
const positions = forwardKinematics(solved, offsets, points[0]);
|
|
250
|
+
if (limbBlocked(positions, chain, boxes)) continue;
|
|
251
|
+
const cost = chain.slice(1).reduce((s, j) => s + dot(sub(positions[j], points[j]), sub(positions[j], points[j])), 0);
|
|
252
|
+
if (!best || cost < best.cost) best = { locals: solved, positions, cost };
|
|
253
|
+
}
|
|
254
|
+
return best;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** cameraOrigin opts into ray + limb correction instead of rigid stance
|
|
258
|
+
* locks. Those locks (including floor lifts) would violate the root's camera
|
|
259
|
+
* ray and reintroduce the glide this path is meant to remove. s(f) is the
|
|
260
|
+
* slope-limited envelope of each frame's minimal torso-clear offset: 0 where
|
|
261
|
+
* no correction is needed (so known A/B ends stay put when collisions are
|
|
262
|
+
* elsewhere), one sign for the clip (either may be chosen; the smaller peak
|
|
263
|
+
* wins). A single clip-level s contradicted those ends. Bound BOTH scalar
|
|
264
|
+
* motion and vector correction as rays turn. Residual limb penetration is
|
|
265
|
+
* fixed by two-bone IK; an impossible torso placement or limb solve fails
|
|
266
|
+
* explicitly. */
|
|
267
|
+
function fitRayContacts(motion, boxes, cameraOrigin, rayMaxOffsetM, rayMaxStepM) {
|
|
268
|
+
if (!Array.isArray(cameraOrigin) || cameraOrigin.length !== 3 || !cameraOrigin.every(Number.isFinite)) throw new Error("cameraOrigin must be a finite world [x,y,z]");
|
|
269
|
+
if (!(Number.isFinite(rayMaxOffsetM) && rayMaxOffsetM >= 0 && Number.isFinite(rayMaxStepM) && rayMaxStepM > 0)) throw new Error("rayMaxOffsetM must be nonnegative and rayMaxStepM positive");
|
|
270
|
+
const out = cloneMotion(motion), n = motion.frames;
|
|
271
|
+
// G5 independently filters joints and rotations; its posed joints may
|
|
272
|
+
// already disagree with FK. Solve against the actual playback skeleton.
|
|
273
|
+
const offsets = motion.boneScale ? bodyOffsets(motion.boneScale) : deriveBoneOffsets(jointsAt(motion, 0), localsAt(motion, 0));
|
|
274
|
+
const locals = Array.from({ length: n }, (_, f) => localsAt(motion, f));
|
|
275
|
+
const points = locals.map((r, f) => forwardKinematics(r, offsets, vec(motion.rootPos, f * 3)));
|
|
276
|
+
const rays = points.map(p => {
|
|
277
|
+
const v = sub(p[0], cameraOrigin);
|
|
278
|
+
if (Math.hypot(...v) < EPS) throw new Error("scene ray: root coincides with cameraOrigin");
|
|
279
|
+
return unit(v);
|
|
280
|
+
});
|
|
281
|
+
let inputFkResidualM = 0;
|
|
282
|
+
for (let f = 0; f < n; f++) {
|
|
283
|
+
const input = jointsAt(motion, f);
|
|
284
|
+
for (let j = 0; j < 27; j++) inputFkResidualM = Math.max(inputFkResidualM, Math.hypot(...sub(points[f][j], input[j])));
|
|
285
|
+
}
|
|
286
|
+
// Do not translate through the camera, even if a large limit is supplied.
|
|
287
|
+
const caps = points.map(p => Math.min(rayMaxOffsetM, Math.hypot(...sub(p[0], cameraOrigin)) - EPS));
|
|
288
|
+
const turns = rays.map((r, f) => (f ? Math.hypot(...sub(r, rays[f - 1])) : 0));
|
|
289
|
+
const intervals = points.map((p, f) => torsoRayIntervals(p, rays[f], boxes));
|
|
290
|
+
const peak = x => Math.max(...x.map(Math.abs)), total = x => x.reduce((a, v) => a + Math.abs(v), 0);
|
|
291
|
+
const options = [1, -1].map(sign => signedRayOffsets(intervals, sign, caps, turns, rayMaxStepM)).filter(Boolean);
|
|
292
|
+
options.sort((a, b) => peak(a) - peak(b) || total(a) - total(b));
|
|
293
|
+
if (!options.length) throw new Error(`scene ray: no torso-clear ray offset within ${rayMaxOffsetM.toFixed(3)} m and ${rayMaxStepM.toFixed(3)} m/frame; ray limits/scene conflict`);
|
|
294
|
+
const s = options[0], limbFixFrames = [];
|
|
295
|
+
let sceneOverrides = 0, limbFixes = 0, sceneMaxStepM = 0, maxRootStepM = 0, rayMaxStep = 0;
|
|
296
|
+
for (let f = 0; f < n; f++) {
|
|
297
|
+
const delta = scale(rays[f], s[f]);
|
|
298
|
+
out.rootPos.set(add(points[f][0], delta), f * 3);
|
|
299
|
+
let positions = points[f].map(p => add(p, delta)), rotations = locals[f], fixed = false;
|
|
300
|
+
for (const chain of LIMBS) {
|
|
301
|
+
if (!limbBlocked(positions, chain, boxes)) continue;
|
|
302
|
+
const solved = clearLimb(rotations, positions, offsets, chain, boxes);
|
|
303
|
+
if (!solved) throw new Error(`scene ray: no collision-free limb IK at frame ${f}, joint ${chain[0]}; root was not lifted`);
|
|
304
|
+
rotations = solved.locals; positions = solved.positions; fixed = true; limbFixes++;
|
|
305
|
+
}
|
|
306
|
+
if (fixed) limbFixFrames.push(f);
|
|
307
|
+
if (fixed || Math.abs(s[f]) > EPS) sceneOverrides++;
|
|
308
|
+
for (let j = 0; j < 27; j++) out.rotMats.set(rotations[j].flat(), (f * 27 + j) * 9);
|
|
309
|
+
// Recompute from the stored Float32 channels, not unquantized IK points.
|
|
310
|
+
positions = forwardKinematics(localsAt(out, f), offsets, vec(out.rootPos, f * 3));
|
|
311
|
+
if (penetrates(positions, boxes)) throw new Error(`scene ray: residual penetration after FK at frame ${f}`);
|
|
312
|
+
for (let j = 0; j < 27; j++) out.posedJoints.set(positions[j], (f * 27 + j) * 3);
|
|
313
|
+
if (f) {
|
|
314
|
+
sceneMaxStepM = Math.max(sceneMaxStepM, Math.hypot(...sub(delta, scale(rays[f - 1], s[f - 1]))));
|
|
315
|
+
rayMaxStep = Math.max(rayMaxStep, Math.abs(s[f] - s[f - 1]));
|
|
316
|
+
maxRootStepM = Math.max(maxRootStepM, Math.hypot(...sub(vec(out.rootPos, f * 3), vec(out.rootPos, (f - 1) * 3))));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { motion: out, diagnostics: {
|
|
320
|
+
runs: 0, lockedFrames: 0, support: Array(n).fill(-1), maxLockResidualM: 0,
|
|
321
|
+
sceneOverrides, maxCorrectionM: peak(s), sceneMaxStepM, maxRootStepM,
|
|
322
|
+
rayOffset: { minM: Math.min(...s), maxM: Math.max(...s), meanM: s.reduce((a, v) => a + v, 0) / n, maxAbsM: peak(s), maxStepM: rayMaxStep, framesNonZero: s.filter(v => Math.abs(v) > EPS).length },
|
|
323
|
+
rayMaxOffsetM, rayMaxStepM, limbFixFrames, limbFixes, inputFkResidualM,
|
|
324
|
+
geometry: "cskel27 joint/bone segments; no skin radius", sceneSolver: "camera-ray",
|
|
325
|
+
solver: "slope-limited per-frame torso-clear camera-ray offset + two-bone limb IK; no rigid contact locks",
|
|
326
|
+
} };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function contactTarget(point, joint, boxes, height) {
|
|
330
|
+
if (FEET.includes(joint)) {
|
|
331
|
+
let y = 0;
|
|
332
|
+
for (const box of boxes) if (point[0] >= box.min[0] && point[0] <= box.max[0] && point[2] >= box.min[2] && point[2] <= box.max[2] && Math.abs(point[1] - box.max[1]) <= height) y = Math.max(y, box.max[1]);
|
|
333
|
+
return Math.abs(point[1] - y) <= height ? [point[0], y + EPS, point[2]] : null;
|
|
334
|
+
}
|
|
335
|
+
// A hand may support on any known box face; never invent a hand contact
|
|
336
|
+
// from a low percentile of its own trajectory when there is no surface.
|
|
337
|
+
for (const box of boxes) {
|
|
338
|
+
const clamped = point.map((x, k) => Math.max(box.min[k], Math.min(box.max[k], x)));
|
|
339
|
+
const target = add(clamped, boxPenetration(clamped, box));
|
|
340
|
+
if (Math.hypot(...sub(point, target)) <= height) return target;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** F5 uses F4 motion + known floor/boxes ONLY. Low, slow runs nominate one
|
|
346
|
+
* support point (feet first, then a box-contact hand); rigid root offsets
|
|
347
|
+
* lock it without changing pose or bone lengths. Other simultaneous contacts
|
|
348
|
+
* are not an IK solve. Scene feasibility overrides pinning/locks; diagnostics
|
|
349
|
+
* expose every such override instead of claiming incompatible constraints.
|
|
350
|
+
*/
|
|
351
|
+
export function fitContacts(motion, { boxes = [], contactHeight = 0.04, maxSpeed = 0.18, minStanceSeconds = 0.08, sceneSmoothSeconds = null, cameraOrigin = null, rayMaxOffsetM = 0.75, rayMaxStepM = 0.02 } = {}) {
|
|
352
|
+
validateBoxes(boxes);
|
|
353
|
+
if (![contactHeight, maxSpeed, minStanceSeconds].every(x => Number.isFinite(x) && x > 0)) throw new Error("contact thresholds must be positive");
|
|
354
|
+
if (cameraOrigin !== null) return fitRayContacts(motion, boxes, cameraOrigin, rayMaxOffsetM, rayMaxStepM);
|
|
355
|
+
const out = cloneMotion(motion), n = motion.frames, sites = [...FEET, ...HANDS];
|
|
356
|
+
const minimum = Math.max(2, Math.ceil(minStanceSeconds * motion.fps));
|
|
357
|
+
const targets = new Map(), candidates = new Map();
|
|
358
|
+
for (const j of sites) {
|
|
359
|
+
const track = Array.from({ length: n }, (_, f) => vec(motion.posedJoints, (f * 27 + j) * 3));
|
|
360
|
+
const target = track.map(p => contactTarget(p, j, boxes, contactHeight));
|
|
361
|
+
const eligible = target.map((p, f) => {
|
|
362
|
+
const lo = Math.max(0, f - 1), hi = Math.min(n - 1, f + 1);
|
|
363
|
+
const speed = Math.max(...[lo, hi].map(t => Math.hypot(...sub(track[f], track[t])) * motion.fps));
|
|
364
|
+
return !!p && speed <= maxSpeed;
|
|
365
|
+
});
|
|
366
|
+
const good = new Uint8Array(n);
|
|
367
|
+
for (let f = 0; f < n;) {
|
|
368
|
+
if (!eligible[f]) { f++; continue; }
|
|
369
|
+
const start = f; while (f < n && eligible[f]) f++;
|
|
370
|
+
if (f - start >= minimum) good.fill(1, start, f);
|
|
371
|
+
}
|
|
372
|
+
targets.set(j, target); candidates.set(j, good);
|
|
373
|
+
}
|
|
374
|
+
const support = new Int8Array(n).fill(-1);
|
|
375
|
+
for (let f = 0; f < n; f++) {
|
|
376
|
+
const previous = f ? support[f - 1] : -1;
|
|
377
|
+
support[f] = previous >= 0 && candidates.get(previous)[f] ? previous : (sites.find(j => candidates.get(j)[f]) ?? -1);
|
|
378
|
+
}
|
|
379
|
+
const offsets = new Array(n).fill(null), anchors = new Array(n).fill(null);
|
|
380
|
+
let carry = [0, 0, 0], runs = 0, lockedFrames = 0;
|
|
381
|
+
for (let f = 0; f < n;) {
|
|
382
|
+
const j = support[f];
|
|
383
|
+
if (j < 0) { f++; continue; }
|
|
384
|
+
const first = f, target = targets.get(j)[f];
|
|
385
|
+
// Keep accumulated horizontal travel across stance changes, but the
|
|
386
|
+
// surface owns contact height. A hand's authored face owns all axes.
|
|
387
|
+
const anchor = FEET.includes(j) ? [target[0] + carry[0], target[1], target[2] + carry[2]] : target;
|
|
388
|
+
while (f < n && support[f] === j) {
|
|
389
|
+
offsets[f] = sub(anchor, vec(motion.posedJoints, (f * 27 + j) * 3)); anchors[f] = anchor; f++;
|
|
390
|
+
}
|
|
391
|
+
carry = offsets[f - 1]; runs++; lockedFrames += f - first;
|
|
392
|
+
}
|
|
393
|
+
// Smooth offset bridges through flight/swing; no reset snap on release.
|
|
394
|
+
for (let f = 0; f < n;) {
|
|
395
|
+
if (offsets[f]) { f++; continue; }
|
|
396
|
+
const start = f; while (f < n && !offsets[f]) f++;
|
|
397
|
+
const a = start ? offsets[start - 1] : [0, 0, 0], b = f < n ? offsets[f] : a;
|
|
398
|
+
for (let t = start; t < f; t++) { const w = smoothstep((t - start + 1) / (f - start + 1)); offsets[t] = a.map((v, k) => v + (b[k] - v) * w); }
|
|
399
|
+
}
|
|
400
|
+
let sceneOverrides = 0, maxLockResidualM = 0, maxCorrectionM = 0, sceneMaxStepM = 0;
|
|
401
|
+
// sceneSmoothSeconds selects the time-smooth scene solver (its ramp half
|
|
402
|
+
// width); null keeps the original per-frame projection (F5 and earlier).
|
|
403
|
+
let sceneDeltas = null;
|
|
404
|
+
if (sceneSmoothSeconds !== null) {
|
|
405
|
+
if (!(Number.isFinite(sceneSmoothSeconds) && sceneSmoothSeconds > 0)) throw new Error("sceneSmoothSeconds must be a positive duration");
|
|
406
|
+
for (let f = 0; f < n; f++) shiftFrame(out, f, offsets[f]);
|
|
407
|
+
sceneDeltas = continuousSceneDeltas(out, boxes, Math.max(1, Math.round(sceneSmoothSeconds * motion.fps)));
|
|
408
|
+
}
|
|
409
|
+
for (let f = 0; f < n; f++) {
|
|
410
|
+
if (!sceneDeltas) shiftFrame(out, f, offsets[f]);
|
|
411
|
+
const { delta } = sceneDeltas ? { delta: sceneDeltas[f] } : resolveScene(jointsAt(out, f), boxes);
|
|
412
|
+
if (f && sceneDeltas) sceneMaxStepM = Math.max(sceneMaxStepM, Math.hypot(...sub(sceneDeltas[f], sceneDeltas[f - 1])));
|
|
413
|
+
if (Math.hypot(...delta) > 1e-6) sceneOverrides++;
|
|
414
|
+
shiftFrame(out, f, delta);
|
|
415
|
+
maxCorrectionM = Math.max(maxCorrectionM, Math.hypot(...add(offsets[f], delta)));
|
|
416
|
+
if (support[f] >= 0) maxLockResidualM = Math.max(maxLockResidualM, Math.hypot(...sub(vec(out.posedJoints, (f * 27 + support[f]) * 3), anchors[f])));
|
|
417
|
+
}
|
|
418
|
+
return { motion: out, diagnostics: { runs, lockedFrames, sceneOverrides, maxLockResidualM, maxCorrectionM, support: Array.from(support), geometry: "cskel27 joint/bone segments; no skin radius", solver: "one rigid support; scene constraints override locks and A/B pins", sceneSolver: sceneDeltas ? "continuous" : "per-frame", ...(sceneDeltas ? { sceneSmoothSeconds, sceneMaxStepM } : {}) } };
|
|
419
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Foot lock: keep a planted foot fixed in the world during stance by solving
|
|
2
|
+
* analytic two-bone IK on the leg (UpLeg-Leg-Foot). Root-only corrections
|
|
3
|
+
* (depth, scene shifts) translate the body while local rotations stay put, so
|
|
4
|
+
* the planted ankle slides with the pelvis; this pass moves only the leg's
|
|
5
|
+
* local rotations back under the anchor. Root, spine and arms are untouched;
|
|
6
|
+
* the foot keeps its input world orientation. */
|
|
7
|
+
import { forwardKinematics, globalRotations, matMul, matTranspose } from "../../../src/ardy/convert.js";
|
|
8
|
+
import { bodyOffsets, cloneMotion, localsAt, smoothstep, vec } from "./motion.mjs";
|
|
9
|
+
|
|
10
|
+
const LEGS = [
|
|
11
|
+
{ foot: "left", hip: 23, knee: 24, ankle: 25, toe: 26 },
|
|
12
|
+
{ foot: "right", hip: 19, knee: 20, ankle: 21, toe: 22 },
|
|
13
|
+
];
|
|
14
|
+
const HIPS = 0;
|
|
15
|
+
|
|
16
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
17
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
18
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
19
|
+
const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
20
|
+
const norm = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
21
|
+
const unit = (a) => scale(a, 1 / norm(a));
|
|
22
|
+
const perp = (a, axis) => sub(a, scale(axis, dot(a, axis)));
|
|
23
|
+
const mulVec = (m, v) => m.map((row) => dot(row, v));
|
|
24
|
+
const columns = (x, y, z) => [[x[0], y[0], z[0]], [x[1], y[1], z[1]], [x[2], y[2], z[2]]];
|
|
25
|
+
const angleDeg = (a, b) => {
|
|
26
|
+
const r = matMul(matTranspose(a), b);
|
|
27
|
+
return (Math.acos(Math.max(-1, Math.min(1, (r[0][0] + r[1][1] + r[2][2] - 1) / 2))) * 180) / Math.PI;
|
|
28
|
+
};
|
|
29
|
+
const median = (values) => {
|
|
30
|
+
const s = [...values].sort((a, b) => a - b), m = (s.length - 1) / 2;
|
|
31
|
+
return (s[Math.floor(m)] + s[Math.ceil(m)]) / 2;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Minimal rotation taking unit vector a onto unit vector b (Rodrigues). */
|
|
35
|
+
function rotationBetween(a, b) {
|
|
36
|
+
const axis = cross(a, b), s = norm(axis), c = dot(a, b);
|
|
37
|
+
if (s < 1e-12) return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
38
|
+
const [x, y, z] = scale(axis, 1 / s), t = 1 - c;
|
|
39
|
+
return [
|
|
40
|
+
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
|
|
41
|
+
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
|
|
42
|
+
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Knee pole: the knee's offset from the hip-ankle line; for a straight leg the
|
|
47
|
+
* toe direction (knees bend over the toes); last resort any perpendicular. */
|
|
48
|
+
function poleDirection(H, K, A, toe, axis) {
|
|
49
|
+
for (const candidate of [perp(sub(K, H), axis), perp(sub(toe, A), axis), perp([0, 0, 1], axis), perp([1, 0, 0], axis)]) {
|
|
50
|
+
if (norm(candidate) > 1e-3) return unit(candidate);
|
|
51
|
+
}
|
|
52
|
+
throw new Error("footlock: no knee pole direction");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Soft IK reach: near full extension the knee angle is hypersensitive to the
|
|
56
|
+
* ankle distance (a straightening knee sweeps centimetres per millimetre), so
|
|
57
|
+
* the reach saturates exponentially inside the last `soft` metres instead of
|
|
58
|
+
* snapping straight. Applied relative to the input distance d0 so a zero
|
|
59
|
+
* request reproduces the input pose exactly. */
|
|
60
|
+
function softReach(d0, distance, length, soft) {
|
|
61
|
+
if (!(soft > 0)) return Math.min(distance, length);
|
|
62
|
+
const knee = length - soft, cap = length - 1e-9 * length;
|
|
63
|
+
const squash = (x) => (x <= knee ? x : knee + soft * (1 - Math.exp(-(x - knee) / soft)));
|
|
64
|
+
const unsquash = (y) => (y <= knee ? y : knee - soft * Math.log(1 - (Math.min(y, cap) - knee) / soft));
|
|
65
|
+
return squash(unsquash(d0) + distance - d0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Two-bone IK on one leg in place: updates locals of hip/knee/ankle so the
|
|
69
|
+
* ankle reaches T (clamped to the reachable shell), the knee stays in the
|
|
70
|
+
* plane through hip, target and the original pole, and the foot keeps its
|
|
71
|
+
* world orientation. */
|
|
72
|
+
function solveLeg(locals, G, P, leg, T, softness) {
|
|
73
|
+
const H = P[leg.hip], K = P[leg.knee], A = P[leg.ankle];
|
|
74
|
+
const a = norm(sub(K, H)), b = norm(sub(A, K));
|
|
75
|
+
const axis0 = unit(sub(A, H)), pole = poleDirection(H, K, A, P[leg.toe], axis0);
|
|
76
|
+
const toTarget = sub(T, H), distance = norm(toTarget);
|
|
77
|
+
const axis1 = distance > 1e-9 ? scale(toTarget, 1 / distance) : axis0;
|
|
78
|
+
const reach = Math.max(softReach(norm(sub(A, H)), distance, a + b, softness * (a + b)), Math.abs(a - b) + 1e-6);
|
|
79
|
+
const cosA = Math.max(-1, Math.min(1, (a * a + reach * reach - b * b) / (2 * a * reach)));
|
|
80
|
+
const sinA = Math.sqrt(1 - cosA * cosA);
|
|
81
|
+
const pole1 = norm(perp(pole, axis1)) > 1e-6 ? unit(perp(pole, axis1)) : poleDirection(H, K, A, P[leg.toe], axis1);
|
|
82
|
+
const K1 = [0, 1, 2].map((k) => H[k] + axis1[k] * a * cosA + pole1[k] * a * sinA);
|
|
83
|
+
const A1 = [0, 1, 2].map((k) => H[k] + axis1[k] * reach);
|
|
84
|
+
// Thigh: align the (thigh, bend-normal) frame; knee: hinge about the normal.
|
|
85
|
+
const u0 = unit(sub(K, H)), n0 = unit(perp(cross(axis0, pole), u0));
|
|
86
|
+
const u1 = unit(sub(K1, H)), n1 = unit(perp(cross(axis1, pole1), u1));
|
|
87
|
+
const thigh = matMul(columns(u1, n1, cross(u1, n1)), matTranspose(columns(u0, n0, cross(u0, n0))));
|
|
88
|
+
const knee = rotationBetween(unit(mulVec(thigh, sub(A, K))), unit(sub(A1, K1)));
|
|
89
|
+
const gUp = matMul(thigh, G[leg.hip]), gLeg = matMul(knee, matMul(thigh, G[leg.knee]));
|
|
90
|
+
locals[leg.hip] = matMul(matTranspose(G[HIPS]), gUp);
|
|
91
|
+
locals[leg.knee] = matMul(matTranspose(gUp), gLeg);
|
|
92
|
+
locals[leg.ankle] = matMul(matTranspose(gLeg), G[leg.ankle]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Highest support (floor or box top under the foot) the foot may stand on. */
|
|
96
|
+
function supportUnder(points, lowY, floorY, boxes, maxHeight) {
|
|
97
|
+
let best = floorY;
|
|
98
|
+
for (const box of boxes) {
|
|
99
|
+
const inside = points.some((p) => p[0] >= box.min[0] && p[0] <= box.max[0] && p[2] >= box.min[2] && p[2] <= box.max[2]);
|
|
100
|
+
if (inside && box.max[1] > best && lowY >= box.max[1] - maxHeight) best = box.max[1];
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Per frame: { goal } (absolute ankle target) inside a stance, { offset } in
|
|
106
|
+
* the ramps around it, null where the leg is left alone. The ramps fade the
|
|
107
|
+
* stance-edge correction (not a pull toward the anchor), so the swing keeps its
|
|
108
|
+
* shape; each ramp lasts at least blendFrames and long enough that the added
|
|
109
|
+
* per-frame displacement stays near maxBlendStepM. Two stances whose ramps meet
|
|
110
|
+
* share the gap: the correction interpolates from one edge to the next. */
|
|
111
|
+
function planCorrections(motion, leg, stances, blendFrames, maxBlendStepM) {
|
|
112
|
+
const T = motion.frames, plan = new Array(T).fill(null);
|
|
113
|
+
const ankle = (f) => vec(motion.posedJoints, (f * 27 + leg.ankle) * 3);
|
|
114
|
+
const toeY = (f) => motion.posedJoints[(f * 27 + leg.toe) * 3 + 1];
|
|
115
|
+
// A ramp carries a stance-edge correction onto frames whose foot pitch
|
|
116
|
+
// differs; never let it push the foot's lowest point under the support
|
|
117
|
+
// (or deeper than the input already was). The foot keeps its world
|
|
118
|
+
// orientation, so the toe moves with the ankle.
|
|
119
|
+
const fade = (f, offset, level) => {
|
|
120
|
+
const lowest = Math.min(ankle(f)[1], toeY(f));
|
|
121
|
+
return { offset: [offset[0], Math.max(offset[1], -Math.max(0, lowest - level)), offset[2]] };
|
|
122
|
+
};
|
|
123
|
+
const goal = (stance, f) => [stance.anchor[0], stance.height(f), stance.anchor[2]];
|
|
124
|
+
// smoothstep's peak slope is 1.5 / (n + 1) per frame over an n-frame ramp.
|
|
125
|
+
const ramp = (d) => Math.max(blendFrames, Math.ceil((1.5 * norm(d)) / maxBlendStepM) - 1);
|
|
126
|
+
const edges = stances.map((s) => {
|
|
127
|
+
const first = sub(goal(s, s.start), ankle(s.start)), last = sub(goal(s, s.end), ankle(s.end));
|
|
128
|
+
return { first, last, before: ramp(first), after: ramp(last) };
|
|
129
|
+
});
|
|
130
|
+
stances.forEach((s, i) => {
|
|
131
|
+
for (let f = s.start; f <= s.end; f++) plan[f] = { goal: goal(s, f) };
|
|
132
|
+
const next = stances[i + 1], gap = next ? next.start - s.end : Infinity;
|
|
133
|
+
if (next && gap - 1 <= edges[i].after + edges[i + 1].before) {
|
|
134
|
+
for (let f = s.end + 1; f < next.start; f++) {
|
|
135
|
+
const t = smoothstep((f - s.end) / gap);
|
|
136
|
+
plan[f] = fade(f, edges[i].last.map((v, k) => v + (edges[i + 1].first[k] - v) * t), Math.max(s.level, next.level));
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const n = edges[i].after;
|
|
140
|
+
for (let f = s.end + 1; f <= Math.min(T - 1, s.end + n); f++) plan[f] = fade(f, scale(edges[i].last, smoothstep(1 - (f - s.end) / (n + 1))), s.level);
|
|
141
|
+
if (next) {
|
|
142
|
+
const m = edges[i + 1].before;
|
|
143
|
+
for (let f = next.start - m; f < next.start; f++) plan[f] = fade(f, scale(edges[i + 1].first, smoothstep(1 - (next.start - f) / (m + 1))), next.level);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (i === 0) {
|
|
147
|
+
const m = edges[0].before;
|
|
148
|
+
for (let f = Math.max(0, s.start - m); f < s.start; f++) plan[f] = fade(f, scale(edges[0].first, smoothstep(1 - (s.start - f) / (m + 1))), s.level);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return plan;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function detectStances(motion, leg, { floorY, boxes, maxHeight, maxSpeed, minStanceFrames, minSwingFrames }) {
|
|
155
|
+
const T = motion.frames, at = (f, j) => vec(motion.posedJoints, (f * 27 + j) * 3);
|
|
156
|
+
const ankle = Array.from({ length: T }, (_, f) => at(f, leg.ankle));
|
|
157
|
+
const toe = Array.from({ length: T }, (_, f) => at(f, leg.toe));
|
|
158
|
+
// The slower one-sided step: a stance's first/last frame has one slow side.
|
|
159
|
+
const step = (f, g) => Math.hypot(ankle[g][0] - ankle[f][0], ankle[g][2] - ankle[f][2]) * motion.fps;
|
|
160
|
+
const speed = ankle.map((_, f) => Math.min(f > 0 ? step(f - 1, f) : Infinity, f + 1 < T ? step(f, f + 1) : Infinity, T === 1 ? 0 : Infinity));
|
|
161
|
+
const support = ankle.map((p, f) => {
|
|
162
|
+
const lowY = Math.min(p[1], toe[f][1]), s = supportUnder([p, toe[f]], lowY, floorY, boxes, maxHeight);
|
|
163
|
+
return lowY - s <= maxHeight && speed[f] <= maxSpeed ? s : null;
|
|
164
|
+
});
|
|
165
|
+
const runs = [];
|
|
166
|
+
for (let f = 0; f < T;) {
|
|
167
|
+
if (support[f] === null) { f += 1; continue; }
|
|
168
|
+
let end = f;
|
|
169
|
+
while (end + 1 < T && support[end + 1] !== null && Math.abs(support[end + 1] - support[f]) < 0.01) end += 1;
|
|
170
|
+
const last = runs.at(-1);
|
|
171
|
+
// A foot cannot lift and replant in fewer than minSwingFrames: one fast
|
|
172
|
+
// frame inside a sliding stance is not a step.
|
|
173
|
+
if (last && f - last.end - 1 < minSwingFrames && Math.abs(last.level - support[f]) < 0.01) last.end = end;
|
|
174
|
+
else runs.push({ start: f, end, level: support[f] });
|
|
175
|
+
f = end + 1;
|
|
176
|
+
}
|
|
177
|
+
const stances = [];
|
|
178
|
+
for (const { start, end, level } of runs) {
|
|
179
|
+
if (end - start + 1 >= minStanceFrames) {
|
|
180
|
+
const frames = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
181
|
+
const mean = (k) => frames.reduce((sum, i) => sum + ankle[i][k], 0) / frames.length;
|
|
182
|
+
// Flat-foot ankle height above the toe; heel-off raises the ankle above it.
|
|
183
|
+
const flat = median(frames.map((i) => ankle[i][1] - toe[i][1]));
|
|
184
|
+
const height = (i) => level + Math.max(flat, ankle[i][1] - toe[i][1]);
|
|
185
|
+
stances.push({ foot: leg.foot, start, end, anchor: [mean(0), level + flat, mean(2)], level, height });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return stances;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stance = ankle/toe within maxHeight of the floor or a box top AND ankle
|
|
193
|
+
* horizontal speed <= maxSpeed for >= minStanceFrames. maxSpeed defaults to
|
|
194
|
+
* 0.4 m/s, not 0.25: the fitted takes this pass repairs slide 25-30 cm/s on
|
|
195
|
+
* truly planted frames, so 0.25 misses about half of each stance (measured on
|
|
196
|
+
* the 11 gt+cube items: mean skate 9.7 cm/s at 0.25 vs 8.2 at 0.4).
|
|
197
|
+
* @param {object} motion cskel27 motion ({ frames, fps, rotMats, rootPos, posedJoints, boneScale })
|
|
198
|
+
* @returns {{ motion, diagnostics: { stances, maxResidualM, maxAdjustDeg } }}
|
|
199
|
+
*/
|
|
200
|
+
export function lockFeet(motion, { floorY = 0, boxes = [], maxHeight = 0.06, maxSpeed = 0.4, minStanceFrames = 3, blendFrames = 3,
|
|
201
|
+
minSwingFrames = 4, maxBlendStepM = 0.01, softness = 0.03, offsets = bodyOffsets(motion.boneScale) } = {}) {
|
|
202
|
+
if (!(blendFrames >= 0) || !(minStanceFrames >= 1) || !(minSwingFrames >= 0) || !(maxBlendStepM > 0) || !(softness >= 0)) {
|
|
203
|
+
throw new Error("lockFeet: blendFrames >= 0, minStanceFrames >= 1, minSwingFrames >= 0, maxBlendStepM > 0, softness >= 0 required");
|
|
204
|
+
}
|
|
205
|
+
const out = cloneMotion(motion), T = motion.frames;
|
|
206
|
+
const detect = { floorY, boxes, maxHeight, maxSpeed, minStanceFrames, minSwingFrames };
|
|
207
|
+
const plans = LEGS.map((leg) => {
|
|
208
|
+
const stances = detectStances(motion, leg, detect);
|
|
209
|
+
return { leg, stances, plan: planCorrections(motion, leg, stances, blendFrames, maxBlendStepM) };
|
|
210
|
+
});
|
|
211
|
+
let maxResidualM = 0, maxAdjustDeg = 0;
|
|
212
|
+
for (let f = 0; f < T; f++) {
|
|
213
|
+
const active = plans.filter(({ plan }) => plan[f] !== null);
|
|
214
|
+
if (!active.length) continue;
|
|
215
|
+
const original = localsAt(motion, f), locals = original.map((m) => m.map((row) => row.slice()));
|
|
216
|
+
const root = vec(motion.rootPos, f * 3), G = globalRotations(locals), P = forwardKinematics(locals, offsets, root);
|
|
217
|
+
const targets = active.map(({ leg, plan }) => {
|
|
218
|
+
const target = plan[f].goal ?? P[leg.ankle].map((v, k) => v + plan[f].offset[k]);
|
|
219
|
+
solveLeg(locals, G, P, leg, target, softness);
|
|
220
|
+
return { leg, target };
|
|
221
|
+
});
|
|
222
|
+
const solved = forwardKinematics(locals, offsets, root);
|
|
223
|
+
for (const { leg, target } of targets) {
|
|
224
|
+
maxResidualM = Math.max(maxResidualM, norm(sub(solved[leg.ankle], target)));
|
|
225
|
+
for (const j of [leg.hip, leg.knee, leg.ankle]) {
|
|
226
|
+
maxAdjustDeg = Math.max(maxAdjustDeg, angleDeg(original[j], locals[j]));
|
|
227
|
+
out.rotMats.set(locals[j].flat(), (f * 27 + j) * 9);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (let j = 0; j < 27; j++) out.posedJoints.set(solved[j], (f * 27 + j) * 3);
|
|
231
|
+
}
|
|
232
|
+
const stances = plans.flatMap(({ stances }) => stances.map(({ foot, start, end, anchor }) => ({ foot, start, end, anchor })));
|
|
233
|
+
return { motion: out, diagnostics: { stances, maxResidualM, maxAdjustDeg } };
|
|
234
|
+
}
|