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.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. 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,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App.jsx";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("root")).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -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
+ }