cozyclay 1.10.0 → 2.0.2
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 +193 -0
- package/bin/agent/agent-routes.mjs +82 -72
- 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/codex-auth.mjs +2 -1
- package/bin/cozyclay.mjs +38 -11
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +45 -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-BxBLC6GT.js +4876 -0
- package/dist/assets/app-CJdxvnrl.css +1 -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-hN0j_O-p.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-B5XZwVfe.js +179 -0
- package/dist/assets/workflow-DRdwoDBT.css +1 -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 +2 -2
- package/dist/workflow/index.html +5 -5
- 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 +219 -376
- package/package.json +2 -1
- package/src/App.jsx +1275 -9086
- 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/fix-collisions.js +10 -66
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +97 -67
- 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 +1214 -0
- package/src/ardy/timeline.jsx +152 -41
- package/src/ardy/waypoints.js +3 -3
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/camera-tutorial.jsx +38 -18
- 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 +823 -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-panel.css +386 -0
- package/src/hierarchy-panel.jsx +88 -19
- 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/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 +288 -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/scenes.js +9 -8
- 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 +38 -268
- package/src/studio-agent-context.js +38 -9
- package/src/studio-agent-motion.js +122 -378
- package/src/studio-agent-protocol.js +66 -30
- 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 +456 -1748
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/timeline-fps.js +13 -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 +86 -71
- 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/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/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +60 -3
- 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-Cu1cexZR.css +0 -1
- package/dist/assets/app-Dxdh8kds.js +0 -4861
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
- package/dist/assets/workflow-CJd-PDtV.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
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
|
@@ -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
|
@@ -529,16 +529,21 @@ export function resolveIkRig(rig) {
|
|
|
529
529
|
* root→effector reach with a soft clamp: past the cap the effector approaches
|
|
530
530
|
* it asymptotically and never exceeds it, so a locked foot cannot straighten
|
|
531
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.
|
|
532
536
|
*/
|
|
533
|
-
export function solveIk(chain, targetWorld, { maxExtension = null, softening = 0.01 } = {}) {
|
|
534
|
-
|
|
535
|
-
|
|
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;
|
|
536
541
|
const [b0, b1, b2] = bones;
|
|
537
542
|
const p0 = b0.getWorldPosition(new THREE.Vector3());
|
|
538
543
|
const p1cur = b1.getWorldPosition(new THREE.Vector3());
|
|
539
544
|
const t = targetWorld.clone();
|
|
540
|
-
const l0 =
|
|
541
|
-
const l1 =
|
|
545
|
+
const l0 = p0.distanceTo(p1cur);
|
|
546
|
+
const l1 = p1cur.distanceTo(b2.getWorldPosition(new THREE.Vector3()));
|
|
542
547
|
let d = p0.distanceTo(t);
|
|
543
548
|
|
|
544
549
|
const dir = t.clone().sub(p0);
|
|
@@ -593,8 +598,18 @@ export function solveIk(chain, targetWorld, { maxExtension = null, softening = 0
|
|
|
593
598
|
const isBent = seg0.dot(seg1) < 0.999;
|
|
594
599
|
let bend;
|
|
595
600
|
if (isBent) {
|
|
596
|
-
//
|
|
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.
|
|
597
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());
|
|
598
613
|
}
|
|
599
614
|
if (!bend || bend.lengthSq() < 1e-8) {
|
|
600
615
|
// Straight chain — no side to continue; use the pole hint.
|
|
@@ -700,8 +715,7 @@ export function solveHipsTranslateToFloor(joint, worldDelta, startLocalPos, floo
|
|
|
700
715
|
}
|
|
701
716
|
|
|
702
717
|
export function solveMidJoint(chain, midTargetWorld) {
|
|
703
|
-
|
|
704
|
-
const { bones, lengths } = chain;
|
|
718
|
+
const { bones } = chain;
|
|
705
719
|
const [b0, b1, b2] = bones;
|
|
706
720
|
const p0 = b0.getWorldPosition(new THREE.Vector3());
|
|
707
721
|
const p1cur = b1.getWorldPosition(new THREE.Vector3());
|
|
@@ -710,12 +724,12 @@ export function solveMidJoint(chain, midTargetWorld) {
|
|
|
710
724
|
const dir = midTargetWorld.clone().sub(p0);
|
|
711
725
|
if (dir.lengthSq() < 1e-12) dir.copy(p1cur).sub(p0);
|
|
712
726
|
dir.normalize();
|
|
713
|
-
const p1 = p0.clone().addScaledVector(dir,
|
|
727
|
+
const p1 = p0.clone().addScaledVector(dir, p0.distanceTo(p1cur));
|
|
714
728
|
// Forearm keeps its current world direction; the wrist/ankle follows.
|
|
715
729
|
const foreDir = p2cur.clone().sub(p1cur);
|
|
716
730
|
if (foreDir.lengthSq() < 1e-12) foreDir.copy(dir);
|
|
717
731
|
foreDir.normalize();
|
|
718
|
-
const p2 = p1.clone().addScaledVector(foreDir,
|
|
732
|
+
const p2 = p1.clone().addScaledVector(foreDir, p1cur.distanceTo(p2cur));
|
|
719
733
|
aimChain(bones, [p0, p1, p2]);
|
|
720
734
|
return p1; // the clamped position the caller should snap the handle to
|
|
721
735
|
}
|
|
@@ -855,7 +869,9 @@ export function ikRestore(rig, snapshot, fkJoints) {
|
|
|
855
869
|
* (b0, b1, b2) for chains, the single bone for FK joints — and the hips' LOCAL
|
|
856
870
|
* POSITION when the body root was moved. Entries are uniform
|
|
857
871
|
* { q: [...quats], p: localPos | null, basePos? }. Local values are
|
|
858
|
-
* 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.
|
|
859
875
|
*
|
|
860
876
|
* `onlyIds` (Set or array) bakes EXACTLY those ids instead of the whole tracked
|
|
861
877
|
* set, and touches them into `tracked` on the way. Without it a fix to a leg at
|
|
@@ -922,6 +938,16 @@ export function ikBakeKeyframe(rig, ikState, frame, fkJoints, onlyIds = null, ba
|
|
|
922
938
|
if (!q && !p) continue;
|
|
923
939
|
if (!entry) ikState.keys.set(frame, (entry = new Map()));
|
|
924
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
|
+
}
|
|
925
951
|
if (basePos) key.basePos = basePos;
|
|
926
952
|
if (baseQ && q) key.baseQ = baseQ;
|
|
927
953
|
entry.set(id, key);
|
|
@@ -1036,25 +1062,32 @@ export function ikKeyframes(ikState) {
|
|
|
1036
1062
|
* `blendWindow` > 0 turns the layer into a LOCAL correction (used when a
|
|
1037
1063
|
* generated motion plays underneath): the correction holds full strength
|
|
1038
1064
|
* across each ISLAND of keys and eases to zero over that many frames outside
|
|
1039
|
-
* 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
|
|
1040
1067
|
* bone (the motion). With the default 0 the keys hold forever (constant
|
|
1041
1068
|
* extrapolation), the no-motion authoring behaviour.
|
|
1042
1069
|
*/
|
|
1043
1070
|
const deltaPos = new THREE.Vector3();
|
|
1044
1071
|
const identityQuat = new THREE.Quaternion();
|
|
1045
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();
|
|
1046
1076
|
|
|
1047
1077
|
export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
1048
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
|
+
}
|
|
1049
1082
|
for (const id of ikState.tracked) {
|
|
1050
|
-
const sampled = sampleChain(ikState.keys, id, frame);
|
|
1051
|
-
if (!sampled) continue;
|
|
1052
1083
|
const chain = rig.get(id);
|
|
1084
|
+
const sampled = sampleChain(ikState.keys, id, frame, chain);
|
|
1085
|
+
if (!sampled) continue;
|
|
1053
1086
|
const joint = fkJoints?.get(id);
|
|
1054
1087
|
const w = blendWindow > 0 ? correctionWeight(ikState.keys, id, frame, blendWindow) : 1;
|
|
1055
1088
|
if (w <= 0) continue;
|
|
1056
1089
|
if (chain) {
|
|
1057
|
-
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)); });
|
|
1058
1091
|
// DELTA blend for chain rotations, the mirror of basePos for the hips
|
|
1059
1092
|
// and for exactly the same reason. Easing a bone toward the key's
|
|
1060
1093
|
// ABSOLUTE rotation makes the smear proportional to how different the
|
|
@@ -1068,10 +1101,9 @@ export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
|
1068
1101
|
// which is the only thing the key ever claimed.
|
|
1069
1102
|
const deltas = blendWindow > 0 ? sampled.deltaQ : null;
|
|
1070
1103
|
if (deltas) {
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1074
|
-
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);
|
|
1075
1107
|
for (let index = 0; index < chain.bones.length && index < deltas.length; index += 1) {
|
|
1076
1108
|
if (!deltas[index]) continue;
|
|
1077
1109
|
easedDelta.copy(deltas[index]);
|
|
@@ -1128,30 +1160,15 @@ export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
|
|
|
1128
1160
|
}
|
|
1129
1161
|
|
|
1130
1162
|
/**
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
* rotation
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
* authored pose is defined AT bind translations (solveIk resets them before it
|
|
1141
|
-
* aims, and the chain's segment lengths were measured there), so any partial
|
|
1142
|
-
* value is a pose nobody authored. Ramping it by `weight` therefore injects up
|
|
1143
|
-
* to 16 mm of segment-length change into frames whose only claim on the layer is
|
|
1144
|
-
* a fractional rotation delta — several times the correction itself, and enough
|
|
1145
|
-
* on its own to blow a 1 cm budget.
|
|
1146
|
-
*
|
|
1147
|
-
* So: a frame the correction fully owns (a key, or an unkeyed frame inside a key
|
|
1148
|
-
* island) gets bind translations, because it must reproduce the authored pose. A
|
|
1149
|
-
* frame on the ease ramp keeps the CLIP's translations untouched and receives
|
|
1150
|
-
* only the eased rotation delta. The cost is a step of up to that ~19 mm at each
|
|
1151
|
-
* island edge instead of a ramp spread over the whole window; the benefit is
|
|
1152
|
-
* that every frame outside an island deviates from the clip by the correction
|
|
1153
|
-
* and nothing else. Removing the step outright means solving in clip-translation
|
|
1154
|
-
* 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.
|
|
1155
1172
|
*/
|
|
1156
1173
|
function restoreChainPositions(chain, weight = 1) {
|
|
1157
1174
|
if (!chain?.bindPositions) return;
|
|
@@ -1166,39 +1183,48 @@ function restoreChainPositions(chain, weight = 1) {
|
|
|
1166
1183
|
|
|
1167
1184
|
/**
|
|
1168
1185
|
* A track's key ISLANDS: runs of keys close enough together to be one
|
|
1169
|
-
* correction, as [firstFrame, lastFrame]
|
|
1170
|
-
* consecutive keys belong to the same island while the gap between
|
|
1171
|
-
* 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.
|
|
1172
1190
|
*
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
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.
|
|
1177
1198
|
*/
|
|
1178
1199
|
function trackIslands(keys, trackId, blendWindow) {
|
|
1179
1200
|
const frames = [];
|
|
1180
|
-
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
|
+
}
|
|
1181
1205
|
if (!frames.length) return [];
|
|
1182
|
-
frames.sort((a, b) => a - b);
|
|
1206
|
+
frames.sort((a, b) => a[0] - b[0]);
|
|
1183
1207
|
const islands = [];
|
|
1184
|
-
let first = frames[0];
|
|
1185
|
-
let prev = frames[0];
|
|
1208
|
+
let [first, firstBlend] = frames[0];
|
|
1209
|
+
let [prev, prevBlend] = frames[0];
|
|
1186
1210
|
for (let index = 1; index < frames.length; index += 1) {
|
|
1187
|
-
const f = frames[index];
|
|
1188
|
-
if (f - prev >
|
|
1189
|
-
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]);
|
|
1190
1214
|
first = f;
|
|
1215
|
+
firstBlend = blend;
|
|
1191
1216
|
}
|
|
1192
1217
|
prev = f;
|
|
1218
|
+
prevBlend = blend;
|
|
1193
1219
|
}
|
|
1194
|
-
islands.push([first, prev]);
|
|
1220
|
+
islands.push([first, prev, firstBlend, prevBlend]);
|
|
1195
1221
|
return islands;
|
|
1196
1222
|
}
|
|
1197
1223
|
|
|
1198
1224
|
/**
|
|
1199
1225
|
* Correction strength at `frame` for one track: 1 on a key and anywhere inside
|
|
1200
|
-
* a key ISLAND, easing 1 → 0
|
|
1201
|
-
*
|
|
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.
|
|
1202
1228
|
*
|
|
1203
1229
|
* This used to be "1 everywhere between the track's first and last key", which
|
|
1204
1230
|
* quietly made every sparse correction a whole-clip rewrite: two collision
|
|
@@ -1213,13 +1239,12 @@ function trackIslands(keys, trackId, blendWindow) {
|
|
|
1213
1239
|
* boundary pins, a fully baked pose — is one island, so full-strength playback
|
|
1214
1240
|
* of authored ranges is unchanged too.
|
|
1215
1241
|
*/
|
|
1216
|
-
function correctionWeight(keys, trackId, frame, blendWindow) {
|
|
1242
|
+
export function correctionWeight(keys, trackId, frame, blendWindow) {
|
|
1217
1243
|
const islands = trackIslands(keys, trackId, blendWindow);
|
|
1218
1244
|
let best = 0;
|
|
1219
|
-
for (const [first, last] of islands) {
|
|
1245
|
+
for (const [first, last, firstBlend, lastBlend] of islands) {
|
|
1220
1246
|
if (frame >= first && frame <= last) return 1;
|
|
1221
|
-
const
|
|
1222
|
-
const weight = 1 - distance / blendWindow;
|
|
1247
|
+
const weight = frame < first ? 1 - (first - frame) / firstBlend : 1 - (frame - last) / lastBlend;
|
|
1223
1248
|
if (weight > best) best = weight;
|
|
1224
1249
|
}
|
|
1225
1250
|
return Math.max(0, best);
|
|
@@ -1247,7 +1272,7 @@ function withDeltas(entry) {
|
|
|
1247
1272
|
return entry?.baseQ ? { ...entry, deltaQ: keyDeltas(entry) } : entry;
|
|
1248
1273
|
}
|
|
1249
1274
|
|
|
1250
|
-
function sampleChain(keys, trackId, frame) {
|
|
1275
|
+
function sampleChain(keys, trackId, frame, chain) {
|
|
1251
1276
|
let prevFrame = null;
|
|
1252
1277
|
let nextFrame = null;
|
|
1253
1278
|
for (const f of keys.keys()) {
|
|
@@ -1263,9 +1288,14 @@ function sampleChain(keys, trackId, frame) {
|
|
|
1263
1288
|
const t = (frame - prevFrame) / (nextFrame - prevFrame);
|
|
1264
1289
|
const deltasA = keyDeltas(a);
|
|
1265
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);
|
|
1266
1295
|
return {
|
|
1267
|
-
keepTranslations: a.keepTranslations === true && b.keepTranslations === true,
|
|
1268
|
-
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),
|
|
1269
1299
|
// Between two based keys the DELTAS interpolate, not the absolute
|
|
1270
1300
|
// rotations and their bases separately: each key's delta is a statement
|
|
1271
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import "../panels/pose.css";
|
|
3
|
+
|
|
4
|
+
const FOOT_NAMES = { leftFoot: ["Left foot", "왼발"], rightFoot: ["Right foot", "오른발"] };
|
|
5
|
+
function stepLabel(step, ko) {
|
|
6
|
+
if (step.status === "wall") return `${ko("Wall", "벽")} · F${step.start}`;
|
|
7
|
+
const [en, kr] = FOOT_NAMES[step.foot] ?? [step.foot, step.foot];
|
|
8
|
+
const amount = step.lift !== 0 ? step.lift : step.rise;
|
|
9
|
+
return `${ko(en, kr)} F${step.start}–F${step.end} · ${amount >= 0 ? "+" : ""}${Math.round(amount * 100)}cm`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PlatformFitPanel({ ko, disabled, running, progress: progressStore, last, applied, onRun, onApply, onCancel, onRemove, onFrame }) {
|
|
13
|
+
const progress = useSyncExternalStore(progressStore.subscribe, progressStore.getSnapshot);
|
|
14
|
+
const preview = Boolean(last && !applied && last.candidate);
|
|
15
|
+
const steps = last?.steps?.filter((step) => step.lift !== 0 || step.status !== "ok") ?? [];
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!preview) return undefined;
|
|
18
|
+
const onKeyDown = (event) => {
|
|
19
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
20
|
+
if (event.key === "Escape") { event.preventDefault(); onCancel?.(); }
|
|
21
|
+
if (event.key === "Enter") { event.preventDefault(); onApply?.(); }
|
|
22
|
+
};
|
|
23
|
+
window.addEventListener("keydown", onKeyDown);
|
|
24
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
25
|
+
}, [preview, onApply, onCancel]);
|
|
26
|
+
return <section className="platform-fit auto-fix-card pose-section" data-testid="platform-fit-panel" aria-label={ko("Fit to platforms", "발판에 맞추기")}>
|
|
27
|
+
<div className="auto-fix-card-head"><h4>{ko("Fit to platforms", "발판에 맞추기")}</h4><p className="inspector-hint">{ko("Raise feet and body to platforms in the path.", "경로의 발판에 맞춰 발과 몸을 올립니다.")}</p></div>
|
|
28
|
+
<button type="button" data-testid="platform-fit-run" className="btn full primary" disabled={disabled || running || preview} onClick={onRun}>{running ? ko(`Fitting ${progress}%`, `맞추는 중 ${progress}%`) : ko("Run", "실행")}</button>
|
|
29
|
+
{running && <progress max="100" value={progress} aria-label={ko("Platform fit progress", "발판 맞춤 진행률")} />}
|
|
30
|
+
{preview && <div className="pose-preview" data-testid="platform-fit-preview">
|
|
31
|
+
<strong className="pose-preview-title">{ko("Preview", "미리보기")}</strong>
|
|
32
|
+
<span className="pose-preview-summary">{ko(`${last.summary.lifted} steps lifted · ${steps.length} result${steps.length === 1 ? "" : "s"}`, `${last.summary.lifted}개 단계 상승 · 결과 ${steps.length}개`)}</span>
|
|
33
|
+
<ul className="pose-results">{steps.slice(0, 5).map((step) => <li key={step.id} data-status={step.status}><button type="button" className="btn ghost small" onClick={() => onFrame(step.start)}>{stepLabel(step, ko)}</button></li>)}</ul>
|
|
34
|
+
<div className="pose-actions"><button data-testid="platform-fit-cancel" type="button" className="btn" onClick={onCancel}>{ko("Cancel", "취소")}</button><button data-testid="platform-fit-apply" type="button" className="btn primary" disabled={!last.changedFrames?.length || steps.some((step) => step.status === "wall" && step.lift === 0)} onClick={onApply}>{ko("Apply", "적용")}</button></div>
|
|
35
|
+
<span className="pose-keyhint">{ko("Enter applies · Esc cancels", "Enter 적용 · Esc 취소")}</span>
|
|
36
|
+
</div>}
|
|
37
|
+
{applied && <button type="button" data-testid="platform-fit-remove" className="btn full" disabled={disabled || running} onClick={onRemove}>{ko("Remove platform fit", "발판 맞춤 제거")}</button>}
|
|
38
|
+
{last && !preview && <div data-testid="platform-fit-results" className="auto-fix-result"><ul className="platform-fit-results">{steps.slice(0, 5).map((step) => <li key={step.id}><button type="button" className="btn platform-fit-step" data-status={step.status} onClick={() => onFrame(step.start)}>{stepLabel(step, ko)}</button></li>)}</ul>{steps.length === 0 && <p className="inspector-hint">{ko("No platforms in the path.", "경로에 발판이 없어요.")}</p>}</div>}
|
|
39
|
+
</section>;
|
|
40
|
+
}
|