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,101 @@
|
|
|
1
|
+
import { createIkState, ikBakeKeyframe, ikTouch } from "./ik.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-drag bookkeeping for manual IK drags over a loaded motion.
|
|
5
|
+
*
|
|
6
|
+
* A drag keys ONLY the parts it wrote, as DELTA keys over the raw clip pose at
|
|
7
|
+
* the drag frame. Keying every part ever tracked (the old behaviour) planted an
|
|
8
|
+
* absolute key for untouched parts too, which spiked them off the clip on the
|
|
9
|
+
* drag frame (bind-translation reset + absolute slerp on the ramp).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The record for the drag in progress. IkHandles has no drag-start callback,
|
|
13
|
+
* so a record opens on the first solve and closes on drag end; a record left
|
|
14
|
+
* on another frame (a click-sized drag that never ended) is stale and resets. */
|
|
15
|
+
export function ikDragRecord(current, frame) {
|
|
16
|
+
return current && current.frame === frame ? current : { frame, ids: new Set() };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Mark `id` as written by this drag and touch it into the live layer. */
|
|
20
|
+
export function ikDragTouch(ikState, record, id) {
|
|
21
|
+
ikTouch(ikState, id);
|
|
22
|
+
record?.ids.add(id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run `write` and return the chain ids whose bone locals it changed — for
|
|
26
|
+
* solvers like applyBodyContact that only report "something moved". */
|
|
27
|
+
export function chainsChangedBy(chains, write) {
|
|
28
|
+
const before = new Map();
|
|
29
|
+
for (const [id, chain] of chains) {
|
|
30
|
+
before.set(id, chain.bones.map((bone) => [bone.quaternion.clone(), bone.position.clone()]));
|
|
31
|
+
}
|
|
32
|
+
write();
|
|
33
|
+
const changed = [];
|
|
34
|
+
for (const [id, chain] of chains) {
|
|
35
|
+
const saved = before.get(id);
|
|
36
|
+
if (chain.bones.some((bone, index) => !bone.quaternion.equals(saved[index][0]) || !bone.position.equals(saved[index][1]))) changed.push(id);
|
|
37
|
+
}
|
|
38
|
+
return changed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read the RAW clip locals of `ids` at the current frame without disturbing the
|
|
43
|
+
* live pose: snapshot every node under `rig`, let `applyRaw(rig)` pose the clip
|
|
44
|
+
* alone (no correction layer), read chain b0..b2 quaternions and FK joint
|
|
45
|
+
* quaternion + position, then put every node back.
|
|
46
|
+
*/
|
|
47
|
+
export function captureRawBase(rig, chains, fkJoints, ids, applyRaw) {
|
|
48
|
+
const saved = [];
|
|
49
|
+
rig.traverse((node) => saved.push([node, node.position.clone(), node.quaternion.clone(), node.scale.clone()]));
|
|
50
|
+
const baseQuats = new Map();
|
|
51
|
+
const basePositions = new Map();
|
|
52
|
+
try {
|
|
53
|
+
applyRaw(rig);
|
|
54
|
+
for (const id of ids) {
|
|
55
|
+
const chain = chains?.get(id);
|
|
56
|
+
const joint = fkJoints?.get(id);
|
|
57
|
+
if (chain) baseQuats.set(id, chain.bones.map((bone) => bone.quaternion.clone()));
|
|
58
|
+
else if (joint) {
|
|
59
|
+
baseQuats.set(id, [joint.bone.quaternion.clone()]);
|
|
60
|
+
basePositions.set(id, joint.bone.position.clone());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
for (const [node, position, quaternion, scale] of saved) {
|
|
65
|
+
node.position.copy(position);
|
|
66
|
+
node.quaternion.copy(quaternion);
|
|
67
|
+
node.scale.copy(scale);
|
|
68
|
+
}
|
|
69
|
+
rig.updateMatrixWorld(true);
|
|
70
|
+
}
|
|
71
|
+
return { baseQuats, basePositions };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bake the key entry (Map trackId → key) a drag leaves at `frame`, for exactly
|
|
76
|
+
* `ids`. With `applyRaw` (a motion is loaded) every entry is a delta key over
|
|
77
|
+
* the raw clip: chains carry baseQ; FK joints carry baseQ + basePos, and a
|
|
78
|
+
* joint whose rotation matches the clip is stored translation-only, the same
|
|
79
|
+
* shape ikBakeKeyframe gives based hips keys. Without `applyRaw` the entry is
|
|
80
|
+
* the plain absolute key. Returns null when nothing was keyed.
|
|
81
|
+
*/
|
|
82
|
+
export function bakeIkDragKey(chains, fkJoints, frame, ids, applyRaw = null) {
|
|
83
|
+
const list = [...ids];
|
|
84
|
+
if (!list.length) return null;
|
|
85
|
+
const rig = chains?.values().next().value?.rig;
|
|
86
|
+
const base = applyRaw && rig ? captureRawBase(rig, chains, fkJoints, list, applyRaw) : null;
|
|
87
|
+
const scratch = createIkState();
|
|
88
|
+
ikBakeKeyframe(chains, scratch, frame, fkJoints, list, null, base?.baseQuats ?? null);
|
|
89
|
+
const entry = scratch.keys.get(frame);
|
|
90
|
+
if (!entry || !base) return entry ?? null;
|
|
91
|
+
for (const [id, key] of entry) {
|
|
92
|
+
if (chains.has(id) || !fkJoints?.has(id)) continue;
|
|
93
|
+
const baseQ = base.baseQuats.get(id)?.[0];
|
|
94
|
+
const basePos = base.basePositions.get(id);
|
|
95
|
+
if (!baseQ || !basePos || !key.p) continue;
|
|
96
|
+
key.basePos = basePos;
|
|
97
|
+
if (key.q?.[0] && key.q[0].angleTo(baseQ) > 1e-7) key.baseQ = [baseQ];
|
|
98
|
+
else key.q = null;
|
|
99
|
+
}
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One IK track key in the JSON form character.setIkKey takes
|
|
5
|
+
* (src/studio-actions.js ikTrackKey) and back: quaternions as {x,y,z,w},
|
|
6
|
+
* positions as {x,y,z}. Optional fields are written only when the key has
|
|
7
|
+
* them, so a key without a stored `blend` stays one without it.
|
|
8
|
+
*/
|
|
9
|
+
export function ikTrackKeyJson(key) {
|
|
10
|
+
const quaternion = (q) => ({ x: q.x, y: q.y, z: q.z, w: q.w });
|
|
11
|
+
const vector = (p) => ({ x: p.x, y: p.y, z: p.z });
|
|
12
|
+
return {
|
|
13
|
+
...(key.q ? { q: key.q.map(quaternion) } : {}),
|
|
14
|
+
...(key.p ? { p: vector(key.p) } : {}),
|
|
15
|
+
...(key.baseQ ? { baseQ: key.baseQ.map(quaternion) } : {}),
|
|
16
|
+
...(key.basePos ? { basePos: vector(key.basePos) } : {}),
|
|
17
|
+
...(key.chainP ? { chainP: key.chainP.map(vector) } : {}),
|
|
18
|
+
...(key.keepTranslations ? { keepTranslations: true } : {}),
|
|
19
|
+
...(key.blend != null ? { blend: key.blend } : {}),
|
|
20
|
+
...(key.pin != null ? { pin: key.pin } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A baked key entry (Map trackId → key) as character.setIkKey's `tracks`. */
|
|
25
|
+
export function ikKeyJson(entry) {
|
|
26
|
+
return Object.fromEntries([...entry].map(([track, key]) => [track, ikTrackKeyJson(key)]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The IK state's key for one track of character.setIkKey's `tracks`. */
|
|
30
|
+
export function ikTrackKeyFromJson(key) {
|
|
31
|
+
const quaternion = (q) => new THREE.Quaternion(q.x, q.y, q.z, q.w).normalize();
|
|
32
|
+
const vector = (p) => new THREE.Vector3(p.x, p.y, p.z);
|
|
33
|
+
return {
|
|
34
|
+
q: key.q?.map(quaternion) ?? null,
|
|
35
|
+
p: key.p ? vector(key.p) : null,
|
|
36
|
+
...(key.baseQ ? { baseQ: key.baseQ.map(quaternion) } : {}),
|
|
37
|
+
...(key.basePos ? { basePos: vector(key.basePos) } : {}),
|
|
38
|
+
...(key.chainP ? { chainP: key.chainP.map(vector) } : {}),
|
|
39
|
+
...(key.keepTranslations ? { keepTranslations: true } : {}),
|
|
40
|
+
...(key.blend != null ? { blend: key.blend } : {}),
|
|
41
|
+
...(key.pin != null ? { pin: key.pin } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/ardy/ik.js
CHANGED
|
@@ -32,6 +32,29 @@ const CONTACT_RADIUS_FALLBACK = 0.01;
|
|
|
32
32
|
const CONTACT_HEIGHT_MAX = 0.25;
|
|
33
33
|
const contactRadiusCache = new WeakMap();
|
|
34
34
|
const contactHeightCache = new WeakMap();
|
|
35
|
+
// Normalised once: the per-vertex loops below used to re-run the
|
|
36
|
+
// normalizeBoneName regex 16 times per vertex, which was most of the cost of
|
|
37
|
+
// measuring a rig (#413).
|
|
38
|
+
const CONTACT_TARGETS = CONTACT_JOINTS.map((name) => [name, normalizeBoneName(`mixamorig${name}`)]);
|
|
39
|
+
const contactJointFor = (normalized) => {
|
|
40
|
+
if (!normalized) return undefined;
|
|
41
|
+
for (const [name, target] of CONTACT_TARGETS) {
|
|
42
|
+
if (normalized === target || normalized.endsWith(target) || target.endsWith(normalized)) return name;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Copy one rig's measured contact radii/heights onto another rig that shares
|
|
48
|
+
* its bind pose (a skeleton clone). The measurement reads bind-pose geometry
|
|
49
|
+
* only, so the numbers are identical and re-scanning every skinned vertex per
|
|
50
|
+
* clone is pure waste: studioBounds clones the rig per query (#413). */
|
|
51
|
+
export function shareContactMeasurements(from, to) {
|
|
52
|
+
if (!from || !to || from === to) return;
|
|
53
|
+
const radii = contactRadiusCache.get(from);
|
|
54
|
+
const heights = contactHeightCache.get(from);
|
|
55
|
+
if (radii && !contactRadiusCache.has(to)) contactRadiusCache.set(to, radii);
|
|
56
|
+
if (heights && !contactHeightCache.has(to)) contactHeightCache.set(to, heights);
|
|
57
|
+
}
|
|
35
58
|
|
|
36
59
|
function pointSegmentDistance(point, start, end) {
|
|
37
60
|
const segment = end.clone().sub(start);
|
|
@@ -259,11 +282,7 @@ export function measureContactRadii(rig) {
|
|
|
259
282
|
}
|
|
260
283
|
}
|
|
261
284
|
if (dominantWeight <= 0.4) continue;
|
|
262
|
-
const
|
|
263
|
-
const name = CONTACT_JOINTS.find((candidate) => {
|
|
264
|
-
const target = normalizeBoneName(`mixamorig${candidate}`);
|
|
265
|
-
return normalized === target || normalized.endsWith(target) || target.endsWith(normalized);
|
|
266
|
-
});
|
|
285
|
+
const name = contactJointFor(names[dominant]);
|
|
267
286
|
const segment = segments.get(name);
|
|
268
287
|
if (!segment) continue;
|
|
269
288
|
if (bindPose) bindVertexPosition(mesh, index, vertex);
|
|
@@ -327,10 +346,7 @@ export function measureContactHeights(rig) {
|
|
|
327
346
|
if (weight > dominantWeight) { dominantWeight = weight; dominant = indices.getComponent(index, slot); }
|
|
328
347
|
}
|
|
329
348
|
if (dominantWeight <= 0.4 || !names[dominant]) continue;
|
|
330
|
-
const name =
|
|
331
|
-
const target = normalizeBoneName(`mixamorig${candidate}`);
|
|
332
|
-
return names[dominant] === target || names[dominant].endsWith(target) || target.endsWith(names[dominant]);
|
|
333
|
-
});
|
|
349
|
+
const name = contactJointFor(names[dominant]);
|
|
334
350
|
const point = points.get(name);
|
|
335
351
|
if (!point) continue;
|
|
336
352
|
if (bindPose) bindVertexPosition(mesh, index, vertex);
|
|
@@ -513,16 +529,21 @@ export function resolveIkRig(rig) {
|
|
|
513
529
|
* root→effector reach with a soft clamp: past the cap the effector approaches
|
|
514
530
|
* it asymptotically and never exceeds it, so a locked foot cannot straighten
|
|
515
531
|
* the leg past the extension the source pose actually had.
|
|
532
|
+
*
|
|
533
|
+
* `exactHinge` makes the one-step claim hold for a BENT chain too: without it
|
|
534
|
+
* the continuity hinge keeps an along-line component and a solve from a bent
|
|
535
|
+
* pose lands short of a reachable target, converging only over repeated calls.
|
|
516
536
|
*/
|
|
517
|
-
export function solveIk(chain, targetWorld, { maxExtension = null, softening = 0.01 } = {}) {
|
|
518
|
-
|
|
519
|
-
|
|
537
|
+
export function solveIk(chain, targetWorld, { maxExtension = null, softening = 0.01, exactHinge = false } = {}) {
|
|
538
|
+
// Playback owns translations. Measure this frame's lengths rather than
|
|
539
|
+
// resetting to bind, which turns a small target move into length compensation.
|
|
540
|
+
const { bones, poleLocal, rig } = chain;
|
|
520
541
|
const [b0, b1, b2] = bones;
|
|
521
542
|
const p0 = b0.getWorldPosition(new THREE.Vector3());
|
|
522
543
|
const p1cur = b1.getWorldPosition(new THREE.Vector3());
|
|
523
544
|
const t = targetWorld.clone();
|
|
524
|
-
const l0 =
|
|
525
|
-
const l1 =
|
|
545
|
+
const l0 = p0.distanceTo(p1cur);
|
|
546
|
+
const l1 = p1cur.distanceTo(b2.getWorldPosition(new THREE.Vector3()));
|
|
526
547
|
let d = p0.distanceTo(t);
|
|
527
548
|
|
|
528
549
|
const dir = t.clone().sub(p0);
|
|
@@ -577,8 +598,18 @@ export function solveIk(chain, targetWorld, { maxExtension = null, softening = 0
|
|
|
577
598
|
const isBent = seg0.dot(seg1) < 0.999;
|
|
578
599
|
let bend;
|
|
579
600
|
if (isBent) {
|
|
580
|
-
//
|
|
601
|
+
// Legacy continuity: reuse the elbow's current offset from the NEW line.
|
|
602
|
+
// fix-collisions and its fixtures are tuned on this result, but when the
|
|
603
|
+
// line rotates past a near-straight elbow the offset changes sign and
|
|
604
|
+
// the joint reverse-bends.
|
|
581
605
|
bend = p1cur.clone().sub(linePoint);
|
|
606
|
+
// exactHinge keeps the chain's CURRENT hinge axis a = norm(seg0 x seg1)
|
|
607
|
+
// and bends in the plane it spans with dir: bend = dir x a, which is
|
|
608
|
+
// perpendicular to dir, so p1 stays on the l0 sphere. The solved
|
|
609
|
+
// thigh' x shin' = off*d*(a - dir(a.dir)), whose dot with a is
|
|
610
|
+
// off*d*(1 - (a.dir)^2) >= 0, so the bend side cannot flip. When the
|
|
611
|
+
// target lies along a, bend degenerates and the pole fallback runs.
|
|
612
|
+
if (exactHinge) bend = dir.clone().cross(seg0.clone().cross(seg1).normalize());
|
|
582
613
|
}
|
|
583
614
|
if (!bend || bend.lengthSq() < 1e-8) {
|
|
584
615
|
// Straight chain — no side to continue; use the pole hint.
|
|
@@ -684,8 +715,7 @@ export function solveHipsTranslateToFloor(joint, worldDelta, startLocalPos, floo
|
|
|
684
715
|
}
|
|
685
716
|
|
|
686
717
|
export function solveMidJoint(chain, midTargetWorld) {
|
|
687
|
-
|
|
688
|
-
const { bones, lengths } = chain;
|
|
718
|
+
const { bones } = chain;
|
|
689
719
|
const [b0, b1, b2] = bones;
|
|
690
720
|
const p0 = b0.getWorldPosition(new THREE.Vector3());
|
|
691
721
|
const p1cur = b1.getWorldPosition(new THREE.Vector3());
|
|
@@ -694,12 +724,12 @@ export function solveMidJoint(chain, midTargetWorld) {
|
|
|
694
724
|
const dir = midTargetWorld.clone().sub(p0);
|
|
695
725
|
if (dir.lengthSq() < 1e-12) dir.copy(p1cur).sub(p0);
|
|
696
726
|
dir.normalize();
|
|
697
|
-
const p1 = p0.clone().addScaledVector(dir,
|
|
727
|
+
const p1 = p0.clone().addScaledVector(dir, p0.distanceTo(p1cur));
|
|
698
728
|
// Forearm keeps its current world direction; the wrist/ankle follows.
|
|
699
729
|
const foreDir = p2cur.clone().sub(p1cur);
|
|
700
730
|
if (foreDir.lengthSq() < 1e-12) foreDir.copy(dir);
|
|
701
731
|
foreDir.normalize();
|
|
702
|
-
const p2 = p1.clone().addScaledVector(foreDir,
|
|
732
|
+
const p2 = p1.clone().addScaledVector(foreDir, p1cur.distanceTo(p2cur));
|
|
703
733
|
aimChain(bones, [p0, p1, p2]);
|
|
704
734
|
return p1; // the clamped position the caller should snap the handle to
|
|
705
735
|
}
|
|
@@ -839,7 +869,9 @@ export function ikRestore(rig, snapshot, fkJoints) {
|
|
|
839
869
|
* (b0, b1, b2) for chains, the single bone for FK joints — and the hips' LOCAL
|
|
840
870
|
* POSITION when the body root was moved. Entries are uniform
|
|
841
871
|
* { q: [...quats], p: localPos | null, basePos? }. Local values are
|
|
842
|
-
* character-position independent, so keys need no re-anchoring ever.
|
|
872
|
+
* character-position independent, so keys need no re-anchoring ever. New chain
|
|
873
|
+
* keys keep clip translations. Absolute keys also capture chainP; delta keys
|
|
874
|
+
* need it only when an earlier layer changed translations at this frame.
|
|
843
875
|
*
|
|
844
876
|
* `onlyIds` (Set or array) bakes EXACTLY those ids instead of the whole tracked
|
|
845
877
|
* set, and touches them into `tracked` on the way. Without it a fix to a leg at
|
|
@@ -906,6 +938,16 @@ export function ikBakeKeyframe(rig, ikState, frame, fkJoints, onlyIds = null, ba
|
|
|
906
938
|
if (!q && !p) continue;
|
|
907
939
|
if (!entry) ikState.keys.set(frame, (entry = new Map()));
|
|
908
940
|
const key = { q, p };
|
|
941
|
+
if (chain) {
|
|
942
|
+
const clip = chainClipPositions.get(chain);
|
|
943
|
+
// A rotation-only correction must not freeze the keyed frame's bone
|
|
944
|
+
// lengths onto its neighbours. Preserve explicit translations only
|
|
945
|
+
// for absolute authoring or an edit over a translation-changing key.
|
|
946
|
+
if (!baseQ || (clip?.frame === frame && chain.bones.some((bone, i) => !bone.position.equals(clip.positions[i])))) {
|
|
947
|
+
key.chainP = chain.bones.map((bone) => bone.position.clone());
|
|
948
|
+
}
|
|
949
|
+
key.keepTranslations = true;
|
|
950
|
+
}
|
|
909
951
|
if (basePos) key.basePos = basePos;
|
|
910
952
|
if (baseQ && q) key.baseQ = baseQ;
|
|
911
953
|
entry.set(id, key);
|
|
@@ -1020,25 +1062,32 @@ export function ikKeyframes(ikState) {
|
|
|
1020
1062
|
* `blendWindow` > 0 turns the layer into a LOCAL correction (used when a
|
|
1021
1063
|
* generated motion plays underneath): the correction holds full strength
|
|
1022
1064
|
* across each ISLAND of keys and eases to zero over that many frames outside
|
|
1023
|
-
* it
|
|
1065
|
+
* it, or over an edge key's own `blend` when it stores one (see
|
|
1066
|
+
* correctionWeight), blending against whatever pose is already on the
|
|
1024
1067
|
* bone (the motion). With the default 0 the keys hold forever (constant
|
|
1025
1068
|
* extrapolation), the no-motion authoring behaviour.
|
|
1026
1069
|
*/
|
|
1027
1070
|
const deltaPos = new THREE.Vector3();
|
|
1028
1071
|
const identityQuat = new THREE.Quaternion();
|
|
1029
1072
|
const easedDelta = new THREE.Quaternion();
|
|
1073
|
+
// Raw locals under the layer, used when a drag re-keys a legacy bind-space
|
|
1074
|
+
// correction. Solvers no longer translate, but that existing layer might.
|
|
1075
|
+
const chainClipPositions = new WeakMap();
|
|
1030
1076
|
|
|
1031
1077
|
export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
1032
1078
|
if (!rig) return;
|
|
1079
|
+
for (const chain of rig.values()) {
|
|
1080
|
+
chainClipPositions.set(chain, { frame, positions: chain.bones.map((bone) => bone.position.clone()) });
|
|
1081
|
+
}
|
|
1033
1082
|
for (const id of ikState.tracked) {
|
|
1034
|
-
const sampled = sampleChain(ikState.keys, id, frame);
|
|
1035
|
-
if (!sampled) continue;
|
|
1036
1083
|
const chain = rig.get(id);
|
|
1084
|
+
const sampled = sampleChain(ikState.keys, id, frame, chain);
|
|
1085
|
+
if (!sampled) continue;
|
|
1037
1086
|
const joint = fkJoints?.get(id);
|
|
1038
1087
|
const w = blendWindow > 0 ? correctionWeight(ikState.keys, id, frame, blendWindow) : 1;
|
|
1039
1088
|
if (w <= 0) continue;
|
|
1040
1089
|
if (chain) {
|
|
1041
|
-
if (sampled.chainP) chain.bones.forEach((bone, i) => { if (sampled.chainP[i]) bone.position.lerp(sampled.chainP[i], w); });
|
|
1090
|
+
if (sampled.chainP) chain.bones.forEach((bone, i) => { if (sampled.chainP[i]) bone.position.lerp(sampled.chainP[i], w * (sampled.chainPWeight ?? 1)); });
|
|
1042
1091
|
// DELTA blend for chain rotations, the mirror of basePos for the hips
|
|
1043
1092
|
// and for exactly the same reason. Easing a bone toward the key's
|
|
1044
1093
|
// ABSOLUTE rotation makes the smear proportional to how different the
|
|
@@ -1052,10 +1101,9 @@ export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
|
1052
1101
|
// which is the only thing the key ever claimed.
|
|
1053
1102
|
const deltas = blendWindow > 0 ? sampled.deltaQ : null;
|
|
1054
1103
|
if (deltas) {
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
|
|
1058
|
-
if (w >= 1 && !sampled.keepTranslations) restoreChainPositions(chain, 1);
|
|
1104
|
+
// Ease the translations with the rotations: switching to bind only
|
|
1105
|
+
// at w = 1 tears the otherwise continuous correction at island edges.
|
|
1106
|
+
if (!sampled.keepTranslations) restoreChainPositions(chain, w);
|
|
1059
1107
|
for (let index = 0; index < chain.bones.length && index < deltas.length; index += 1) {
|
|
1060
1108
|
if (!deltas[index]) continue;
|
|
1061
1109
|
easedDelta.copy(deltas[index]);
|
|
@@ -1112,30 +1160,15 @@ export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
|
1112
1160
|
}
|
|
1113
1161
|
|
|
1114
1162
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
* rotation
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
* authored pose is defined AT bind translations (solveIk resets them before it
|
|
1125
|
-
* aims, and the chain's segment lengths were measured there), so any partial
|
|
1126
|
-
* value is a pose nobody authored. Ramping it by `weight` therefore injects up
|
|
1127
|
-
* to 16 mm of segment-length change into frames whose only claim on the layer is
|
|
1128
|
-
* a fractional rotation delta — several times the correction itself, and enough
|
|
1129
|
-
* on its own to blow a 1 cm budget.
|
|
1130
|
-
*
|
|
1131
|
-
* So: a frame the correction fully owns (a key, or an unkeyed frame inside a key
|
|
1132
|
-
* island) gets bind translations, because it must reproduce the authored pose. A
|
|
1133
|
-
* frame on the ease ramp keeps the CLIP's translations untouched and receives
|
|
1134
|
-
* only the eased rotation delta. The cost is a step of up to that ~19 mm at each
|
|
1135
|
-
* island edge instead of a ramp spread over the whole window; the benefit is
|
|
1136
|
-
* that every frame outside an island deviates from the clip by the correction
|
|
1137
|
-
* and nothing else. Removing the step outright means solving in clip-translation
|
|
1138
|
-
* space, which is a change to solveIk's contract and to every drag path with it.
|
|
1163
|
+
* Legacy keys were solved at bind translations and must still replay there.
|
|
1164
|
+
* New correction keys keep clip translations; explicit poses carry chainP.
|
|
1165
|
+
* On a legacy key's ease ramp, interpolate the ENTIRE local transform:
|
|
1166
|
+
* p = clipP + (bindP - clipP) * weight,
|
|
1167
|
+
* alongside the eased rotation delta. This keeps both endpoints exact and is
|
|
1168
|
+
* continuous at full weight; withholding the translation until then made a
|
|
1169
|
+
* 1 cm correction jump 50.5 mm on a chain whose locals were 2 cm off bind.
|
|
1170
|
+
* Partial weights deliberately interpolate segment lengths too, just as the
|
|
1171
|
+
* absolute-key path does. They are transition poses, not fresh IK solves.
|
|
1139
1172
|
*/
|
|
1140
1173
|
function restoreChainPositions(chain, weight = 1) {
|
|
1141
1174
|
if (!chain?.bindPositions) return;
|
|
@@ -1150,39 +1183,48 @@ function restoreChainPositions(chain, weight = 1) {
|
|
|
1150
1183
|
|
|
1151
1184
|
/**
|
|
1152
1185
|
* A track's key ISLANDS: runs of keys close enough together to be one
|
|
1153
|
-
* correction, as [firstFrame, lastFrame]
|
|
1154
|
-
* consecutive keys belong to the same island while the gap between
|
|
1155
|
-
* wider than
|
|
1186
|
+
* correction, as [firstFrame, lastFrame, firstBlend, lastBlend] in ascending
|
|
1187
|
+
* order. Two consecutive keys belong to the same island while the gap between
|
|
1188
|
+
* them is no wider than the larger of their two blend ranges; a wider gap
|
|
1189
|
+
* starts a new island.
|
|
1156
1190
|
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1191
|
+
* A key's blend range is its own `blend` (frames, stored by a Pose fix drag
|
|
1192
|
+
* made with a wider or narrower correction range) or, for every key without
|
|
1193
|
+
* one, `blendWindow`. The range is the right ruler because it is exactly how
|
|
1194
|
+
* far a correction is allowed to reach: keys closer together than that overlap
|
|
1195
|
+
* anyway, and keys further apart are, by the layer's own definition, separate
|
|
1196
|
+
* local fixes with clip in between. The larger of the pair decides because the
|
|
1197
|
+
* wider key already reaches its neighbour.
|
|
1161
1198
|
*/
|
|
1162
1199
|
function trackIslands(keys, trackId, blendWindow) {
|
|
1163
1200
|
const frames = [];
|
|
1164
|
-
for (const [f, entry] of keys)
|
|
1201
|
+
for (const [f, entry] of keys) {
|
|
1202
|
+
const key = entry.get(trackId);
|
|
1203
|
+
if (key) frames.push([f, key.blend ?? blendWindow]);
|
|
1204
|
+
}
|
|
1165
1205
|
if (!frames.length) return [];
|
|
1166
|
-
frames.sort((a, b) => a - b);
|
|
1206
|
+
frames.sort((a, b) => a[0] - b[0]);
|
|
1167
1207
|
const islands = [];
|
|
1168
|
-
let first = frames[0];
|
|
1169
|
-
let prev = frames[0];
|
|
1208
|
+
let [first, firstBlend] = frames[0];
|
|
1209
|
+
let [prev, prevBlend] = frames[0];
|
|
1170
1210
|
for (let index = 1; index < frames.length; index += 1) {
|
|
1171
|
-
const f = frames[index];
|
|
1172
|
-
if (f - prev >
|
|
1173
|
-
islands.push([first, prev]);
|
|
1211
|
+
const [f, blend] = frames[index];
|
|
1212
|
+
if (f - prev > Math.max(prevBlend, blend)) {
|
|
1213
|
+
islands.push([first, prev, firstBlend, prevBlend]);
|
|
1174
1214
|
first = f;
|
|
1215
|
+
firstBlend = blend;
|
|
1175
1216
|
}
|
|
1176
1217
|
prev = f;
|
|
1218
|
+
prevBlend = blend;
|
|
1177
1219
|
}
|
|
1178
|
-
islands.push([first, prev]);
|
|
1220
|
+
islands.push([first, prev, firstBlend, prevBlend]);
|
|
1179
1221
|
return islands;
|
|
1180
1222
|
}
|
|
1181
1223
|
|
|
1182
1224
|
/**
|
|
1183
1225
|
* Correction strength at `frame` for one track: 1 on a key and anywhere inside
|
|
1184
|
-
* a key ISLAND, easing 1 → 0
|
|
1185
|
-
*
|
|
1226
|
+
* a key ISLAND, easing 1 → 0 outward from each island edge across that edge
|
|
1227
|
+
* key's blend range (its `blend`, else `blendWindow`), 0 beyond every island.
|
|
1186
1228
|
*
|
|
1187
1229
|
* This used to be "1 everywhere between the track's first and last key", which
|
|
1188
1230
|
* quietly made every sparse correction a whole-clip rewrite: two collision
|
|
@@ -1197,13 +1239,12 @@ function trackIslands(keys, trackId, blendWindow) {
|
|
|
1197
1239
|
* boundary pins, a fully baked pose — is one island, so full-strength playback
|
|
1198
1240
|
* of authored ranges is unchanged too.
|
|
1199
1241
|
*/
|
|
1200
|
-
function correctionWeight(keys, trackId, frame, blendWindow) {
|
|
1242
|
+
export function correctionWeight(keys, trackId, frame, blendWindow) {
|
|
1201
1243
|
const islands = trackIslands(keys, trackId, blendWindow);
|
|
1202
1244
|
let best = 0;
|
|
1203
|
-
for (const [first, last] of islands) {
|
|
1245
|
+
for (const [first, last, firstBlend, lastBlend] of islands) {
|
|
1204
1246
|
if (frame >= first && frame <= last) return 1;
|
|
1205
|
-
const
|
|
1206
|
-
const weight = 1 - distance / blendWindow;
|
|
1247
|
+
const weight = frame < first ? 1 - (first - frame) / firstBlend : 1 - (frame - last) / lastBlend;
|
|
1207
1248
|
if (weight > best) best = weight;
|
|
1208
1249
|
}
|
|
1209
1250
|
return Math.max(0, best);
|
|
@@ -1231,7 +1272,7 @@ function withDeltas(entry) {
|
|
|
1231
1272
|
return entry?.baseQ ? { ...entry, deltaQ: keyDeltas(entry) } : entry;
|
|
1232
1273
|
}
|
|
1233
1274
|
|
|
1234
|
-
function sampleChain(keys, trackId, frame) {
|
|
1275
|
+
function sampleChain(keys, trackId, frame, chain) {
|
|
1235
1276
|
let prevFrame = null;
|
|
1236
1277
|
let nextFrame = null;
|
|
1237
1278
|
for (const f of keys.keys()) {
|
|
@@ -1247,9 +1288,14 @@ function sampleChain(keys, trackId, frame) {
|
|
|
1247
1288
|
const t = (frame - prevFrame) / (nextFrame - prevFrame);
|
|
1248
1289
|
const deltasA = keyDeltas(a);
|
|
1249
1290
|
const deltasB = keyDeltas(b);
|
|
1291
|
+
// Missing chainP means raw clip for a new key, bind for a legacy key.
|
|
1292
|
+
// A mixed pair must ease between those policies, not switch at an endpoint.
|
|
1293
|
+
const positionsA = a.chainP ?? (a.keepTranslations ? null : chain?.bindPositions);
|
|
1294
|
+
const positionsB = b.chainP ?? (b.keepTranslations ? null : chain?.bindPositions);
|
|
1250
1295
|
return {
|
|
1251
|
-
keepTranslations: a.keepTranslations === true && b.keepTranslations === true,
|
|
1252
|
-
chainP:
|
|
1296
|
+
keepTranslations: Boolean(chain) || (a.keepTranslations === true && b.keepTranslations === true),
|
|
1297
|
+
chainP: positionsA && positionsB ? positionsA.map((p, i) => p.clone().lerp(positionsB[i], t)) : (positionsA ?? positionsB),
|
|
1298
|
+
chainPWeight: positionsA && positionsB ? 1 : (positionsA ? 1 - t : t),
|
|
1253
1299
|
// Between two based keys the DELTAS interpolate, not the absolute
|
|
1254
1300
|
// rotations and their bases separately: each key's delta is a statement
|
|
1255
1301
|
// about its own frame's clip pose, and interpolating those statements is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
@layer legacy {
|
|
1
2
|
.physics-review { border-top: 1px solid var(--border, #40444c); margin-top: 12px; padding-top: 12px; }
|
|
2
3
|
.physics-review h4 { margin: 0 0 8px; font-size: 13px; }
|
|
3
4
|
.physics-review h4 small { font-weight: 400; opacity: .7; }
|
|
@@ -17,3 +18,4 @@
|
|
|
17
18
|
.physics-review th:first-child, .physics-review td:first-child { text-align: left; }
|
|
18
19
|
.physics-list { max-height: 170px; overflow: auto; }
|
|
19
20
|
.physics-warning { padding: 8px; border: 1px solid #cc924e; border-radius: 5px; font-size: 11px; margin-top: 8px; }
|
|
21
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useState, useSyncExternalStore } from "react";
|
|
1
|
+
import React, { useEffect, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import { SUPPORT_SITES } from "./physics-review.js";
|
|
3
|
+
import { useMotionCommands } from '../domains/motion.js';
|
|
3
4
|
import "./physics-panel.css";
|
|
4
5
|
|
|
5
6
|
// Only the progress panel subscribes. Publishing a percentage must not
|
|
@@ -12,6 +13,7 @@ export function createPhysicsProgress() {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function PhysicsPanel({ ko, disabled, running, progress: progressStore, preview, show, options, frame, frames, onOptions, onRun, onShow, onApply, onCancel, onFrame }) {
|
|
16
|
+
const { run } = useMotionCommands();
|
|
15
17
|
const progress = useSyncExternalStore(progressStore.subscribe, progressStore.getSnapshot);
|
|
16
18
|
const [site, setSite] = useState("leftFoot");
|
|
17
19
|
const [start, setStart] = useState(0), [end, setEnd] = useState(0), [mode, setMode] = useState("plant");
|
|
@@ -24,8 +26,18 @@ export function PhysicsPanel({ ko, disabled, running, progress: progressStore, p
|
|
|
24
26
|
};
|
|
25
27
|
const cm = (x) => `${(x * 100).toFixed(2)} cm`;
|
|
26
28
|
const warning = (r) => ({ unsupported: ko("Unexplained body support", "몸을 지탱하는 접촉 미확인"), floor: ko("Floor penetration", "바닥 관통"), float: ko("Contact floats", "접지점 뜸"), slide: ko("Contact drift", "접지점 밀림"), "knee-pop": ko("Knee speed jump", "무릎 튐"), "knee-acceleration": ko("Knee acceleration increased", "무릎 가속도 증가"), "root-acceleration": ko("Root acceleration increased", "골반 가속도 증가"), replay: ko("Playback mismatch", "재생 불일치") }[r] ?? r);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!preview) return undefined;
|
|
31
|
+
const onKeyDown = (event) => {
|
|
32
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
33
|
+
if (event.key === "Escape") { event.preventDefault(); onCancel?.(); }
|
|
34
|
+
if (event.key === "Enter") { event.preventDefault(); onApply?.(); }
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("keydown", onKeyDown);
|
|
37
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
38
|
+
}, [preview, onApply, onCancel]);
|
|
39
|
+
return <section className="physics-review" data-testid="physics-panel" aria-label={ko("Physics cleanup", "물리 정리")}>
|
|
40
|
+
<h4>{ko("Physics cleanup", "물리 정리")} <small>{ko("review before applying", "확인 후 적용")}</small></h4>
|
|
29
41
|
<p className="inspector-hint">{ko("Floor-support hypotheses → force + moment check → pelvis + limbs. Flight is preserved; uncertain support stays flagged. Original stays unchanged until Apply.", "바닥 지지 후보 → 힘·회전 검사 → 골반·팔다리를 보정합니다. 비행은 보존하고, 불명확한 지지는 표시해요. 적용 전까지 원본은 바뀌지 않아요.")}</p>
|
|
30
42
|
<fieldset disabled={disabled || running}>
|
|
31
43
|
<label>{ko("Correction strength", "보정 강도")} <output>{Math.round(options.strength * 100)}%</output>
|
|
@@ -44,7 +56,7 @@ export function PhysicsPanel({ ko, disabled, running, progress: progressStore, p
|
|
|
44
56
|
<button data-testid="physics-protect" className="btn full" onClick={() => set({ protectedFrames: [...new Set([...options.protectedFrames, frame])].sort((a, b) => a - b) })}>{ko(`Protect pose at frame ${frame}`, `${frame}프레임 포즈 보호`)}</button>
|
|
45
57
|
{options.protectedFrames.map((f) => <div key={f} className="physics-row"><button className="btn" onClick={() => onFrame(f)}>{ko("Protected", "보호")} F{f}</button><button className="btn" aria-label={ko("Unprotect pose", "포즈 보호 해제")} onClick={() => set({ protectedFrames: options.protectedFrames.filter((p) => p !== f) })}>×</button></div>)}
|
|
46
58
|
</details>
|
|
47
|
-
<button data-testid="physics-analyse" className="btn full primary" onClick={
|
|
59
|
+
<button data-testid="physics-analyse" className="btn full primary" onClick={() => run('motion.autoPhysics', { ...options, apply: false })}>{running ? ko(`Analysing ${progress}%`, `분석 중 ${progress}%`) : ko("Analyse & preview", "분석 · 미리보기")}</button>
|
|
48
60
|
</fieldset>
|
|
49
61
|
{running && <progress max="100" value={progress} aria-label={ko("Analysis progress", "분석 진행률")} />}
|
|
50
62
|
{preview && <div data-testid="physics-results">
|
|
@@ -70,7 +82,7 @@ export function PhysicsPanel({ ko, disabled, running, progress: progressStore, p
|
|
|
70
82
|
{!!preview.skippedAir?.length && <p>{ko(`${preview.skippedAir.length} flight spans skipped`, `공중 ${preview.skippedAir.length}구간 보류`)}</p>}
|
|
71
83
|
{!!preview.contacts.rejected.length && <button className="btn full" onClick={() => onFrame(preview.contacts.rejected[0].start)}>{ko(`${preview.contacts.rejected.length} moving support intervals rejected`, `움직이는 접지 ${preview.contacts.rejected.length}구간 제외`)}</button>}
|
|
72
84
|
</div>
|
|
73
|
-
<div className="physics-actions"><button data-testid="physics-cancel" className="btn" onClick={onCancel}>{ko("Cancel", "취소")}</button><button data-testid="physics-apply" className="btn primary" disabled={!preview.changedFrames.length || preview.strength === 0} onClick={
|
|
85
|
+
<div className="physics-actions"><button data-testid="physics-cancel" className="btn" onClick={onCancel}>{ko("Cancel", "취소")}</button><button data-testid="physics-apply" className="btn primary" disabled={!preview.changedFrames.length || preview.strength === 0} onClick={() => run('motion.applyPhysics')}>{ko("Apply", "적용")}</button></div><small className="pose-keyhint">{ko("Enter applies · Esc cancels", "Enter 적용 · Esc 취소")}</small>
|
|
74
86
|
</div>}
|
|
75
87
|
</section>;
|
|
76
88
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
@layer legacy {
|
|
2
|
+
/* Inspector scale: 4 px spacing, 13 px section headings, shared flat buttons.
|
|
3
|
+
Success matches the existing completed-guide marker; warning/error use theme tokens. */
|
|
4
|
+
.platform-fit {
|
|
5
|
+
--platform-fit-space: 4px;
|
|
6
|
+
--platform-fit-heading-size: 13px;
|
|
7
|
+
--platform-fit-rule: 1px;
|
|
8
|
+
--platform-fit-success: #65c18c;
|
|
9
|
+
min-width: 0;
|
|
10
|
+
border-top: var(--platform-fit-rule) solid var(--line2);
|
|
11
|
+
margin-top: calc(var(--platform-fit-space) * 3);
|
|
12
|
+
padding-top: calc(var(--platform-fit-space) * 3);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.platform-fit.auto-fix-card {
|
|
16
|
+
margin-top: 0;
|
|
17
|
+
padding-top: calc(var(--platform-fit-space) * 3);
|
|
18
|
+
border-top: var(--platform-fit-rule) solid var(--line2);
|
|
19
|
+
}
|
|
20
|
+
.platform-fit h4 {
|
|
21
|
+
margin: 0;
|
|
22
|
+
font-size: var(--platform-fit-heading-size);
|
|
23
|
+
}
|
|
24
|
+
.platform-fit progress { width: 100%; accent-color: var(--accent); }
|
|
25
|
+
.platform-fit-results {
|
|
26
|
+
display: grid;
|
|
27
|
+
gap: var(--platform-fit-space);
|
|
28
|
+
margin: calc(var(--platform-fit-space) * 2) 0 0;
|
|
29
|
+
padding: 0;
|
|
30
|
+
list-style: none;
|
|
31
|
+
max-height: calc(var(--platform-fit-space) * 48);
|
|
32
|
+
overflow: auto;
|
|
33
|
+
}
|
|
34
|
+
.platform-fit .platform-fit-step {
|
|
35
|
+
display: grid;
|
|
36
|
+
grid-template-columns: calc(var(--platform-fit-space) * 2) minmax(0, 1fr);
|
|
37
|
+
align-items: start;
|
|
38
|
+
gap: calc(var(--platform-fit-space) * 2);
|
|
39
|
+
width: 100%;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
height: auto;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: calc(var(--platform-fit-space) * 2);
|
|
44
|
+
text-align: left;
|
|
45
|
+
white-space: normal;
|
|
46
|
+
font-variant-numeric: tabular-nums;
|
|
47
|
+
}
|
|
48
|
+
.platform-fit-step[data-status="ok"] { --platform-fit-status: var(--platform-fit-success); }
|
|
49
|
+
.platform-fit-step[data-status="tooHigh"] { --platform-fit-status: var(--range-pin-warning); }
|
|
50
|
+
.platform-fit-step[data-status="wall"] { --platform-fit-status: var(--danger); }
|
|
51
|
+
.platform-fit-chip {
|
|
52
|
+
width: calc(var(--platform-fit-space) * 2);
|
|
53
|
+
height: calc(var(--platform-fit-space) * 2);
|
|
54
|
+
margin-top: var(--platform-fit-space);
|
|
55
|
+
border-radius: var(--radius);
|
|
56
|
+
background: var(--platform-fit-status);
|
|
57
|
+
}
|
|
58
|
+
.platform-fit-step .inspector-hint {
|
|
59
|
+
display: block;
|
|
60
|
+
margin: var(--platform-fit-space) 0 0;
|
|
61
|
+
font-weight: normal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
}
|