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,932 @@
1
+ import { createObjectPath } from "../object-path.js";
2
+ import { useState, useRef } from "react";
3
+ import { createDocumentStore } from "../document-store.js";
4
+ import { useDocumentDomain } from "../store/use-document-store.js";
5
+ import { arrangement } from "../studio-agent-commands.js";
6
+ import {
7
+ readStoredObjectColors,
8
+ rememberObjectColor,
9
+ writeStoredObjectColors,
10
+ sceneObjectIdFromHierarchy,
11
+ updateSceneObject,
12
+ removeSceneObject,
13
+ dropToSurfacePatch,
14
+ placementInFront,
15
+ createSceneObject,
16
+ createCutoutObject,
17
+ CUTOUT_DEFAULT_HEIGHT,
18
+ createMeshObject,
19
+ CUTOUT_KIND,
20
+ duplicateCutoutOptions,
21
+ MESH_KIND,
22
+ duplicateMeshOptions,
23
+ objectSize,
24
+ setSceneObjectAttach,
25
+ setSceneObjectParent,
26
+ } from "../scene-objects.js";
27
+
28
+ import { ko, isKo } from "../locale.js";
29
+ import {
30
+ sceneObjectNameDisplayKo,
31
+ attachWorldMatrix,
32
+ sceneObjectMatrix,
33
+ ATTACH_BONE_ROWS,
34
+ HIERARCHY_INSPECTOR_TITLES,
35
+ attachPlacementPatch,
36
+ placeSceneObject,
37
+ } from "../app-stage.jsx";
38
+ import { rememberAsset, assetRecord } from "../scene-asset-cache.js";
39
+ import { importImageFile, assetAspect, openAssetDb, putAsset } from "../scene-assets.js";
40
+ import { importMeshFile, compressedGlbReason, meshBoundsFromAsset, fitMeshBounds } from "../scene-mesh.js";
41
+ import { cutOutBackground, maskAsset } from "../matte.js";
42
+ import { parseRigNodeId } from "../hierarchy-model.js";
43
+ import { StudioProtocolError } from "../studio-agent-protocol.js";
44
+
45
+ // A stable owner across scene loads. App's remaining native history readers use
46
+ // the small store facade below; authored writes are owned by the document store.
47
+ export function createObjectsDomain(appContext, initial) {
48
+ const listeners = new Set();
49
+ const notify = () => { for (const listener of listeners) listener(); };
50
+ let native = createDocumentStore({ owned: { objects: initial } });
51
+ let release = native.subscribe(notify);
52
+ const documentStore = {
53
+ ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
54
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
55
+ };
56
+ const read = () => documentStore.read("objects");
57
+ function write(update) {
58
+ return documentStore.write("objects", before => {
59
+ let next = typeof update === "function" ? update(before) : update;
60
+ for (const row of next.filter(row => row.remove === true)) next = removeSceneObject(next, row.id);
61
+ if (JSON.stringify(next) === JSON.stringify(before)) return before;
62
+ return next;
63
+ });
64
+ }
65
+ const publish = () => { if (appContext.live.state) appContext.patchLive({ objects: read() }); };
66
+ const unsubscribe = documentStore.subscribe(publish);
67
+ function beginAction() { return documentStore.beginAction("objects"); }
68
+ function stepHistory(redo) {
69
+ domain.settle?.();
70
+ return Boolean((redo ? documentStore.redo : documentStore.undo)());
71
+ }
72
+ const domain = { documentStore, read, write, beginAction, canUndo: id => documentStore.canUndo(id), stepHistory,
73
+ document: () => ({ objects: read() }), publish: state => write(state.objects), commitDraft: write,
74
+ load(objects) {
75
+ domain.settle?.(); release(); native.dispose();
76
+ native = createDocumentStore({ owned: { objects } });
77
+ release = native.subscribe(notify); notify();
78
+ },
79
+ arrange(args) {
80
+ const state = appContext.ports.read();
81
+ const plan = arrangement({ name: "arrange_objects", args }, { ...state, frame: state.view.frame }, { bounds: appContext.ports.bounds });
82
+ write(plan.draft); return plan;
83
+ },
84
+ dispose() { domain.cancelGesture?.(); unregister(); unsubscribe(); release(); native.dispose(); listeners.clear(); },
85
+ };
86
+ const store = {
87
+ get objects() { return read(); },
88
+ present: () => documentStore.history().present.snapshot.objects,
89
+ depths: documentStore.depths,
90
+ applyAtomic: write,
91
+ undo: () => stepHistory(false) ? read() : null,
92
+ redo: () => stepHistory(true) ? read() : null,
93
+ settle: () => domain.settle?.(),
94
+ hasHistoryState: state => [...documentStore.history().past, documentStore.history().present, ...documentStore.history().future].some(entry => entry.snapshot.objects === state),
95
+ beginCommand() {
96
+ const session = beginAction();
97
+ return { ...session, commit: () => Boolean(session.commit().historyEntryId) };
98
+ },
99
+ };
100
+ domain.store = store;
101
+ const unregister = appContext.registerStoreDomain("objects", domain);
102
+ return domain;
103
+ }
104
+
105
+ export function useObjects(appContext) {
106
+ // Hand-mixed object tints, newest first. An editor preference like the
107
+ // guides above — it belongs to this browser, never to the scene, so it is
108
+ // kept out of the scene document and written straight back to storage.
109
+ const [recentObjectColors, setRecentObjectColors] = useState(() => readStoredObjectColors(globalThis.localStorage));
110
+
111
+ // What is being typed into the hex field right now, or null when nobody is
112
+ // typing. Held apart from the record so a half-written "#ff3" survives on
113
+ // screen without ever repainting the prop, and so the field snaps back to
114
+ // the object's real colour the moment the edit ends.
115
+ const [objectColorDraft, setObjectColorDraft] = useState(null);
116
+
117
+ function rememberSceneObjectColor(hex) {
118
+ setRecentObjectColors((previous) => {
119
+ const next = rememberObjectColor(previous, hex);
120
+ // rememberObjectColor returns the same array when nothing changed, so a
121
+ // re-pick of the same tint neither writes storage nor re-renders.
122
+ if (next !== previous) writeStoredObjectColors(globalThis.localStorage, next);
123
+ return next;
124
+ });
125
+ }
126
+
127
+ const [objectDeleteUndo, setObjectDeleteUndo] = useState(null);
128
+
129
+ // StrictMode replays initializers: reuse this editor's registered owner,
130
+ // rather than registering a second store whose React result is discarded.
131
+ const [domain] = useState(() => appContext.storeDomain("objects") ?? createObjectsDomain(appContext, appContext.shared.startupScene.objects));
132
+ const sceneObjects = useDocumentDomain(domain.documentStore, "objects");
133
+ const storeRef = useRef(domain.store);
134
+ const store = storeRef.current;
135
+ const gesture = useRef(null);
136
+ function run(id, args = {}) {
137
+ const receipt = appContext.bus.run(id, args);
138
+ const check = result => { if (!result.ok) throw new StudioProtocolError(result.code, result.message); return result; };
139
+ return receipt?.then ? receipt.then(check) : check(receipt);
140
+ }
141
+
142
+ const selectedSceneObjectId = sceneObjectIdFromHierarchy(appContext.shared.selectedHierarchyId);
143
+
144
+ const selectedSceneObject = sceneObjects.find((object) => object.id === selectedSceneObjectId) ?? null;
145
+
146
+ // Producer drag lifecycle (plan §6.1): begin issues a token the producer
147
+ // presents on every apply and on close; end commits the drag as one
148
+ // history entry, or rolls it back when commit is false (Escape).
149
+ function beginSceneTransaction({ owner, cancel }) {
150
+ domain.settle();
151
+ const { txId } = run("run.begin", { id: "object.update", args: {} });
152
+ const release = appContext.bus.subscribe(event => {
153
+ if (event.type !== "transaction.cancelled" || event.txId !== txId) return;
154
+ gesture.current = null; release(); cancel();
155
+ });
156
+ gesture.current = { txId, owner, cancel, release };
157
+ return txId;
158
+ }
159
+
160
+ function endSceneTransaction(token, { commit }) {
161
+ if (gesture.current?.txId !== token) return;
162
+ gesture.current.release();
163
+ gesture.current = null;
164
+ return run(commit ? "run.commit" : "run.cancel", { txId: token });
165
+ }
166
+ domain.settle = () => {
167
+ const active = gesture.current;
168
+ if (!active) return;
169
+ const receipt = endSceneTransaction(active.txId, { commit: true });
170
+ active.cancel();
171
+ return receipt;
172
+ };
173
+ domain.cancelGesture = () => {
174
+ const active = gesture.current;
175
+ if (!active) return;
176
+ endSceneTransaction(active.txId, { commit: false }); active.cancel();
177
+ };
178
+
179
+ // App's single scene-object mutation entry (plan §6.1). A token means a
180
+ // producer drag stream: apply inside the open transaction so the change
181
+ // lands in the live array without its own history entry. No token is an
182
+ // atomic edit — one entry. updateSceneObject returns the same array when
183
+ // nothing changed, so a no-op can never create an entry.
184
+ function changeSceneObject(id, patch, token) {
185
+ return token != null
186
+ ? run("run.update", { txId: token, args: { id, patch } })
187
+ : run("object.update", { id, patch });
188
+ }
189
+
190
+ function deleteSelectedSceneObject() {
191
+ deleteSceneObject(selectedSceneObjectId);
192
+ }
193
+
194
+ /** Delete by id — the hierarchy context menu's Delete. Unlike the
195
+ * selection-based path above, removing a row that is not the selection
196
+ * must leave the selection alone. */
197
+ function deleteSceneObject(id) {
198
+ if (!id) return;
199
+ const wasSelected = id === selectedSceneObjectId;
200
+ run("object.remove", { ids: [id] });
201
+ setObjectDeleteUndo({ id, pastDepth: store.depths().past });
202
+ appContext.shared.setInspectorActionsOpen(false);
203
+ if (wasSelected) {
204
+ appContext.shared.setSelectedHierarchyId("props");
205
+ }
206
+ }
207
+
208
+ /** Drop-to-surface (plan §9.2/§9.3): End, no modifier. Strict drop-down —
209
+ * the selection falls until its base touches the highest support top at or
210
+ * below it, or the floor. dropToSurfacePatch is pure and returns null when
211
+ * already resting, so a redundant press never creates a history entry, and
212
+ * x/z are never written. One applyAtomic = one undo entry. */
213
+ function dropSelectedSceneObject() {
214
+ const object = sceneObjects.find((item) => item.id === selectedSceneObjectId) ?? null;
215
+ if (!object) return;
216
+ const patch = dropToSurfacePatch(object, sceneObjects.filter((item) => item.id !== object.id), appContext.shared.characters);
217
+ if (patch === null) {
218
+ appContext.notify(ko("Nothing to drop", "내려놓을 대상이 없어요"));
219
+ return;
220
+ }
221
+ changeSceneObject(object.id, patch);
222
+ appContext.notify(isKo ? `${sceneObjectNameDisplayKo(object.name)}을 표면 위에 내려놓았어요` : `${object.name} dropped to surface`);
223
+ }
224
+
225
+ // How much of the wall counts as the wall, and how wide the brush that
226
+ // argues with the answer is.
227
+ const [matteTolerance, setMatteTolerance] = useState(0.18);
228
+
229
+ const [matteBrush, setMatteBrush] = useState(18);
230
+
231
+ // Edge cleanup for the cut: shrink eats the blended rim, feather softens
232
+ // what is left. Both ride into applyMask; the defaults match applyMask's.
233
+ const [matteShrink, setMatteShrink] = useState(1);
234
+
235
+ const [matteFeather, setMatteFeather] = useState(1);
236
+
237
+ const [matteMode, setMatteMode] = useState("paint");
238
+
239
+ const [matteStats, setMatteStats] = useState({ painted: 0, coverage: 0, zoom: 1, canUndo: false, canRedo: false });
240
+
241
+ const [matteBusy, setMatteBusy] = useState(false);
242
+
243
+ const [gizmoMode, setGizmoMode] = useState("move");
244
+
245
+ // Snap is a preference, not a law: with it on the gizmo blocks on the plan
246
+ // board's grid, and Ctrl/Cmd during a drag gives a free one. Off, it is the
247
+ // other way round. (docs/unity-reference.md §9.5)
248
+ const [snapEnabled, setSnapEnabled] = useState(true);
249
+
250
+ /** `at` overrides the floor point: the Assets-shelf drop already knows
251
+ * where the pointer hit, everyone else gets in-front-of-camera. */
252
+ function addSceneObject(kind, at) {
253
+ const camera = (appContext.shared.lookThroughShot ? appContext.shared.shotCamRef : appContext.shared.editorCamRef).current;
254
+ const paneYaw = (appContext.shared.lookThroughShot ? appContext.shared.look : appContext.shared.editorLook).current.yaw;
255
+ const placement = at ?? (camera
256
+ ? placementInFront({ x: camera.position.x, z: camera.position.z }, paneYaw)
257
+ : {});
258
+ const receipt = run("object.add", { kind, placement });
259
+ const object = domain.read().find(row => row.id === receipt.affectedIds[0]);
260
+ appContext.shared.markCraftAction("object");
261
+ appContext.shared.setSelectedHierarchyId(`object:${object.id}`);
262
+ // Deliberate divergence from Unity's rename-on-create: creating an object
263
+ // here is followed by placing it, and dropping focus into a text field
264
+ // swallows the very next W/E/R. Renaming stays on F2/Return and the row's
265
+ // context menu. (docs/unity-reference.md §9.7)
266
+ setGizmoMode("move");
267
+ appContext.notify(isKo ? `${sceneObjectNameDisplayKo(object.name)} 추가됨 — W 이동, E 회전, R 크기` : `${object.name} added — W move, E rotate, R scale`);
268
+ }
269
+
270
+ /** "Sofa 2.png" reads as a set piece; "sofa-2.png" does not. The extension
271
+ * goes, the rest is the user's own name for the thing. */
272
+ function cutoutNameFromFile(fileName) {
273
+ const base = String(fileName ?? "").replace(/\.[^.]+$/, "").trim();
274
+ return base || ko("Cutout", "컷아웃");
275
+ }
276
+
277
+ /**
278
+ * Import one image and stand it up in the set. The card arrives at the
279
+ * figure's own height, because a standee whose scale is a guess is worse
280
+ * than useless in a tool where every camera level is a height in metres —
281
+ * 1.8 m is at least an honest starting point to correct from.
282
+ */
283
+ async function importCutout(file, commandContext) {
284
+ if (!file) return;
285
+ if (!commandContext) return importFile(file, "cutout");
286
+ try {
287
+ const asset = await rememberAsset(await importImageFile(file));
288
+ const camera = (appContext.shared.lookThroughShot ? appContext.shared.shotCamRef : appContext.shared.editorCamRef).current;
289
+ const placement = camera
290
+ ? placementInFront({ x: camera.position.x, z: camera.position.z }, (appContext.shared.lookThroughShot ? appContext.shared.look : appContext.shared.editorLook).current.yaw)
291
+ : {};
292
+ const object = createCutoutObject(
293
+ { assetId: asset.id, aspect: assetAspect(asset) ?? 1, height: CUTOUT_DEFAULT_HEIGHT, name: cutoutNameFromFile(asset.name) },
294
+ domain.read(),
295
+ placement,
296
+ );
297
+ const imported = publishImported(object, commandContext);
298
+ appContext.notify(
299
+ isKo
300
+ ? `${object.name} 추가됨 — 실제 높이(m)를 입력하면 크기가 맞습니다`
301
+ : `${object.name} added — type its real height in metres to set the scale`,
302
+ );
303
+ return imported;
304
+ } catch (error) {
305
+ throw new StudioProtocolError(error.code ?? "INVALID_ARGUMENT", `Could not import that image: ${error.message}`);
306
+ }
307
+ }
308
+
309
+ /** A drop can carry several pictures. They go in one at a time so each
310
+ * lands in its own place and the last one is the one left selected. */
311
+ async function importCutouts(files) {
312
+ for (const file of files) await importCutout(file);
313
+ }
314
+
315
+ /**
316
+ * Stand an ALREADY-STORED picture up as a fresh cutout — the Assets-shelf
317
+ * drop. The bytes are content-addressed and in the store, so this is
318
+ * `importCutout` without the import: read the record for its true aspect
319
+ * and name, mint the card, one atomic history entry.
320
+ */
321
+ async function spawnCutoutAt(assetId, placement, commandContext) {
322
+ if (!commandContext) return importStoredAsset(assetId, "cutout", placement);
323
+ appContext.shared.markCraftAction("cutout");
324
+ const record = await assetRecord(assetId);
325
+ if (!record) throw new StudioProtocolError("TARGET_NOT_READY", "That image is no longer stored.");
326
+ const object = createCutoutObject(
327
+ { assetId: record.id, aspect: assetAspect(record) ?? 1, height: CUTOUT_DEFAULT_HEIGHT, name: cutoutNameFromFile(record.name) },
328
+ domain.read(),
329
+ placement,
330
+ );
331
+ const imported = publishImported(object, commandContext);
332
+ appContext.notify(
333
+ isKo
334
+ ? `${object.name} 추가됨 — 실제 높이(m)를 입력하면 크기가 맞습니다`
335
+ : `${object.name} added — type its real height in metres to set the scale`,
336
+ );
337
+ return imported;
338
+ }
339
+
340
+ const importFiles = useRef(new Map());
341
+ async function importFile(file, placeAs) {
342
+ const fileToken = crypto.randomUUID();
343
+ importFiles.current.set(fileToken, file);
344
+ try {
345
+ const receipt = await appContext.bus.run("asset.import", { fileToken, placeAs });
346
+ if (!receipt.ok) appContext.notify(receipt.message);
347
+ return receipt;
348
+ } finally { importFiles.current.delete(fileToken); }
349
+ }
350
+ async function importStoredAsset(assetId, placeAs, placement) {
351
+ const receipt = await appContext.bus.run("asset.import", { assetId, placeAs, ...(placement ? { placement } : {}) });
352
+ if (!receipt.ok) appContext.notify(receipt.message);
353
+ return receipt;
354
+ }
355
+ function publishImported(object, commandContext) {
356
+ if (!object) throw new StudioProtocolError("INVALID_ARGUMENT", "Could not create the imported object.");
357
+ commandContext.commit(() => domain.write(objects => [...objects, object]));
358
+ appContext.shared.setSelectedHierarchyId(`object:${object.id}`);
359
+ setGizmoMode("move");
360
+ return { assetId: object.assetId, objectId: object.id };
361
+ }
362
+ domain.importAsset = async (args, context) => {
363
+ if (args.fileToken) {
364
+ if (context.origin !== "ui") throw new StudioProtocolError("CAPABILITY_MISSING", "File imports belong to the UI.");
365
+ const file = importFiles.current.get(args.fileToken);
366
+ if (!file) throw new StudioProtocolError("STALE_TARGET", "The selected import file is no longer available.");
367
+ return args.placeAs === "mesh" ? importMesh(file, context) : importCutout(file, context);
368
+ }
369
+ if (args.assetId) return args.placeAs === "mesh" ? spawnMeshAt(args.assetId, args.placement, context) : spawnCutoutAt(args.assetId, args.placement, context);
370
+ return importCommandAsset(args, context);
371
+ };
372
+ domain.applyMatte = applyMatte;
373
+
374
+ function meshNameFromFile(fileName) {
375
+ const base = String(fileName ?? "").replace(/\.[^.]+$/, "").trim();
376
+ return base || ko("Model", "모델");
377
+ }
378
+
379
+ async function persistMeshAsset(asset) {
380
+ const db = await openAssetDb();
381
+ try {
382
+ return await putAsset(db, asset);
383
+ } finally {
384
+ db.close?.();
385
+ }
386
+ }
387
+
388
+ function placementInFrontOfShot() {
389
+ const camera = (appContext.shared.lookThroughShot ? appContext.shared.shotCamRef : appContext.shared.editorCamRef).current;
390
+ return camera
391
+ ? placementInFront({ x: camera.position.x, z: camera.position.z }, (appContext.shared.lookThroughShot ? appContext.shared.look : appContext.shared.editorLook).current.yaw)
392
+ : {};
393
+ }
394
+
395
+ /**
396
+ * Import one GLB and stand it on the floor. Bytes go through putAsset —
397
+ * never rememberAsset — because the texture cache would decode them as a
398
+ * bitmap. Height and footprint come from the import heuristic once;
399
+ * later instances reuse those stored metres.
400
+ */
401
+ async function importMesh(file, commandContext) {
402
+ if (!file) return;
403
+ if (!commandContext) return importFile(file, "mesh");
404
+ try {
405
+ const { asset, height, footprint } = await importMeshFile(file);
406
+ await persistMeshAsset(asset);
407
+ const object = createMeshObject(
408
+ { assetId: asset.id, height, footprint, name: meshNameFromFile(asset.name) },
409
+ domain.read(),
410
+ placementInFrontOfShot(),
411
+ );
412
+ const imported = publishImported(object, commandContext);
413
+ appContext.notify(
414
+ isKo
415
+ ? `${object.name} 추가됨 — 실제 높이(m)를 입력하면 크기가 맞습니다`
416
+ : `${object.name} added — type its real height in metres to set the scale`,
417
+ );
418
+ return imported;
419
+ } catch (error) {
420
+ throw new StudioProtocolError(error.code ?? "INVALID_ARGUMENT", `Could not import that model: ${error.message}`);
421
+ }
422
+ }
423
+
424
+ async function importMeshes(files) {
425
+ for (const file of files) await importMesh(file);
426
+ }
427
+
428
+ /**
429
+ * Stand an already-stored GLB up as a fresh instance. The shelf drop does
430
+ * not keep a previous object's size: it re-reads the blob and fits once,
431
+ * the same as a first import, because there is no prior record to copy.
432
+ */
433
+ async function spawnMeshAt(assetId, placement, commandContext) {
434
+ if (!commandContext) return importStoredAsset(assetId, "mesh", placement);
435
+ appContext.shared.markCraftAction("object");
436
+ const record = await assetRecord(assetId);
437
+ if (!record) throw new StudioProtocolError("TARGET_NOT_READY", "That model is no longer stored.");
438
+ const compressed = compressedGlbReason(record.bytes);
439
+ if (compressed) throw new StudioProtocolError("INVALID_ARGUMENT", compressed);
440
+ const bounds = meshBoundsFromAsset(record);
441
+ const fitted = bounds ? fitMeshBounds(bounds) : null;
442
+ if (!fitted) throw new StudioProtocolError("INVALID_ARGUMENT", "That model has no measurable geometry.");
443
+ const object = createMeshObject(
444
+ {
445
+ assetId: record.id,
446
+ height: fitted.height,
447
+ footprint: fitted.footprint,
448
+ name: meshNameFromFile(record.name),
449
+ },
450
+ domain.read(),
451
+ placement,
452
+ );
453
+ const imported = publishImported(object, commandContext);
454
+ appContext.notify(
455
+ isKo
456
+ ? `${object.name} 추가됨 — 실제 높이(m)를 입력하면 크기가 맞습니다`
457
+ : `${object.name} added — type its real height in metres to set the scale`,
458
+ );
459
+ return imported;
460
+ }
461
+
462
+ /**
463
+ * Apply what the background editor is showing.
464
+ *
465
+ * Nothing is destroyed. The card keeps three things: the photograph it was
466
+ * imported from, the purple someone painted on it, and the cut picture the
467
+ * set actually renders — so the next edit starts from the original with the
468
+ * selection still on it, however many times it is re-cut.
469
+ *
470
+ * Trimming the dead margin changes how much of the frame the subject fills,
471
+ * so the card's height is scaled with it. The scale is stored rather than
472
+ * multiplied in, or a second cut would compound one trim onto the last.
473
+ */
474
+ async function applyMatte(id = selectedSceneObjectId, commandContext) {
475
+ if (!commandContext) return run("object.matte", { objectId: id });
476
+ const object = domain.read().find((item) => item.id === id) ?? null;
477
+ const options = appContext.shared.matteEditorRef.current?.options();
478
+ // Nothing purple means nothing was asked for. Removing "the background"
479
+ // on a picture nobody has marked would be a guess applied to their set.
480
+ if (!object || object.renderer !== CUTOUT_KIND || !options || matteBusy) throw new StudioProtocolError("TARGET_NOT_READY", "Select a cutout with a painted matte first.");
481
+ setMatteBusy(true);
482
+ try {
483
+ const sourceId = object.sourceAssetId || object.assetId;
484
+ const source = await assetRecord(sourceId);
485
+ if (!source) throw new Error(ko("its picture is missing from the store", "저장소에 사진이 없습니다"));
486
+ const [cut, matte] = await Promise.all([
487
+ cutOutBackground(source, { mask: options.mask, shrink: matteShrink, feather: matteFeather }),
488
+ maskAsset(options.mask, { width: options.maskWidth, height: options.maskHeight, name: `${source.name || "cutout"} matte` }),
489
+ ]);
490
+ await Promise.all([
491
+ rememberAsset({ ...cut.asset, role: "derived" }),
492
+ rememberAsset({ ...matte, role: "derived" }),
493
+ ]);
494
+ const fullFrameHeight = object.height / (object.matteScale || 1);
495
+ commandContext.commit(() => domain.write(objects => updateSceneObject(objects, object.id, {
496
+ assetId: cut.asset.id,
497
+ sourceAssetId: source.id,
498
+ matteAssetId: matte.id,
499
+ matteScale: cut.heightScale,
500
+ aspect: cut.asset.width / cut.asset.height,
501
+ height: fullFrameHeight * cut.heightScale,
502
+ })));
503
+ appContext.notify(
504
+ isKo
505
+ ? `${object.name} 배경 제거 — ${Math.round(cut.removed * 100)}% 지움. 원본과 칠한 영역은 그대로 남습니다`
506
+ : `${object.name} — ${Math.round(cut.removed * 100)}% removed. The original and your selection are kept`,
507
+ );
508
+ return { objectId: object.id };
509
+ } catch (error) {
510
+ throw new StudioProtocolError(error.code ?? "TARGET_NOT_READY", `Could not remove the background: ${error.message}`);
511
+ } finally {
512
+ setMatteBusy(false);
513
+ }
514
+ }
515
+
516
+ function duplicateSelectedSceneObject(id = selectedSceneObjectId) {
517
+ // Defaults to the selection (Ctrl/Cmd+D); the hierarchy context menu
518
+ // passes a specific row's id. Same result either way: the copy is
519
+ // selected, offset one grid step, and toasted.
520
+ const objects = domain.read();
521
+ const object = objects.find((item) => item.id === id) ?? null;
522
+ if (!object) return;
523
+ const placement = { x: object.x, z: object.z, rot: object.rot };
524
+ // A cutout cannot be minted from the catalogue — it needs the picture the
525
+ // original is already wearing — so the copy is created through its own
526
+ // door and shares the asset rather than importing it twice.
527
+ const copy = object.renderer === CUTOUT_KIND
528
+ ? createCutoutObject(duplicateCutoutOptions(object), objects, placement)
529
+ : object.renderer === MESH_KIND
530
+ ? createMeshObject(duplicateMeshOptions(object), objects, placement)
531
+ : createSceneObject(object.renderer, objects, placement);
532
+ if (!copy) return;
533
+ // Unity drops the duplicate exactly on top of the original; for blocking,
534
+ // one grid step to the side means you can see that it worked.
535
+ const placed = { ...object, id: copy.id, name: copy.name, x: object.x + 0.5 };
536
+ domain.write((objects) => [...objects, placed]);
537
+ appContext.shared.setSelectedHierarchyId(`object:${placed.id}`);
538
+ appContext.notify((isKo, ko) => isKo ? `${sceneObjectNameDisplayKo(placed.name)} 복제됨` : `${placed.name} duplicated`);
539
+ }
540
+
541
+ function frameSelection(id = selectedSceneObjectId) {
542
+ const object = sceneObjects.find((item) => item.id === id) ?? null;
543
+ if (!object) return;
544
+ const size = objectSize(object);
545
+ appContext.shared.frameWorldTarget(
546
+ { x: object.x, y: (object.y ?? 0) + size.height / 2, z: object.z },
547
+ Math.max(size.width, size.height, size.depth, 0.5),
548
+ );
549
+ }
550
+
551
+ /** In-place rename commit from the hierarchy (F2 / Return / rename on
552
+ * create). The row label lives in the tree; the object name is shared
553
+ * state, so this is just the inspector's rename through another door. */
554
+ function renameSceneObject(id, name) {
555
+ return run("object.rename", { id, name });
556
+ }
557
+
558
+ /** The prop's live world matrix, falling back to its authored numbers while
559
+ * it is unattached (those ARE world) and the set has not mounted it yet. */
560
+ function sceneObjectWorldMatrix(object) {
561
+ return appContext.shared.propWorldRef.current?.(object.id, attachWorldMatrix)
562
+ ?? ((object.attach ?? null) ? null : sceneObjectMatrix(object, attachWorldMatrix));
563
+ }
564
+
565
+ /** The attachment a hierarchy row offers, or null when the row is not a
566
+ * frame. A character row means the whole body's animated root; a bone row
567
+ * means that one frame. Bone rows are namespaced per character (#76), so
568
+ * the row itself names whose frame it is. */
569
+ function attachTargetForRow(rowId) {
570
+ const charId = appContext.shared.charIdFromHierarchyId(rowId);
571
+ if (charId) return appContext.shared.characters.some((entry) => entry.id === charId) ? { characterId: charId, bone: null } : null;
572
+ const rig = parseRigNodeId(rowId);
573
+ const owner = rig ? appContext.shared.charIdFromHierarchyId(rig.rowId) : null;
574
+ const bone = rig ? ATTACH_BONE_ROWS.get(rig.token) : null;
575
+ if (!bone || !owner || !appContext.shared.characters.some((entry) => entry.id === owner)) return null;
576
+ return { characterId: owner, bone };
577
+ }
578
+
579
+ /** "Character 1 · Right Hand" — the same words the rows the user dropped on
580
+ * carry, so the Inspector names the target the way the tree does. */
581
+ function attachTargetLabel(attach) {
582
+ const index = appContext.shared.characters.findIndex((entry) => entry.id === attach.characterId);
583
+ const who = index < 0
584
+ ? ko("Missing character", "없는 인물")
585
+ : index === 0
586
+ ? ko("Character 1", "인물 1")
587
+ : index === 1
588
+ ? ko("Character 2", "인물 2")
589
+ : isKo ? `인물 ${index + 1}` : `Character ${index + 1}`;
590
+ const bone = attach.bone
591
+ ? HIERARCHY_INSPECTOR_TITLES[`rig.${attach.bone}`] ?? attach.bone
592
+ : ko("Root", "루트");
593
+ return `${who} · ${bone}`;
594
+ }
595
+
596
+ /** Carry a prop on a character's root (`bone` null) or one of its bones, or
597
+ * put it back in the world with `attach` null — the Hierarchy's character,
598
+ * bone and Props drops, the Inspector's Detach and run_action
599
+ * object.attach/detach. */
600
+ function attachSceneObject(id, attach) {
601
+ const object = storeRef.current.objects.find((entry) => entry.id === id);
602
+ if (!object) throw new StudioProtocolError("STALE_TARGET", `Object ${id} is not in this scene.`);
603
+ if (attach) appContext.shared.castMemberOf(attach.characterId);
604
+ // Where the prop is on screen right now, expressed in the frame it is
605
+ // joining (or left as world when it joins none). ONE conversion, whether
606
+ // the prop is coming from the world or from another frame.
607
+ const shown = appContext.shared.animatedSceneObjects.find((entry) => entry.id === id) ?? object;
608
+ const placement = attachPlacementPatch(sceneObjectWorldMatrix(shown), attach, appContext.shared.attachFrameRef.current);
609
+ // A placement that could not be computed refuses the attachment, not just
610
+ // the numbers: attaching without converting would silently reinterpret the
611
+ // old frame's numbers in the new frame, which is the jump itself.
612
+ if (!placement) {
613
+ throw new StudioProtocolError("TARGET_NOT_READY", attach
614
+ ? `The ${attach.bone ?? "root"} frame of character ${attach.characterId} is not on stage (its rig has not loaded).`
615
+ : `${object.name || id} is not on stage, so where it is now cannot be read.`);
616
+ }
617
+ // ONE atomic: a single undo puts back both the field and the numbers.
618
+ storeRef.current.applyAtomic((objects) => {
619
+ let next = setSceneObjectAttach(objects, id, attach);
620
+ // Back to the world means "world-anchored again", which drops the
621
+ // grouping parent too — attach and parent are the same slot.
622
+ if (attach === null) next = setSceneObjectParent(next, id, null);
623
+ if (next === objects) return objects;
624
+ return placeSceneObject(next, id, placement);
625
+ });
626
+ }
627
+ async function importCommandAsset(args, commandContext) {
628
+ const IMPORT_BACKDROP_DISTANCE_M = 12, IMPORT_BACKDROP_HEIGHT_M = 5;
629
+ if (typeof args.name !== "string" || !args.name.trim()) throw new Error("Invalid name");
630
+ if (args.placeAs === "mesh") {
631
+ const dataUrl = args.dataUrl;
632
+ if (typeof dataUrl !== "string") throw new Error("dataUrl must be a 3D model data URL");
633
+ const nameLower = String(args.name).toLowerCase();
634
+ const headerMime = dataUrl.slice(5, dataUrl.search(/[;,]/)).toLowerCase();
635
+ const mime = (typeof args.mimeType === "string" && args.mimeType
636
+ ? args.mimeType
637
+ : headerMime).toLowerCase();
638
+ const objPlain = mime === "text/plain" && nameLower.endsWith(".obj");
639
+ const fbxPlain = mime === "text/plain" && nameLower.endsWith(".fbx");
640
+ const headerOk = dataUrl.startsWith("data:model/gltf-binary")
641
+ || dataUrl.startsWith("data:application/octet-stream")
642
+ || dataUrl.startsWith("data:model/obj")
643
+ || dataUrl.startsWith("data:model/fbx")
644
+ || (dataUrl.startsWith("data:text/plain") && (nameLower.endsWith(".obj") || nameLower.endsWith(".fbx")));
645
+ const mimeOk = mime === "model/gltf-binary" || mime === "application/octet-stream"
646
+ || mime === "model/obj" || mime === "model/fbx" || objPlain || fbxPlain;
647
+ if (!headerOk && !mimeOk) throw new Error("dataUrl must be a 3D model data URL");
648
+ const bytes = await (await fetch(dataUrl)).arrayBuffer();
649
+ const fileType = mime || headerMime || "application/octet-stream";
650
+ const file = new File([bytes], args.name, { type: fileType });
651
+ const { asset, height, footprint } = await importMeshFile(file);
652
+ const db = await openAssetDb();
653
+ try {
654
+ await putAsset(db, asset);
655
+ } finally {
656
+ db.close?.();
657
+ }
658
+ const live = appContext.live.state;
659
+ const camera = appContext.shared.shotCamRef.current;
660
+ const hasFloor = Number.isFinite(args.x) || Number.isFinite(args.z);
661
+ const placement = hasFloor
662
+ ? {
663
+ x: Number.isFinite(args.x) ? args.x : 0,
664
+ z: Number.isFinite(args.z) ? args.z : 0,
665
+ }
666
+ : camera
667
+ ? placementInFront({ x: camera.position.x, z: camera.position.z }, appContext.shared.look.current.yaw)
668
+ : {};
669
+ if (Number.isFinite(args.rot)) placement.rot = args.rot;
670
+ let object = createMeshObject(
671
+ {
672
+ assetId: asset.id,
673
+ height,
674
+ footprint,
675
+ name: args.name,
676
+ clay: args.clay === true,
677
+ },
678
+ live.objects,
679
+ placement,
680
+ );
681
+ if (!object) throw new Error("Could not create the mesh object");
682
+ // Inspector height edits scale the stored footprint. Do the same
683
+ // here so a 50 cm import is a smaller cube, not a squat 1×1×0.5 box.
684
+ if (Number.isFinite(args.height) && args.height > 0) {
685
+ object = updateSceneObject([object], object.id, { height: args.height })[0];
686
+ }
687
+ if (Number.isFinite(args.y)) object.y = args.y;
688
+ commandContext.commit(() => domain.write((objects) => [...objects, object]));
689
+ return { assetId: asset.id, objectId: object.id };
690
+ }
691
+ if (args.placeAs !== "cutout" && args.placeAs !== "backdrop") throw new Error('placeAs must be "cutout", "backdrop" or "mesh"');
692
+ if (typeof args.dataUrl !== "string" || !args.dataUrl.startsWith("data:image/")) throw new Error("dataUrl must be an image data URL");
693
+ const mime = typeof args.mimeType === "string" && args.mimeType
694
+ ? args.mimeType
695
+ : args.dataUrl.slice(5, args.dataUrl.search(/[;,]/));
696
+ const bytes = await (await fetch(args.dataUrl)).arrayBuffer();
697
+ const file = new File([bytes], args.name, { type: mime });
698
+ const live = appContext.live.state;
699
+ const asset = await rememberAsset(await importImageFile(file));
700
+ const backdrop = args.placeAs === "backdrop";
701
+ const camera = appContext.shared.shotCamRef.current;
702
+ const placement = camera
703
+ ? placementInFront(
704
+ { x: camera.position.x, z: camera.position.z },
705
+ appContext.shared.look.current.yaw,
706
+ backdrop ? IMPORT_BACKDROP_DISTANCE_M : undefined,
707
+ )
708
+ : {};
709
+ if (backdrop) {
710
+ // The card's face is its +z; rotate by the camera's own yaw so the
711
+ // plate faces the lens instead of standing edge-on to it.
712
+ placement.rot = (appContext.shared.look.current.yaw * 180) / Math.PI;
713
+ }
714
+ const object = createCutoutObject(
715
+ {
716
+ assetId: asset.id,
717
+ aspect: assetAspect(asset) ?? 1,
718
+ height: backdrop ? IMPORT_BACKDROP_HEIGHT_M : CUTOUT_DEFAULT_HEIGHT,
719
+ name: args.name,
720
+ },
721
+ live.objects,
722
+ placement,
723
+ );
724
+ if (!object) throw new Error("Could not create the cutout object");
725
+ commandContext.commit(() => domain.write((objects) => [...objects, object]));
726
+ return { assetId: asset.id, objectId: object.id };
727
+ }
728
+ function applyObjectBatch(args) {
729
+ let batchObjects = domain.read();
730
+ const finitePatch = (args, fields) => Object.fromEntries(fields.filter(field => args[field] !== undefined).map(field => {
731
+ if (!Number.isFinite(args[field])) throw new Error(`Invalid ${field}`);
732
+ return [field, args[field]];
733
+ }));
734
+ function syncObjects() { appContext.patchLive({ objects: storeRef.current.objects }); }
735
+ function applyObjectMutation(mutation) { batchObjects = mutation(batchObjects); }
736
+ const liveObjects = () => ({ ...appContext.live.state, objects: batchObjects });
737
+ function placeObject(args) {
738
+ if (typeof args.kind !== "string") throw new Error("Invalid kind");
739
+ const live = liveObjects();
740
+ // The parent is checked before anything is created: a bad id must
741
+ // not leave a half-made part lying around unattached.
742
+ if (args.parent !== undefined) {
743
+ if (typeof args.parent !== "string" || !live.objects.some((o) => o.id === args.parent)) {
744
+ throw new Error(`Parent object not found: ${args.parent}`);
745
+ }
746
+ }
747
+ if (args.name !== undefined && (typeof args.name !== "string" || !args.name.trim())) {
748
+ throw new Error("Invalid name");
749
+ }
750
+ const placement = finitePatch(args, ["x", "z", "rot"]);
751
+ const object = createSceneObject(args.kind, live.objects, placement);
752
+ if (!object) throw new Error(`Unknown object kind: ${args.kind}`);
753
+ const patch = finitePatch(args, ["y"]);
754
+ if (args.name !== undefined) patch.name = args.name;
755
+ const placed = updateSceneObject([object], object.id, patch)[0];
756
+ // One atomic entry: create, name and attach undo together, as the
757
+ // single "place part" gesture they are to the caller.
758
+ applyObjectMutation((objects) => {
759
+ const next = [...objects, placed];
760
+ return args.parent !== undefined ? setSceneObjectParent(next, placed.id, args.parent) : next;
761
+ });
762
+ return { id: placed.id };
763
+ }
764
+ function updateObject(args) {
765
+ const live = liveObjects();
766
+ if (typeof args.id !== "string" || !live.objects.some((object) => object.id === args.id)) throw new Error("Object not found");
767
+ const patch = finitePatch(args, ["x", "y", "z", "rot", "rotX", "rotZ"]);
768
+ // A uniform `scale` is the common case; per-axis values are what a
769
+ // squashed disc or a stretched column needs, exactly as the
770
+ // inspector's three sliders provide. Per-axis wins when both come.
771
+ if (args.scale !== undefined) {
772
+ if (!Number.isFinite(args.scale)) throw new Error("Invalid scale");
773
+ patch.scaleX = args.scale;
774
+ patch.scaleY = args.scale;
775
+ patch.scaleZ = args.scale;
776
+ }
777
+ Object.assign(patch, finitePatch(args, ["scaleX", "scaleY", "scaleZ"]));
778
+ if (args.color !== undefined) {
779
+ if (typeof args.color !== "string") throw new Error("Invalid color");
780
+ patch.color = args.color;
781
+ }
782
+ if (args.name !== undefined) {
783
+ if (typeof args.name !== "string" || !args.name.trim()) throw new Error("Invalid name");
784
+ patch.name = args.name;
785
+ }
786
+ // A travel path arrives whole (or null to clear it); the object
787
+ // schema repairs or refuses it, so a bad route cannot land.
788
+ if (args.path !== undefined) {
789
+ if (args.path !== null && createObjectPath(args.path) === null) throw new Error("Invalid path: needs two or more distinct points");
790
+ patch.path = args.path;
791
+ }
792
+ if (Number.isFinite(args.height)) patch.height = args.height;
793
+ if (typeof args.clay === "boolean") patch.clay = args.clay;
794
+ if (typeof args.hidden === "boolean") patch.hidden = args.hidden;
795
+ applyObjectMutation((objects) => updateSceneObject(objects, args.id, patch));
796
+ return { id: args.id };
797
+ }
798
+ function removeObject(args) {
799
+ const live = liveObjects();
800
+ if (typeof args.id !== "string" || !live.objects.some((object) => object.id === args.id)) throw new Error("Object not found");
801
+ applyObjectMutation((objects) => removeSceneObject(objects, args.id));
802
+ return { id: args.id };
803
+ }
804
+ function groupObjects(args) {
805
+ const live = liveObjects();
806
+ if (typeof args.parent !== "string" || !live.objects.some((o) => o.id === args.parent)) {
807
+ throw new Error("Parent object not found");
808
+ }
809
+ if (!Array.isArray(args.children) || !args.children.length) throw new Error("No children given");
810
+ for (const child of args.children) {
811
+ if (!live.objects.some((o) => o.id === child)) throw new Error(`Object not found: ${child}`);
812
+ }
813
+ applyObjectMutation((objects) =>
814
+ args.children.reduce((acc, child) => setSceneObjectParent(acc, child, args.parent), objects),
815
+ );
816
+ return { parent: args.parent, children: args.children.length };
817
+ }
818
+ function ungroupObjects(args) {
819
+ const live = liveObjects();
820
+ if (!Array.isArray(args.children) || !args.children.length) throw new Error("No children given");
821
+ for (const child of args.children) {
822
+ if (!live.objects.some((o) => o.id === child)) throw new Error(`Object not found: ${child}`);
823
+ }
824
+ applyObjectMutation((objects) =>
825
+ args.children.reduce((acc, child) => setSceneObjectParent(acc, child, null), objects),
826
+ );
827
+ return { children: args.children.length };
828
+ }
829
+ function executeBatch(args) {
830
+ if (!Array.isArray(args.ops)) throw new Error("Invalid batch operations");
831
+ if (args.ops.length > 100) throw new Error("A batch may contain at most 100 operations");
832
+ if (args.atomic !== undefined && typeof args.atomic !== "boolean") throw new Error("Invalid atomic flag");
833
+ if (args.stopOnError !== undefined && typeof args.stopOnError !== "boolean") throw new Error("Invalid stopOnError flag");
834
+ if (args.label !== undefined && (typeof args.label !== "string" || !args.label.trim())) throw new Error("Invalid batch label");
835
+ const objectCommands = new Set(["place_object", "update_object", "remove_object", "group_objects", "ungroup_objects"]);
836
+ for (const operation of args.ops) {
837
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) throw new Error("Invalid batch operation");
838
+ if (operation.name === "apply_batch") throw new Error("Nested batches are not supported");
839
+ if (!objectCommands.has(operation.name)) {
840
+ throw new Error("Batch v1 supports object mutations only; character mutations are not supported");
841
+ }
842
+ if (!operation.args || typeof operation.args !== "object" || Array.isArray(operation.args)) throw new Error("Invalid batch operation arguments");
843
+ }
844
+ const atomic = args.atomic === true;
845
+ const stopOnError = args.stopOnError !== false;
846
+ batchObjects = domain.read();
847
+ const applied = [];
848
+ const failed = [];
849
+ let rolledBack = false;
850
+ try {
851
+ for (const [index, operation] of args.ops.entries()) {
852
+ try {
853
+ handlers[operation.name](operation.args);
854
+ applied.push(index + 1);
855
+ } catch (error) {
856
+ failed.push({ index: index + 1, error: error instanceof Error ? error.message : "Command failed" });
857
+ if (stopOnError) break;
858
+ }
859
+ }
860
+ rolledBack = atomic && failed.length > 0;
861
+ if (!rolledBack) domain.write(batchObjects);
862
+ } finally {
863
+ batchObjects = null;
864
+ }
865
+ syncObjects();
866
+ return { label: args.label?.trim() || "MCP batch", applied, failed, rolledBack };
867
+ }
868
+ const handlers = { place_object: placeObject, update_object: updateObject, remove_object: removeObject, group_objects: groupObjects, ungroup_objects: ungroupObjects };
869
+ return executeBatch(args);
870
+ }
871
+ domain.batch = applyObjectBatch;
872
+ function canReparentSceneObject(sourceRowId, targetRowId) {
873
+ const id = sceneObjectIdFromHierarchy(String(sourceRowId ?? ""));
874
+ if (!id || sourceRowId === targetRowId) return false;
875
+ const object = sceneObjects.find((entry) => entry.id === id);
876
+ if (!object) return false;
877
+ const targetObjectId = sceneObjectIdFromHierarchy(String(targetRowId ?? ""));
878
+ // Grouping keeps its own rules (self, cycles, unknown ids) — asking the
879
+ // store is the only way to stay honest about them.
880
+ if (targetObjectId) return setSceneObjectParent(sceneObjects, id, targetObjectId) !== sceneObjects;
881
+ if (targetRowId === "props") return (object.attach ?? null) !== null || (object.parent ?? null) !== null;
882
+ const attach = attachTargetForRow(targetRowId);
883
+ if (!attach) return false;
884
+ const current = object.attach ?? null;
885
+ return !current || current.characterId !== attach.characterId || (current.bone ?? null) !== attach.bone;
886
+ }
887
+ function reparentSceneObject(sourceRowId, targetRowId) {
888
+ if (!canReparentSceneObject(sourceRowId, targetRowId)) return;
889
+ const id = sceneObjectIdFromHierarchy(String(sourceRowId));
890
+ const targetObjectId = sceneObjectIdFromHierarchy(String(targetRowId));
891
+ // Grouping moves nothing on screen — the set places every prop at its
892
+ // own absolute transform. Taking a parent DOES cancel an attachment
893
+ // (the store's exclusivity rule), so a carried prop dropped into a
894
+ // group comes back to world numbers on the way, exactly as the Props
895
+ // row would put it back.
896
+ if (targetObjectId) return run("object.group", { parent: targetObjectId, children: [id] });
897
+ const attach = targetRowId === "props" ? null : attachTargetForRow(targetRowId);
898
+ appContext.shared.runStudioAction(attach ? "object.attach" : "object.detach", attach
899
+ ? { objectId: id, characterId: attach.characterId, ...(attach.bone ? { bone: attach.bone } : {}) }
900
+ : { objectId: id });
901
+ }
902
+ domain.group = (parent, children) => {
903
+ const placements = new Map(children.map(id => {
904
+ const object = domain.read().find(row => row.id === id);
905
+ const shown = appContext.shared.animatedSceneObjects.find(row => row.id === id) ?? object;
906
+ const placement = object.attach ? attachPlacementPatch(sceneObjectWorldMatrix(shown), null, appContext.shared.attachFrameRef.current) : null;
907
+ if (object.attach && !placement) throw new StudioProtocolError("TARGET_NOT_READY", `Object ${id} is not on stage.`);
908
+ return [id, placement];
909
+ }));
910
+ domain.write(objects => children.reduce((rows, id) => placeSceneObject(setSceneObjectParent(rows, id, parent), id, placements.get(id)), objects));
911
+ };
912
+ function settleObjects() { return storeRef.current.settle(); }
913
+ function beginStudioObjectAction() { return storeRef.current.beginCommand(); }
914
+ function stepObjectHistory(redo) { return (redo ? storeRef.current.redo : storeRef.current.undo)(); }
915
+ function applyExternalObjects(objects) {
916
+ return run("objects.replace", { objects: Array.isArray(objects) ? objects : [] });
917
+ }
918
+
919
+ return {
920
+ ...domain, run, beginStudioObjectAction, stepObjectHistory, applyExternalObjects,
921
+ canReparentSceneObject, reparentSceneObject, settleObjects,
922
+ recentObjectColors, objectColorDraft, setObjectColorDraft, rememberSceneObjectColor, objectDeleteUndo,
923
+ setObjectDeleteUndo, sceneObjects, storeRef, store, selectedSceneObjectId,
924
+ selectedSceneObject, beginSceneTransaction, endSceneTransaction, changeSceneObject,
925
+ deleteSelectedSceneObject, deleteSceneObject, dropSelectedSceneObject, matteTolerance, setMatteTolerance,
926
+ matteBrush, setMatteBrush, matteShrink, setMatteShrink, matteFeather, setMatteFeather, matteMode,
927
+ setMatteMode, matteStats, setMatteStats, matteBusy, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled,
928
+ addSceneObject, importCutout, importCutouts, spawnCutoutAt, persistMeshAsset, importMesh, importMeshes,
929
+ spawnMeshAt, applyMatte, duplicateSelectedSceneObject, frameSelection, renameSceneObject,
930
+ sceneObjectWorldMatrix, attachTargetForRow, attachTargetLabel, attachSceneObject,
931
+ };
932
+ }