cozyclay 1.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/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { buildHierarchyNodes } from "./hierarchy-model.js";
|
|
3
|
+
import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
|
|
4
|
+
import AddObjectMenu, { CatalogueEntries } from "./object-catalog.jsx";
|
|
5
|
+
|
|
6
|
+
function indexParents(nodes, parent = null, parents = new Map()) {
|
|
7
|
+
for (const node of nodes) {
|
|
8
|
+
if (parent) parents.set(node.id, parent);
|
|
9
|
+
if (node.children) indexParents(node.children, node.id, parents);
|
|
10
|
+
}
|
|
11
|
+
return parents;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Fixed-position menu at the pointer: the object-row actions
|
|
15
|
+
* (Rename / Duplicate / Delete / Frame) or the create catalogue, matching
|
|
16
|
+
* Unity's Hierarchy right-click menu (docs/unity-reference.md §9.7).
|
|
17
|
+
* Closes on Escape, on any outside mousedown, and after a pick. */
|
|
18
|
+
function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
|
|
19
|
+
const rootRef = useRef(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!menu) return undefined;
|
|
23
|
+
const onDocDown = (event) => {
|
|
24
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) onClose();
|
|
25
|
+
};
|
|
26
|
+
const onKey = (event) => {
|
|
27
|
+
if (event.key !== "Escape") return;
|
|
28
|
+
// Capture phase + stopImmediatePropagation: closing the menu must
|
|
29
|
+
// not also run the app's window-level Escape (clear selection).
|
|
30
|
+
event.stopImmediatePropagation();
|
|
31
|
+
onClose();
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener("mousedown", onDocDown);
|
|
34
|
+
window.addEventListener("keydown", onKey, true);
|
|
35
|
+
return () => {
|
|
36
|
+
document.removeEventListener("mousedown", onDocDown);
|
|
37
|
+
window.removeEventListener("keydown", onKey, true);
|
|
38
|
+
};
|
|
39
|
+
}, [menu, onClose]);
|
|
40
|
+
|
|
41
|
+
if (!menu) return null;
|
|
42
|
+
// Keep the menu inside the window; `height` is an upper bound per kind,
|
|
43
|
+
// so a short menu never sits below the pointer that opened it.
|
|
44
|
+
const style = {
|
|
45
|
+
left: Math.max(4, Math.min(menu.x, window.innerWidth - 244)),
|
|
46
|
+
top: Math.max(4, Math.min(menu.y, window.innerHeight - menu.height)),
|
|
47
|
+
};
|
|
48
|
+
return (
|
|
49
|
+
<div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
|
|
50
|
+
{menu.kind === "object" ? (
|
|
51
|
+
<>
|
|
52
|
+
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("rename", menu.id)}>
|
|
53
|
+
Rename
|
|
54
|
+
</button>
|
|
55
|
+
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("duplicate", menu.id)}>
|
|
56
|
+
Duplicate
|
|
57
|
+
</button>
|
|
58
|
+
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("delete", menu.id)}>
|
|
59
|
+
Delete
|
|
60
|
+
</button>
|
|
61
|
+
<button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("frame", menu.id)}>
|
|
62
|
+
Frame
|
|
63
|
+
</button>
|
|
64
|
+
</>
|
|
65
|
+
) : (
|
|
66
|
+
<CatalogueEntries onPick={onAddObject} />
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function TreeRow({
|
|
73
|
+
node,
|
|
74
|
+
depth,
|
|
75
|
+
selectedId,
|
|
76
|
+
expanded,
|
|
77
|
+
onSelect,
|
|
78
|
+
onToggle,
|
|
79
|
+
badge,
|
|
80
|
+
status,
|
|
81
|
+
editing,
|
|
82
|
+
onRenameCommit,
|
|
83
|
+
onRenameCancel,
|
|
84
|
+
onRowContextMenu,
|
|
85
|
+
}) {
|
|
86
|
+
const branch = !!node.children?.length;
|
|
87
|
+
const rowWrapRef = useRef(null);
|
|
88
|
+
const inputRef = useRef(null);
|
|
89
|
+
// A commit/cancel may race the blur that follows the input unmounting;
|
|
90
|
+
// the flag ends the edit session exactly once.
|
|
91
|
+
const doneRef = useRef(false);
|
|
92
|
+
|
|
93
|
+
const finish = () => {
|
|
94
|
+
if (doneRef.current) return;
|
|
95
|
+
doneRef.current = true;
|
|
96
|
+
const name = (inputRef.current?.value ?? "").trim();
|
|
97
|
+
if (name) onRenameCommit(name);
|
|
98
|
+
else onRenameCancel(); // an empty name is a cancel, like Unity
|
|
99
|
+
rowWrapRef.current?.focus();
|
|
100
|
+
};
|
|
101
|
+
const cancel = () => {
|
|
102
|
+
if (doneRef.current) return;
|
|
103
|
+
doneRef.current = true;
|
|
104
|
+
onRenameCancel();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={rowWrapRef}
|
|
110
|
+
className={"hierarchy-row-wrap" + (selectedId === node.id ? " selected" : "")}
|
|
111
|
+
style={{ "--hierarchy-depth": depth }}
|
|
112
|
+
data-node-id={node.id}
|
|
113
|
+
role="treeitem"
|
|
114
|
+
tabIndex={-1}
|
|
115
|
+
aria-selected={selectedId === node.id}
|
|
116
|
+
aria-expanded={branch ? expanded : undefined}
|
|
117
|
+
onContextMenu={(event) => onRowContextMenu(event, node.id)}
|
|
118
|
+
>
|
|
119
|
+
{branch ? (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className="hierarchy-toggle"
|
|
123
|
+
aria-label={`${expanded ? "Collapse" : "Expand"} ${node.label}`}
|
|
124
|
+
onClick={() => onToggle(node.id)}
|
|
125
|
+
>
|
|
126
|
+
{expanded ? "▾" : "▸"}
|
|
127
|
+
</button>
|
|
128
|
+
) : (
|
|
129
|
+
<span className="hierarchy-toggle placeholder" />
|
|
130
|
+
)}
|
|
131
|
+
{editing ? (
|
|
132
|
+
// In-place rename, Unity-style: Enter commits, Escape reverts,
|
|
133
|
+
// blur commits (docs/unity-reference.md §9.7). A div, not the
|
|
134
|
+
// row button — an input inside a button is invalid HTML.
|
|
135
|
+
<div className="hierarchy-row">
|
|
136
|
+
<span className={`hierarchy-icon ${node.kind}`} aria-hidden="true" />
|
|
137
|
+
<input
|
|
138
|
+
ref={inputRef}
|
|
139
|
+
className="hierarchy-rename-input"
|
|
140
|
+
defaultValue={node.label}
|
|
141
|
+
aria-label={`Rename ${node.label}`}
|
|
142
|
+
autoFocus
|
|
143
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
144
|
+
onKeyDown={(event) => {
|
|
145
|
+
if (event.key === "Enter") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
event.stopPropagation(); // keep the tree hotkey quiet
|
|
148
|
+
finish();
|
|
149
|
+
} else if (event.key === "Escape") {
|
|
150
|
+
event.stopPropagation(); // revert only, not clear-selection
|
|
151
|
+
cancel();
|
|
152
|
+
}
|
|
153
|
+
}}
|
|
154
|
+
onBlur={finish}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<button type="button" className="hierarchy-row" onClick={() => onSelect(node.id)}>
|
|
159
|
+
<span className={`hierarchy-icon ${node.kind}`} aria-hidden="true" />
|
|
160
|
+
<span className="hierarchy-label">{node.label}</span>
|
|
161
|
+
{status && <span className="hierarchy-status">{status}</span>}
|
|
162
|
+
{badge !== null && badge !== undefined && badge !== 0 && <span className="hierarchy-badge">{badge}</span>}
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default function HierarchyPanel({
|
|
170
|
+
selectedId,
|
|
171
|
+
onSelect,
|
|
172
|
+
showB,
|
|
173
|
+
motionFrames,
|
|
174
|
+
promptCount,
|
|
175
|
+
ikFrames,
|
|
176
|
+
ikMode,
|
|
177
|
+
waypointCount,
|
|
178
|
+
sceneObjects = [],
|
|
179
|
+
onAddObject,
|
|
180
|
+
onRenameObject,
|
|
181
|
+
onDuplicateObject,
|
|
182
|
+
onDeleteObject,
|
|
183
|
+
onFrameObject,
|
|
184
|
+
}) {
|
|
185
|
+
const [expanded, setExpanded] = useState(() => new Set(["shot", "characters", "characterA", "characterA.motion"]));
|
|
186
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
187
|
+
// Row currently in in-place rename. The panel owns it: F2/Return and the
|
|
188
|
+
// row context menu are the only ways in, so app state stays out of it.
|
|
189
|
+
const [editingId, setEditingId] = useState(null);
|
|
190
|
+
const treeRef = useRef(null);
|
|
191
|
+
const lastTreeSelectRef = useRef(null);
|
|
192
|
+
const firstRenderRef = useRef(true);
|
|
193
|
+
const pendingScrollRef = useRef(false);
|
|
194
|
+
const hierarchyNodes = useMemo(() => buildHierarchyNodes(sceneObjects), [sceneObjects]);
|
|
195
|
+
const parents = useMemo(() => indexParents(hierarchyNodes), [hierarchyNodes]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
setExpanded((current) => {
|
|
199
|
+
const next = new Set(current);
|
|
200
|
+
let id = selectedId;
|
|
201
|
+
while (parents.has(id)) {
|
|
202
|
+
id = parents.get(id);
|
|
203
|
+
next.add(id);
|
|
204
|
+
}
|
|
205
|
+
if (ikMode) {
|
|
206
|
+
next.add("characterA");
|
|
207
|
+
next.add("characterA.rig");
|
|
208
|
+
}
|
|
209
|
+
return next;
|
|
210
|
+
});
|
|
211
|
+
}, [ikMode, parents, selectedId]);
|
|
212
|
+
|
|
213
|
+
// Selection made outside the tree (viewport click, plan board, inspector)
|
|
214
|
+
// scrolls the row into view (docs/unity-reference.md §9.6). Tree-originated
|
|
215
|
+
// selections skip this — the browser already scrolls the clicked row. The
|
|
216
|
+
// tab may be hidden at selection time (selecting switches to the inspector),
|
|
217
|
+
// so a hidden-tree selection is remembered and scrolled once the tree shows.
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (firstRenderRef.current) {
|
|
220
|
+
firstRenderRef.current = false;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (lastTreeSelectRef.current !== selectedId) pendingScrollRef.current = true;
|
|
224
|
+
}, [selectedId]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const tree = treeRef.current;
|
|
228
|
+
if (!tree || tree.offsetParent === null) return;
|
|
229
|
+
if (!pendingScrollRef.current) return;
|
|
230
|
+
pendingScrollRef.current = false;
|
|
231
|
+
if (!selectedId) return;
|
|
232
|
+
const row = tree.querySelector(`[data-node-id="${CSS.escape(selectedId)}"]`);
|
|
233
|
+
row?.scrollIntoView({ block: "nearest" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const toggle = (id) => {
|
|
237
|
+
setExpanded((current) => {
|
|
238
|
+
const next = new Set(current);
|
|
239
|
+
if (next.has(id)) next.delete(id);
|
|
240
|
+
else next.add(id);
|
|
241
|
+
return next;
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
const badgeFor = (id) => {
|
|
245
|
+
if (id === "characters") return showB ? 2 : 1;
|
|
246
|
+
if (id === "characterA.motion" || id === "characterA.baseMotion") return motionFrames ? `${motionFrames}f` : "empty";
|
|
247
|
+
if (id === "characterA.promptBlocks") return promptCount;
|
|
248
|
+
if (id === "characterA.ik") return ikFrames;
|
|
249
|
+
if (id === "rootPath") return waypointCount;
|
|
250
|
+
if (id === "props") return sceneObjects.length;
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
const statusFor = (id) => {
|
|
254
|
+
if (id === "characterA" && ikMode) return "IK ON";
|
|
255
|
+
return null;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const handleSelect = (id) => {
|
|
259
|
+
lastTreeSelectRef.current = id;
|
|
260
|
+
onSelect(id);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const openRowMenu = (event, id) => {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
event.stopPropagation(); // a row pick must not also open the create menu
|
|
266
|
+
if (sceneObjectIdFromHierarchy(id) !== null) {
|
|
267
|
+
setContextMenu({ x: event.clientX, y: event.clientY, height: 148, kind: "object", id });
|
|
268
|
+
} else {
|
|
269
|
+
setContextMenu({ x: event.clientX, y: event.clientY, height: 344, kind: "create" });
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const openCreateMenu = (event) => {
|
|
274
|
+
event.preventDefault(); // suppress the browser menu on the tree only
|
|
275
|
+
setContextMenu({ x: event.clientX, y: event.clientY, height: 344, kind: "create" });
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const handleMenuAction = (action, hierarchyId) => {
|
|
279
|
+
setContextMenu(null);
|
|
280
|
+
if (action === "rename") {
|
|
281
|
+
setEditingId(hierarchyId);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const objectId = sceneObjectIdFromHierarchy(hierarchyId);
|
|
285
|
+
if (!objectId) return;
|
|
286
|
+
if (action === "duplicate") onDuplicateObject?.(objectId);
|
|
287
|
+
else if (action === "delete") onDeleteObject?.(objectId);
|
|
288
|
+
else if (action === "frame") onFrameObject?.(objectId);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const commitRename = (hierarchyId, name) => {
|
|
292
|
+
setEditingId(null);
|
|
293
|
+
const objectId = sceneObjectIdFromHierarchy(hierarchyId);
|
|
294
|
+
if (objectId) onRenameObject?.(objectId, name);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const cancelRename = () => {
|
|
298
|
+
setEditingId(null);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// F2 / Return rename the selected row in place while focus is in the
|
|
302
|
+
// tree (docs/unity-reference.md §9.2). The rename input stops its own
|
|
303
|
+
// Enter/Escape, so an active edit never re-triggers this.
|
|
304
|
+
const onTreeKeyDown = (event) => {
|
|
305
|
+
if (editingId) return;
|
|
306
|
+
if (event.key !== "F2" && event.key !== "Enter") return;
|
|
307
|
+
if (sceneObjectIdFromHierarchy(selectedId) === null) return;
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
setEditingId(selectedId);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const renderNodes = (nodes, depth = 0) =>
|
|
313
|
+
nodes.flatMap((node) => {
|
|
314
|
+
if (node.optional === "showB" && !showB) return [];
|
|
315
|
+
const open = expanded.has(node.id);
|
|
316
|
+
return [
|
|
317
|
+
<TreeRow
|
|
318
|
+
key={node.id}
|
|
319
|
+
node={node}
|
|
320
|
+
depth={depth}
|
|
321
|
+
selectedId={selectedId}
|
|
322
|
+
expanded={open}
|
|
323
|
+
onSelect={handleSelect}
|
|
324
|
+
onToggle={toggle}
|
|
325
|
+
badge={badgeFor(node.id)}
|
|
326
|
+
status={statusFor(node.id)}
|
|
327
|
+
editing={editingId === node.id}
|
|
328
|
+
onRenameCommit={(name) => commitRename(node.id, name)}
|
|
329
|
+
onRenameCancel={cancelRename}
|
|
330
|
+
onRowContextMenu={openRowMenu}
|
|
331
|
+
/>,
|
|
332
|
+
...(node.children && open ? renderNodes(node.children, depth + 1) : []),
|
|
333
|
+
];
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<section className="hierarchy-pane" aria-label="Shot hierarchy">
|
|
338
|
+
<div className="hierarchy-heading">
|
|
339
|
+
<div>
|
|
340
|
+
<span className="hierarchy-kicker">Hierarchy</span>
|
|
341
|
+
<strong>Shot structure</strong>
|
|
342
|
+
</div>
|
|
343
|
+
<span className="hierarchy-frame-status">{motionFrames ? `${motionFrames} frames` : "Blocking"}</span>
|
|
344
|
+
</div>
|
|
345
|
+
{onAddObject && (
|
|
346
|
+
<div className="hierarchy-toolbar">
|
|
347
|
+
<AddObjectMenu onAdd={onAddObject} />
|
|
348
|
+
<span className="hierarchy-frame-status">{motionFrames ? `${motionFrames} frames` : "Blocking"}</span>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
<div className="hierarchy-tree" role="tree" ref={treeRef} onKeyDown={onTreeKeyDown} onContextMenu={openCreateMenu}>
|
|
352
|
+
{renderNodes(hierarchyNodes)}
|
|
353
|
+
</div>
|
|
354
|
+
<RowContextMenu
|
|
355
|
+
menu={contextMenu}
|
|
356
|
+
onClose={() => setContextMenu(null)}
|
|
357
|
+
onAction={handleMenuAction}
|
|
358
|
+
onAddObject={(kind) => {
|
|
359
|
+
setContextMenu(null);
|
|
360
|
+
onAddObject?.(kind);
|
|
361
|
+
}}
|
|
362
|
+
/>
|
|
363
|
+
</section>
|
|
364
|
+
);
|
|
365
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Pure undo/redo stack and transaction coordinator. No scene knowledge and
|
|
2
|
+
// no React — node-importable so the transition invariants are unit-testable.
|
|
3
|
+
|
|
4
|
+
export const HISTORY_LIMIT = 50;
|
|
5
|
+
|
|
6
|
+
export function createHistory(present) {
|
|
7
|
+
return { past: [], present, future: [] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// `pushHistory` takes the NEW present. The reference-equality short-circuit
|
|
11
|
+
// is the coalescing primitive: `updateSceneObject` already returns the same
|
|
12
|
+
// array when nothing changed, so a no-op drag or a patch aimed at a deleted
|
|
13
|
+
// id can never create an entry.
|
|
14
|
+
export function pushHistory(history, next) {
|
|
15
|
+
if (next === history.present) return history;
|
|
16
|
+
const past = [...history.past, history.present];
|
|
17
|
+
if (past.length > HISTORY_LIMIT) past.shift();
|
|
18
|
+
return { past, present: next, future: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function undoHistory(history) {
|
|
22
|
+
if (history.past.length === 0) return null;
|
|
23
|
+
const present = history.past[history.past.length - 1];
|
|
24
|
+
return {
|
|
25
|
+
past: history.past.slice(0, -1),
|
|
26
|
+
present,
|
|
27
|
+
future: [history.present, ...history.future],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function redoHistory(history) {
|
|
32
|
+
if (history.future.length === 0) return null;
|
|
33
|
+
const [present, ...future] = history.future;
|
|
34
|
+
return {
|
|
35
|
+
past: [...history.past, history.present],
|
|
36
|
+
present,
|
|
37
|
+
future,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function canUndo(history) {
|
|
42
|
+
return history.past.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function canRedo(history) {
|
|
46
|
+
return history.future.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- tx ----
|
|
50
|
+
//
|
|
51
|
+
// `cancel` is teardown-only by contract: it must not call back into the
|
|
52
|
+
// coordinator (`endTransaction`/`settleTransaction`). The coordinator owns
|
|
53
|
+
// the close — it retires the token BEFORE running `cancel`, so a close
|
|
54
|
+
// attempted from inside `cancel` is inert (unit-asserted).
|
|
55
|
+
|
|
56
|
+
export function createTransactions() {
|
|
57
|
+
return { current: null, nextToken: 1 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Settles any already-open transaction first: the previous owner's cancel
|
|
61
|
+
// runs and the close is owned by the coordinator, not by the new begin.
|
|
62
|
+
export function beginTransaction(tx, { owner, cancel }) {
|
|
63
|
+
if (tx.current !== null) settleTransaction(tx);
|
|
64
|
+
const token = tx.nextToken;
|
|
65
|
+
tx.nextToken += 1;
|
|
66
|
+
tx.current = { token, owner, cancel };
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isCurrentTransaction(tx, token) {
|
|
71
|
+
return tx.current !== null && tx.current.token === token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// False for a stale token — a resumed pointer stream after a settle is
|
|
75
|
+
// inert by construction rather than by hope.
|
|
76
|
+
export function endTransaction(tx, token) {
|
|
77
|
+
if (!isCurrentTransaction(tx, token)) return false;
|
|
78
|
+
tx.current = null;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Retires the token before running cancel, so a close attempted from
|
|
83
|
+
// inside cancel is inert; returns what the settle closed.
|
|
84
|
+
export function settleTransaction(tx) {
|
|
85
|
+
if (tx.current === null) return null;
|
|
86
|
+
const current = tx.current;
|
|
87
|
+
tx.current = null;
|
|
88
|
+
if (typeof current.cancel === "function") current.cancel();
|
|
89
|
+
return { token: current.token, owner: current.owner };
|
|
90
|
+
}
|
package/src/main.jsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { OBJECT_LIBRARY } from "./scene-objects.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Add object" — the hierarchy's create menu, Unity's GameObject > 3D Object
|
|
6
|
+
* in one popover. Grouped by catalogue section; picking an entry drops the
|
|
7
|
+
* object in front of the camera and hands selection to the caller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The catalogue grouped by section. Shared with the hierarchy's right-click
|
|
11
|
+
* create menu so both entry points list the same objects
|
|
12
|
+
* (docs/unity-reference.md §9.7). */
|
|
13
|
+
export function buildObjectMenuGroups() {
|
|
14
|
+
const groups = [];
|
|
15
|
+
for (const entry of OBJECT_LIBRARY) {
|
|
16
|
+
const group = groups.find((item) => item.name === entry.group);
|
|
17
|
+
if (group) group.entries.push(entry);
|
|
18
|
+
else groups.push({ name: entry.group, entries: [entry] });
|
|
19
|
+
}
|
|
20
|
+
return groups;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One rendered entry list; the toolbar popover and the right-click create
|
|
24
|
+
* menu both use it so the two can never drift apart. */
|
|
25
|
+
export function CatalogueEntries({ onPick }) {
|
|
26
|
+
return buildObjectMenuGroups().map((group) => (
|
|
27
|
+
<div className="add-object-group" key={group.name}>
|
|
28
|
+
<span className="add-object-heading">{group.name}</span>
|
|
29
|
+
{group.entries.map((entry) => (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
role="menuitem"
|
|
33
|
+
key={entry.kind}
|
|
34
|
+
className="add-object-item"
|
|
35
|
+
onClick={() => onPick(entry.kind)}
|
|
36
|
+
>
|
|
37
|
+
<span className={`add-object-swatch ${entry.kind}`} style={{ background: entry.color }} aria-hidden="true" />
|
|
38
|
+
<span>{entry.label}</span>
|
|
39
|
+
<small>{entry.footprint.width} × {entry.footprint.depth} m</small>
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function AddObjectMenu({ onAdd, label = "Add object" }) {
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const rootRef = useRef(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) return undefined;
|
|
52
|
+
const onDocDown = (event) => {
|
|
53
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
|
|
54
|
+
};
|
|
55
|
+
const onKey = (event) => {
|
|
56
|
+
if (event.key === "Escape") setOpen(false);
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener("mousedown", onDocDown);
|
|
59
|
+
window.addEventListener("keydown", onKey);
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener("mousedown", onDocDown);
|
|
62
|
+
window.removeEventListener("keydown", onKey);
|
|
63
|
+
};
|
|
64
|
+
}, [open]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={"add-object" + (open ? " open" : "")} ref={rootRef}>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="add-object-trigger"
|
|
71
|
+
aria-expanded={open}
|
|
72
|
+
aria-haspopup="menu"
|
|
73
|
+
onClick={() => setOpen((value) => !value)}
|
|
74
|
+
>
|
|
75
|
+
<span aria-hidden="true">+</span>
|
|
76
|
+
{label}
|
|
77
|
+
</button>
|
|
78
|
+
{open && (
|
|
79
|
+
<div className="add-object-menu" role="menu">
|
|
80
|
+
<CatalogueEntries
|
|
81
|
+
onPick={(kind) => {
|
|
82
|
+
onAdd(kind);
|
|
83
|
+
setOpen(false);
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|