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,211 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { StudioProtocolError } from './studio-agent-protocol.js';
3
+
4
+ export const AppContext = createContext(null);
5
+ export function useBus() { return useContext(AppContext).bus; }
6
+
7
+ const ref = current => ({ current });
8
+
9
+ // Stores own the document and its snapshots. The facade owns only the ordered
10
+ // relationship between their committed sessions, never a second state history.
11
+ export function createAppContext({
12
+ characters: charactersRef = ref(null), scenes: scenesRef = ref(null),
13
+ motion: motionRef = ref(null), state: liveStateRef = ref(null), getBus, notify,
14
+ } = {}) {
15
+ const storeDomains = new Map(), domainBegins = new WeakMap(), observedStores = new WeakSet();
16
+ const entries = new Map();
17
+ let past = [], future = [], activeComposition = null, historyRevision = 0;
18
+ let currentPorts = {};
19
+ const storeDomain = domain => storeDomains.get(domain);
20
+ const registeredDomains = () => [...storeDomains.values()];
21
+ const ports = { storeDomain, storeDomains: registeredDomains }, actionPorts = { storeDomain, storeDomains: registeredDomains };
22
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
23
+ const memberRetained = row => storeDomain(row.name) === row.handle && row.store.isRetained(row.id);
24
+ const retained = entry => entry.members.every(memberRetained);
25
+ const frontier = redo => redo ? future[0] : past.at(-1);
26
+ const ready = (entry, redo) => !activeComposition && entry === frontier(redo) && retained(entry)
27
+ && entry.members.every(row => redo ? row.store.canRedo() && row.store.history().future[0]?.historyEntryId === row.id : row.store.canUndo(row.id));
28
+ function prune() {
29
+ // The oldest store snapshot has no pre-image, but is still a boundary.
30
+ // Keep it (and partially expired compositions) so traversal cannot skip
31
+ // an unundoable newer transition to reach an older owner.
32
+ for (const [id, entry] of entries) if (!entry.members.some(row => {
33
+ if (storeDomain(row.name) !== row.handle) return false;
34
+ const history = row.store.history();
35
+ return row.store.isRetained(row.id) || (history.past[0] ?? history.present).historyEntryId === row.id;
36
+ })) entries.delete(id);
37
+ past = past.filter(entry => entries.has(entry.id));
38
+ future = future.filter(entry => entries.has(entry.id));
39
+ }
40
+ function step(entry, redo) {
41
+ if (!entry || !ready(entry, redo)) return false;
42
+ const revision = ports.revision?.current;
43
+ for (const row of redo ? entry.members : [...entry.members].reverse()) (redo ? row.store.redo : row.store.undo)();
44
+ if (redo) { future.shift(); past.push(entry); }
45
+ else { past.pop(); future.unshift(entry); }
46
+ historyRevision++;
47
+ // Multiple owners publish synchronously, but traversal is one authored
48
+ // transition at the binding boundary.
49
+ if (revision !== undefined) ports.revision.current = revision + 1;
50
+ return true;
51
+ }
52
+ function remember(members) {
53
+ if (!members.length) return { historyEntryId: null };
54
+ const id = members.length === 1 ? members[0].id : crypto.randomUUID();
55
+ if (entries.has(id)) return { historyEntryId: id };
56
+ for (const entry of future) entries.delete(entry.id);
57
+ future = [];
58
+ const entry = { id, members };
59
+ entry.handle = members.length === 1 ? members[0].handle : {
60
+ documentStore: { isRetained: wanted => wanted === id && entries.has(id) && retained(entry) },
61
+ canUndo: wanted => (wanted === undefined || wanted === id) && ready(entry, false),
62
+ stepHistory: redo => step(entry, redo),
63
+ };
64
+ entries.set(id, entry); past.push(entry); historyRevision++; prune();
65
+ return { historyEntryId: id };
66
+ }
67
+ function beginAction(domain, targetId = null, nested = false) {
68
+ if (activeComposition) {
69
+ if (!activeComposition.running && !nested) fail('TARGET_BUSY', 'A composed document transaction is already open.');
70
+ const parent = activeComposition;
71
+ parent.touch(domain, targetId);
72
+ return { run: parent.run, commit: () => ({ historyEntryId: null }), cancel: parent.cancel };
73
+ }
74
+ const members = new Map();
75
+ let closed = false;
76
+ const check = () => { if (closed) fail('STALE_TARGET', 'The composed document transaction is closed.'); };
77
+ const session = {
78
+ running: 0,
79
+ touches: handle => members.has(handle),
80
+ touch(name, targetId) {
81
+ check();
82
+ const handle = storeDomain(name);
83
+ if (!handle) fail('CAPABILITY_MISSING', `No document owner is registered for ${name}.`);
84
+ if (!members.has(handle)) members.set(handle, { name, handle, store: handle.documentStore,
85
+ session: domainBegins.get(handle)(targetId) });
86
+ },
87
+ run(fn) {
88
+ check(); session.running++;
89
+ try { return [...members.values()].reduceRight((next, row) => () => row.session.run(next), fn)(); }
90
+ finally { session.running--; }
91
+ },
92
+ commit() {
93
+ check();
94
+ const changed = [...members.values()].flatMap(row => {
95
+ const result = row.session.commit();
96
+ return result.historyEntryId ? [{ name: row.name, handle: row.handle, store: row.store, id: result.historyEntryId }] : [];
97
+ });
98
+ closed = true; activeComposition = null;
99
+ return remember(changed);
100
+ },
101
+ cancel(options) {
102
+ if (closed) return false;
103
+ closed = true; activeComposition = null;
104
+ for (const row of [...members.values()].reverse()) row.session.cancel(options);
105
+ return true;
106
+ },
107
+ };
108
+ activeComposition = session;
109
+ try { session.touch(domain, targetId); }
110
+ catch (error) { session.cancel(); throw error; }
111
+ return session;
112
+ }
113
+ function recordAction(domain, run, targetId = null, nested = false) {
114
+ const session = beginAction(domain, targetId, nested);
115
+ const done = result => ({ result, ...session.commit() });
116
+ const failed = error => { session.cancel(); throw error; };
117
+ try { const result = session.run(run); return result?.then ? result.then(done, failed) : done(result); }
118
+ catch (error) { return failed(error); }
119
+ }
120
+
121
+ return {
122
+ beginAction, recordAction, notify, storeDomain,
123
+ storeDomainForReceipt(receipt) {
124
+ const entry = entries.get(receipt?.undo?.historyEntryId);
125
+ return entry && retained(entry) ? entry.handle : undefined;
126
+ },
127
+ storeDomains: registeredDomains,
128
+ registerStoreDomain(name, handle) {
129
+ const store = handle.documentStore;
130
+ if (store && !observedStores.has(store)) {
131
+ observedStores.add(store);
132
+ const committed = result => {
133
+ if (result.historyEntryId && !activeComposition) remember([{ name, handle, store, id: result.historyEntryId }]);
134
+ return result;
135
+ };
136
+ const begin = store.beginAction, record = store.recordAction;
137
+ store.beginAction = (...args) => {
138
+ const session = begin(...args);
139
+ return { ...session, commit: () => committed(session.commit()) };
140
+ };
141
+ store.recordAction = (...args) => {
142
+ const result = record(...args);
143
+ return result?.then ? result.then(committed) : committed(result);
144
+ };
145
+ }
146
+ if (handle.beginAction && !domainBegins.has(handle)) {
147
+ domainBegins.set(handle, handle.beginAction.bind(handle));
148
+ handle.beginAction = targetId => beginAction(name, targetId);
149
+ handle.stepHistory = redo => {
150
+ const entry = frontier(redo);
151
+ return entry?.handle === handle && step(entry, redo);
152
+ };
153
+ if (handle.load) {
154
+ const load = handle.load.bind(handle);
155
+ handle.load = (...args) => {
156
+ if (activeComposition?.touches(handle)) activeComposition.cancel();
157
+ const result = load(...args); prune(); return result;
158
+ };
159
+ }
160
+ }
161
+ storeDomains.set(name, handle);
162
+ return () => {
163
+ if (activeComposition?.touches(handle)) activeComposition.cancel();
164
+ if (storeDomain(name) === handle) storeDomains.delete(name);
165
+ prune();
166
+ };
167
+ },
168
+ nextStoreHistory(redo) {
169
+ prune();
170
+ const entry = frontier(redo);
171
+ return entry && ready(entry, redo) ? entry.handle : undefined;
172
+ },
173
+ historyEntry(redo = false) {
174
+ const entry = frontier(redo);
175
+ return entry && ready(entry, redo) ? entry.id : null;
176
+ },
177
+ loadStoreDomains(slices) {
178
+ const loaded = new Set();
179
+ for (const [name, domain] of storeDomains) if (domain.load) {
180
+ domain.load(domain.sceneSlice ? domain.sceneSlice(slices) : slices[name]);
181
+ loaded.add(name);
182
+ }
183
+ return loaded;
184
+ },
185
+ forRender(shared) { return Object.create(this, { shared: { value: shared } }); },
186
+ // A change counter for project save checkpoints, not undo arbitration.
187
+ get undoClock() { return historyRevision; },
188
+ live: Object.freeze({
189
+ get state() { return liveStateRef.current; },
190
+ get characters() { return charactersRef.current; },
191
+ get scenes() { return scenesRef.current; },
192
+ get motion() { return motionRef.current; },
193
+ }),
194
+ publishLive(value) { liveStateRef.current = value; },
195
+ patchLive(patch) { Object.assign(liveStateRef.current, patch); },
196
+ patchTimeline(patch) { Object.assign(liveStateRef.current.timeline, patch); },
197
+ publishCharacters(value) { charactersRef.current = value; },
198
+ publishScenes(value) { scenesRef.current = value; },
199
+ publishMotion(value) { motionRef.current = value; },
200
+ get bus() { return getBus(); },
201
+ ports, actionPorts,
202
+ updatePorts(next) {
203
+ currentPorts = next;
204
+ for (const key of Object.keys(next)) {
205
+ if (key === 'revision') ports[key] = next[key];
206
+ else if (!ports[key]) ports[key] = (...args) => currentPorts[key](...args);
207
+ }
208
+ },
209
+ updateActionPorts(next) { Object.assign(actionPorts, next); },
210
+ };
211
+ }
package/src/app-stage.jsx CHANGED
@@ -21,9 +21,9 @@ import {
21
21
  TRAIL_TRACKS,
22
22
  falloffWeight,
23
23
  jointTrailPoints,
24
- nearestFrameToRay,
25
24
  trailEditRange,
26
25
  } from "./motion-trail.js";
