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.
- package/CHANGELOG.md +171 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/LIVE-PROTOCOL.md +1 -1
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/otio.js +2 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scene-objects.js +37 -1
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +460 -1742
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
|
@@ -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
|
+
}
|