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.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  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/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  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 +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  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/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. 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 normalized = names[dominant];
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 = CONTACT_JOINTS.find((candidate) => {
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
- restoreChainPositions(chain);
519
- 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;
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 = lengths[0];
525
- const l1 = lengths[1];
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
- // 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.
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
- restoreChainPositions(chain);
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, lengths[0]);
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, lengths[1]);
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 (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
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
- // Translations stay on the CLIP until the correction has full
1056
- // authority. See restoreChainPositions for why this is a step and
1057
- // not a ramp.
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
- * ARDY playback positions mapped joints independently. Once IK authors a
1116
- * chain's rotations, those generated translations no longer describe the
1117
- * same FK pose and can visually separate the limb. Return the edited chain
1118
- * to its Mixamo bind translations before applying IK rotations so parent
1119
- * rotation and fixed segment lengths own all descendants.
1120
- *
1121
- * WHY THE DELTA PATH CALLS THIS ONLY AT FULL WEIGHT. The bind restore is not a
1122
- * small adjustment — on a generated clip the per-bone translations sit ~19 mm
1123
- * off bind — and, unlike a rotation, it cannot be expressed as a delta: the
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] pairs in ascending order. Two
1154
- * consecutive keys belong to the same island while the gap between them is no
1155
- * 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.
1156
1190
  *
1157
- * The window is the right ruler because it is exactly how far a correction is
1158
- * allowed to reach: keys closer together than that overlap anyway, and keys
1159
- * further apart are, by the layer's own definition, separate local fixes with
1160
- * 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.
1161
1198
  */
1162
1199
  function trackIslands(keys, trackId, blendWindow) {
1163
1200
  const frames = [];
1164
- 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
+ }
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 > blendWindow) {
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 across `blendWindow` frames outward from the
1185
- * 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.
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 distance = frame < first ? first - frame : frame - last;
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: 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),
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
- 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
+ }