26
+ import { pickTrailPoint } from "./trail-pick.js";
27
27
  import Timeline from "./ardy/timeline.jsx";
28
28
  import { FlyControls, aimAt } from "./controls.jsx";
29
29
  import { GIZMO_LAYER } from "./dualview.jsx";
@@ -482,11 +482,13 @@ export const DEMO_MOTION_PROMPT = "A person walks forward.";
482
482
  // same window so the two deletion paths feel like one rule.
483
483
  export const OBJECT_DELETE_UNDO_MS = 7000;
484
484
  export const ASSET_DELETE_UNDO_MS = 7000;
485
- export const CLAY = "#f2eee6";
486
- export const CLAY_B = "#ddd6ca";
485
+ // Neutral grey clay: the set is the grey workbench, so the figures are grey
486
+ // too, light enough to stand off the deck and the sky.
487
+ export const CLAY = "#bdbec3";
488
+ export const CLAY_B = "#a4a5aa";
487
489
  // X Bot's shell is smooth (no raised exoskeleton like Y Bot's), so it gets a
488
490
  // brighter, whiter clay to keep it readable against the set.
489
- export const CLAY_X = "#faf8f2";
491
+ export const CLAY_X = "#d0d0d4";
490
492
  // Model/role default for a cast member; a user-picked entry.tint always wins
