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.
Files changed (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. 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
- restoreChainPositions(chain);
535
- const { bones, lengths, poleLocal, rig } = chain;
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 = lengths[0];
541
- const l1 = lengths[1];
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
- // Continuity: reuse the elbow's current offset from the line.
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
- restoreChainPositions(chain);
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, lengths[0]);
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, lengths[1]);
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 (see correctionWeight), blending against whatever pose is already on the
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
- // Translations stay on the CLIP until the correction has full
1072
- // authority. See restoreChainPositions for why this is a step and
1073
- // not a ramp.
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
- * ARDY playback positions mapped joints independently. Once IK authors a
1132
- * chain's rotations, those generated translations no longer describe the
1133
- * same FK pose and can visually separate the limb. Return the edited chain
1134
- * to its Mixamo bind translations before applying IK rotations so parent
1135
- * rotation and fixed segment lengths own all descendants.
1136
- *
1137
- * WHY THE DELTA PATH CALLS THIS ONLY AT FULL WEIGHT. The bind restore is not a
1138
- * small adjustment — on a generated clip the per-bone translations sit ~19 mm
1139
- * off bind — and, unlike a rotation, it cannot be expressed as a delta: the
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] pairs in ascending order. Two
1170
- * consecutive keys belong to the same island while the gap between them is no
1171
- * wider than `blendWindow`; a wider gap starts a new island.
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
- * The window is the right ruler because it is exactly how far a correction is
1174
- * allowed to reach: keys closer together than that overlap anyway, and keys
1175
- * further apart are, by the layer's own definition, separate local fixes with
1176
- * clip in between.
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) if (entry.has(trackId)) frames.push(f);
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 > blendWindow) {
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 across `blendWindow` frames outward from the
1201
- * nearest island edge, 0 beyond every island.
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 distance = frame < first ? first - frame : frame - last;
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: a.chainP && b.chainP ? a.chainP.map((p, i) => p.clone().lerp(b.chainP[i], t)) : null,
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
- return <section className="physics-review" data-testid="physics-panel" aria-label="AutoPhysics">
28
- <h4>AutoPhysics <small>{ko("review before applying", "확인 후 적용")}</small></h4>
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={onRun}>{running ? ko(`Analysing ${progress}%`, `분석 중 ${progress}%`) : ko("Analyse & preview", "분석 · 미리보기")}</button>
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={onApply}>{ko("Apply", "적용")}</button></div>
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
+ }