491
493
  // over this at render time.
492
494
  export const defaultCharacterTint = (entry, index) => (entry.model === "x-bot-tpose" ? CLAY_X : index === 0 ? CLAY : CLAY_B);
@@ -2671,7 +2673,7 @@ export function loadSceneStartup() {
2671
2673
  * re-drawn on top as a bright highlight. Grabbing any point of a line starts
2672
2674
  * a drag on a camera-facing plane through the grab point; the caller deforms
2673
2675
  * the take (motion-trail.js falloff math) so the preview updates live. */
2674
- export const MotionTrails = memo(function MotionTrails({ motion, baseY, charScale, ikFocus, falloffFrames, pendingEdit, enabled, visible = true, onDragStart, onDragPreview, onDragEnd }) {
2676
+ export const MotionTrails = memo(function MotionTrails({ motion, rig = null, baseY, charScale, ikFocus, falloffFrames, playheadFrame, pendingEdit, enabled, visible = true, onDragStart, onDragPreview, onDragEnd }) {
2675
2677
  const { camera, gl, invalidate } = useThree();
2676
2678
  const [drag, setDrag] = useState(null);
2677
2679
  const callbacksRef = useRef({ onDragStart, onDragPreview, onDragEnd });
@@ -2682,14 +2684,16 @@ export const MotionTrails = memo(function MotionTrails({ motion, baseY, charScal
2682
2684
  for (let index = 0; index + 2 < flat.length; index += 3) out.push([flat[index], flat[index + 1], flat[index + 2]]);
2683
2685
  return out;
2684
2686
  };
2685
- // Every trail track (root + IK endpoints + head) in its handle colour.
2687
+ // Every trail track (root + IK endpoints + head) in its handle colour,
2688
+ // sampled where playback renders each bone on this rig (not the clip's
2689
+ // own joints, which sit 4-15 cm off the body), so draw and pick agree.
2686
2690
  const tracks = useMemo(
2687
2691
  () => TRAIL_TRACKS.map((track) => {
2688
- const flat = jointTrailPoints(motion, track.joint, { baseY, scale: charScale });
2692
+ const flat = jointTrailPoints(motion, track.joint, { baseY, scale: charScale, rig });
2689
2693
  return flat ? { ...track, flat, points: toTriples(flat) } : null;
2690
2694
  }).filter(Boolean),
2691
2695
  // eslint-disable-next-line react-hooks/exhaustive-deps
2692
- [motion, baseY, charScale],
2696
+ [motion, rig, baseY, charScale],
2693
2697
  );
2694
2698
  const trackById = (id) => tracks.find((track) => track.id === id) ?? null;
2695
2699
  // The falloff window rides whichever line is being (or was last) grabbed.
@@ -2713,7 +2717,7 @@ export const MotionTrails = memo(function MotionTrails({ motion, baseY, charScal
2713
2717
  // A single pointerdown listener that measures point-to-ray distance against
2714
2718
  // the cached trail arrays costs nothing while the mouse merely moves.
2715
2719
  const pickRef = useRef(null);
2716
- pickRef.current = { tracks, enabled, falloffFrames };
2720
+ pickRef.current = { tracks, enabled, falloffFrames, playheadFrame };
2717
2721
  // Line2 instances for in-place geometry rewrites during a drag.
2718
2722
  const lineRefs = useRef({});
2719
2723
  const highlightRef = useRef(null);
@@ -2731,21 +2735,16 @@ export const MotionTrails = memo(function MotionTrails({ motion, baseY, charScal
2731
2735
  );
2732
2736
  raycaster.setFromCamera(ndc, camera);
2733
2737
  const { origin, direction } = raycaster.ray;
2734
- // TRAIL_TRACKS order is limbs-first, hips last: an overlapping grab
2735
- // prefers the finer limb target, and among candidates within the
2736
- // threshold the closest line wins.
2737
- let track = null;
2738
- let grabFrame = 0;
2739
- let bestDistance = Infinity;
2740
- for (const candidate of pick.tracks) {
2741
- const near = nearestFrameToRay(candidate.flat, origin, direction, 0.2);
2742
- if (near && near.distance < bestDistance) {
2743
- track = candidate.id;
2744
- grabFrame = near.frame;
2745
- bestDistance = near.distance;
2746
- }
2747
- }
2748
- if (!track) return;
2738
+ const picked = pickTrailPoint({
2739
+ tracks: pick.tracks,
2740
+ playheadFrame: pick.playheadFrame,
2741
+ falloffFrames: pick.falloffFrames,
2742
+ rayOrigin: origin,
2743
+ rayDirection: direction,
2744
+ maxDistance: 0.2,
2745
+ });
2746
+ if (!picked) return;
2747
+ const { track, grabFrame } = picked;
2749
2748
  // The grab wins over the camera controls listening in the bubble phase.
2750
2749
  event.stopPropagation();
2751
2750
  event.preventDefault();
@@ -2851,10 +2850,12 @@ export const MotionTrails = memo(function MotionTrails({ motion, baseY, charScal
2851
2850
  );
2852
2851
  }, (previous, next) => (
2853
2852
  previous.motion === next.motion &&
2853
+ previous.rig === next.rig &&
2854
2854
  previous.baseY === next.baseY &&
2855
2855
  previous.charScale === next.charScale &&
2856
2856
  previous.ikFocus === next.ikFocus &&
2857
2857
  previous.falloffFrames === next.falloffFrames &&
2858
+ previous.playheadFrame === next.playheadFrame &&
2858
2859
  previous.pendingEdit === next.pendingEdit &&
2859
2860
  previous.enabled === next.enabled &&
2860
2861
  previous.visible === next.visible
@@ -0,0 +1,108 @@
1
+ @layer legacy {
2
+ .auto-fix-group {
3
+ --auto-fix-space: 4px;
4
+ display: grid;
5
+ gap: calc(var(--auto-fix-space) * 3);
6
+ margin-top: calc(var(--auto-fix-space) * 3);
7
+ }
8
+
9
+ .auto-fix-card {
10
+ --auto-fix-space: 4px;
11
+ min-width: 0;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: calc(var(--auto-fix-space) * 2);
15
+ padding: calc(var(--auto-fix-space) * 3);
16
+ border: 1px solid var(--line2);
17
+ border-radius: var(--radius);
18
+ background: var(--card2);
19
+ color: var(--fg);
20
+ }
21
+
22
+ .auto-fix-card h4 {
23
+ margin: 0;
24
+ color: var(--fg);
25
+ font-size: 12px;
26
+ font-weight: 650;
27
+ }
28
+
29
+ .auto-fix-card-head {
30
+ display: grid;
31
+ gap: var(--auto-fix-space);
32
+ min-width: 0;
33
+ }
34
+
35
+ .auto-fix-card .inspector-hint {
36
+ margin: 0;
37
+ }
38
+
39
+ .auto-fix-actions {
40
+ display: grid;
41
+ grid-template-columns: repeat(2, minmax(0, 1fr));
42
+ gap: calc(var(--auto-fix-space) * 2);
43
+ }
44
+
45
+ .auto-fix-actions > .btn {
46
+ min-width: 0;
47
+ margin-top: 0;
48
+ white-space: normal;
49
+ line-height: 1.3;
50
+ }
51
+
52
+ .body-collision-card .auto-fix-actions {
53
+ grid-template-columns: 1fr;
54
+ }
55
+
56
+ .auto-fix-card > .btn.full {
57
+ margin-top: 0;
58
+ }
59
+
60
+ .auto-fix-result {
61
+ min-width: 0;
62
+ padding: calc(var(--auto-fix-space) * 2);
63
+ border: 1px solid var(--line2);
64
+ border-radius: var(--radius);
65
+ background: var(--card2);
66
+ color: var(--muted);
67
+ font-size: 10px;
68
+ line-height: 1.4;
69
+ }
70
+
71
+ .auto-fix-card details {
72
+ border-top: 1px solid var(--line2);
73
+ padding-top: calc(var(--auto-fix-space) * 2);
74
+ }
75
+
76
+ .auto-fix-card details > summary {
77
+ color: var(--muted);
78
+ font-size: 11px;
79
+ }
80
+
81
+ .auto-fix-card .physics-actions {
82
+ margin: 0;
83
+ }
84
+
85
+ .auto-fix-card.platform-fit,
86
+ .auto-fix-card.physics-review {
87
+ margin-top: 0;
88
+ padding-top: calc(var(--auto-fix-space) * 3);
89
+ border-top: 1px solid var(--line2);
90
+ }
91
+
92
+ /* Four tools: the shared .segmented pill (styles.css, loaded after this file)
93
+ is sized for two, so these rules need the extra class to win. Same inset
94
+ geometry as the base pill, one quarter wide. */
95
+ .segmented.ik-edit-tools:before {
96
+ width: calc((100% - 8px) / 4);
97
+ }
98
+
99
+ .segmented.ik-edit-tools[data-active="ik"]:before { transform: translateX(0); }
100
+ .segmented.ik-edit-tools[data-active="trail"]:before { transform: translateX(100%); }
101
+ .segmented.ik-edit-tools[data-active="pin"]:before { transform: translateX(200%); }
102
+ .segmented.ik-edit-tools[data-active="autofix"]:before { transform: translateX(300%); }
103
+
104
+ .ik-tool-panel {
105
+ min-width: 0;
106
+ }
107
+
108
+ }
@@ -1206,23 +1206,14 @@ const NO_PUSH = Object.freeze({ ran: false, moved: false });
1206
1206
  * limb's rest pose by escapeVector — see the REST-BIASED ESCAPE note there for
1207
1207
  * why the raw separation normal is not enough on a deep hit.
1208
1208
  */
1209
- function pushCapsule(capsule, chains, push, ikState, { onlyChains = null, floorY = 0, normalise = null } = {}) {
1209
+ function pushCapsule(capsule, chains, push, ikState, { onlyChains = null, floorY = 0 } = {}) {
1210
1210
  const target = capsule.def.movable;
1211
1211
  if (!target) return NO_PUSH;
1212
1212
  if (onlyChains && !onlyChains.has(target.chain)) return NO_PUSH;
1213
1213
  const chain = chains?.get(target.chain);
1214
1214
  if (!chain) return NO_PUSH;
1215
- // BIND TRANSLATIONS, HERE, FOR THIS CHAIN ONLY. solveIk resets them anyway —
1216
- // its segment lengths were measured at bind and its law of cosines is exact
1217
- // only there — so the reset happens either way; doing it HERE means the push
1218
- // target is read on the same skeleton the solver will produce, which keeps
1219
- // the baked delta the push rather than the length compensation, while every
1220
- // chain this run does not solve keeps the clip's own translations. Doing it
1221
- // to the WHOLE rig up front (what this used to do) meant detecting on a
1222
- // skeleton the caller never sees: phantom pairs on limbs the readout calls
1223
- // clean, and leg chains keyed — and snapped to bind length — for penetrations
1224
- // that only existed at bind.
1225
- if (normalise) normalise(target.chain, chain);
1215
+ // Detection, target and solve all use the clip's current translations.
1216
+ // A bind reset here would reintroduce length compensation into the key.
1226
1217
  const rotations = chain.bones.map((bone) => bone.quaternion.clone());
1227
1218
  const driven = target.joint === "mid" ? chain.bones[1] : chain.bones[2];
1228
1219
  const before = driven.getWorldPosition(new THREE.Vector3());
@@ -1288,24 +1279,10 @@ function pushCapsule(capsule, chains, push, ikState, { onlyChains = null, floorY
1288
1279
  * pose on the rig at entry is used, which is exact whenever no earlier key is
1289
1280
  * already blending into this frame.
1290
1281
  *
1291
- * BIND TRANSLATIONS, PER CHAIN, AT SOLVE TIME. solveIk resets a chain to its
1292
- * bind translations the moment it runs — its segment lengths were measured at
1293
- * bind and its law of cosines is exact only there — so the push target is read
1294
- * AFTER that reset, on the same skeleton the solver will produce. That is what
1295
- * keeps the baked delta the push rather than the LENGTH COMPENSATION: aiming at
1296
- * a clip-length limb's wrist with a bind-length one turned a 15 mm lift into an
1297
- * 11.8 ° knee swing whose components cancel only at full weight, and a partly
1298
- * weighted blend of that wandered 31 mm.
1299
- *
1300
- * What this must NOT do — and used to — is normalise every chain up front,
1301
- * before the first capsule is built. That made the pass detect on a skeleton the
1302
- * caller never sees: on a real clip (~19 mm off bind per bone) it reported pairs
1303
- * the UI's own readout calls clean, keyed limbs for penetrations that exist only
1304
- * at bind (whole-clip runs moved a leg 142 mm on frames with no leg pair at all,
1305
- * and put a toe 17 mm under the floor), and made a second press of the button
1306
- * put an arm back inside the crate the first press took it out of. Detection now
1307
- * reads the pose as handed over; only a chain being solved is normalised, and a
1308
- * chain this run does not key gets its clip translations back untouched.
1282
+ * CLIP TRANSLATIONS THROUGHOUT. The solvers measure each frame's segment
1283
+ * lengths without changing its locals. Detection, pushes and warm starts must
1284
+ * therefore leave those translations alone too; the bake records them so the
1285
+ * pose measured here is the one playback reproduces.
1309
1286
  */
1310
1287
  export function fixCollisions(rig, chains, {
1311
1288
  radii = null,
@@ -1328,33 +1305,6 @@ export function fixCollisions(rig, chains, {
1328
1305
  const bases = baseQuats ?? new Map(
1329
1306
  [...chains].map(([id, chain]) => [id, chain.bones.map((bone) => bone.quaternion.clone())]),
1330
1307
  );
1331
- /** Bind translations, once, for a chain that is about to be solved — see
1332
- * pushCapsule. Nothing else on the rig is touched, so DETECTION always reads
1333
- * the pose the caller handed over (plus whatever this run has already
1334
- * solved), which is the same pose detectPenetrations reports to the UI. */
1335
- const clipTranslations = [];
1336
- const normalised = new Set();
1337
- const normaliseChain = (id, chain) => {
1338
- if (normalised.has(id) || !chain?.bindPositions) return;
1339
- normalised.add(id);
1340
- chain.bones.forEach((bone, index) => {
1341
- clipTranslations.push([id, bone, bone.position.clone()]);
1342
- bone.position.copy(chain.bindPositions[index]);
1343
- });
1344
- rig.updateMatrixWorld(true);
1345
- };
1346
- /** Give the clip its translations back on every chain this pass did not
1347
- * actually key. Only a keyed chain owes the bind pose its rotations were
1348
- * solved in; the rest are the clip's business and are handed back untouched. */
1349
- const restoreTranslations = (keptIds = null) => {
1350
- let restored = false;
1351
- for (const [id, bone, position] of clipTranslations) {
1352
- if (keptIds?.has(id)) continue;
1353
- bone.position.copy(position);
1354
- restored = true;
1355
- }
1356
- if (restored) rig.updateMatrixWorld(true);
1357
- };
1358
1308
  const touched = new Set();
1359
1309
  let poseDirty = false;
1360
1310
  /**
@@ -1377,7 +1327,7 @@ export function fixCollisions(rig, chains, {
1377
1327
  const chainId = capsule.def.movable?.chain;
1378
1328
  if (allowedChains && chainId && !allowedChains.has(chainId)) return false;
1379
1329
  const outcome = pushCapsule(capsule, chains, vector, ikState, {
1380
- onlyChains, floorY, normalise: normaliseChain,
1330
+ onlyChains, floorY,
1381
1331
  });
1382
1332
  if (outcome.ran) {
1383
1333
  touched.add(capsule.def.movable.chain);
@@ -1549,7 +1499,6 @@ export function fixCollisions(rig, chains, {
1549
1499
  for (; pass < maxIterations; pass += 1) {
1550
1500
  const capsules = buildCollisionCapsules(rig, radii);
1551
1501
  if (!capsules) {
1552
- if (!changed) restoreTranslations();
1553
1502
  return { supported: false, changed, passes: pass, residual, touched: [...touched], baseQuats: bases };
1554
1503
  }
1555
1504
  // `changed` is cumulative across passes, so it can never report what
@@ -1803,7 +1752,6 @@ export function fixCollisions(rig, chains, {
1803
1752
  && afterFoot < Math.min(entryFoot, floorY + PUSH_FLOOR_CLEARANCE) - 1e-6;
1804
1753
  if (!improved || buried) {
1805
1754
  frameRestore(entryPose);
1806
- restoreTranslations();
1807
1755
  return {
1808
1756
  supported: true,
1809
1757
  changed: false,
@@ -1816,8 +1764,6 @@ export function fixCollisions(rig, chains, {
1816
1764
  }
1817
1765
  }
1818
1766
 
1819
- // Hand the pose back as it arrived everywhere this pass did not key.
1820
- restoreTranslations(changed ? touched : null);
1821
1767
  // Final measurement for an honest residual report.
1822
1768
  if (changed) {
1823
1769
  const capsules = buildCollisionCapsules(rig, radii);
@@ -2043,16 +1989,14 @@ export function fixCollisionsRange({
2043
1989
  }
2044
1990
  return deltas;
2045
1991
  };
2046
- /** Seed `ids` with the previous frame's correction: bind translations (the
2047
- * pose a keyed chain is evaluated at, so it is the pose worth measuring) and
2048
- * this frame's rotations composed with that frame's delta. */
1992
+ /** Seed `ids` with the previous frame's rotation correction on this frame's
1993
+ * own translations, the same solve space used by the fresh attempts. */
2049
1994
  const applyWarmStart = (ids, deltas) => {
2050
1995
  for (const id of ids) {
2051
1996
  const chain = chains.get(id);
2052
1997
  const delta = deltas.get(id);
2053
1998
  if (!chain || !delta) continue;
2054
1999
  chain.bones.forEach((bone, index) => {
2055
- if (chain.bindPositions) bone.position.copy(chain.bindPositions[index]);
2056
2000
  if (delta[index]) bone.quaternion.multiply(delta[index]);
2057
2001
  });
2058
2002
  }
@@ -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
+ }