@jgengine/editor 0.11.0 → 0.12.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 (169) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -0
  3. package/dist/AssetBrowser.d.ts +26 -1
  4. package/dist/AssetBrowser.js +54 -1
  5. package/dist/AssetDropZone.d.ts +9 -0
  6. package/dist/AssetDropZone.js +65 -0
  7. package/dist/CatalogsPanel.d.ts +4 -2
  8. package/dist/CatalogsPanel.js +162 -9
  9. package/dist/CityPreview.d.ts +11 -0
  10. package/dist/CityPreview.js +45 -0
  11. package/dist/CollectionsPanel.js +5 -5
  12. package/dist/DebugDraw.d.ts +3 -1
  13. package/dist/DebugDraw.js +37 -19
  14. package/dist/EditorApp.d.ts +9 -1
  15. package/dist/EditorApp.js +147 -35
  16. package/dist/EditorChrome.d.ts +25 -6
  17. package/dist/EditorChrome.js +310 -130
  18. package/dist/InspectorPanel.d.ts +17 -7
  19. package/dist/InspectorPanel.js +173 -27
  20. package/dist/LightingPanel.d.ts +11 -0
  21. package/dist/LightingPanel.js +99 -0
  22. package/dist/MaterialsWorkspacePanel.d.ts +12 -0
  23. package/dist/MaterialsWorkspacePanel.js +86 -0
  24. package/dist/ParentPickerMenu.d.ts +13 -0
  25. package/dist/ParentPickerMenu.js +33 -0
  26. package/dist/PerfProbe.js +33 -0
  27. package/dist/PrefabsPanel.js +5 -5
  28. package/dist/RuntimePlayBridge.d.ts +5 -3
  29. package/dist/RuntimePlayBridge.js +6 -13
  30. package/dist/ScatterPreview.d.ts +3 -1
  31. package/dist/ScatterPreview.js +10 -7
  32. package/dist/SchemaInspector.js +21 -8
  33. package/dist/ScriptingPanel.d.ts +15 -0
  34. package/dist/ScriptingPanel.js +46 -0
  35. package/dist/SelectionGizmo.js +114 -30
  36. package/dist/StandaloneEditor.d.ts +16 -9
  37. package/dist/StandaloneEditor.js +19 -20
  38. package/dist/TerrainPanel.d.ts +3 -2
  39. package/dist/TerrainPanel.js +16 -5
  40. package/dist/TerrainSculpt.js +98 -59
  41. package/dist/agent/AgentPanel.d.ts +5 -2
  42. package/dist/agent/AgentPanel.js +11 -6
  43. package/dist/agent/toolBridge.js +5 -0
  44. package/dist/assetImport.d.ts +36 -0
  45. package/dist/assetImport.js +66 -0
  46. package/dist/authoredComponentMeta.d.ts +25 -0
  47. package/dist/authoredComponentMeta.js +65 -0
  48. package/dist/gizmoPivot.d.ts +4 -0
  49. package/dist/gizmoPivot.js +34 -0
  50. package/dist/handlers/catalogs.d.ts +3 -0
  51. package/dist/handlers/catalogs.js +137 -0
  52. package/dist/handlers/collections.d.ts +3 -0
  53. package/dist/handlers/collections.js +57 -0
  54. package/dist/handlers/context.d.ts +36 -0
  55. package/dist/handlers/context.js +1 -0
  56. package/dist/handlers/document.d.ts +3 -0
  57. package/dist/handlers/document.js +350 -0
  58. package/dist/handlers/grids.d.ts +3 -0
  59. package/dist/handlers/grids.js +142 -0
  60. package/dist/handlers/hierarchy.d.ts +3 -0
  61. package/dist/handlers/hierarchy.js +55 -0
  62. package/dist/handlers/index.d.ts +8 -0
  63. package/dist/handlers/index.js +23 -0
  64. package/dist/handlers/minimap.d.ts +9 -0
  65. package/dist/handlers/minimap.js +32 -0
  66. package/dist/handlers/runtime.d.ts +3 -0
  67. package/dist/handlers/runtime.js +191 -0
  68. package/dist/handlers/terrain.d.ts +3 -0
  69. package/dist/handlers/terrain.js +204 -0
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.js +3 -2
  72. package/dist/listDocumentTriggers.d.ts +42 -0
  73. package/dist/listDocumentTriggers.js +49 -0
  74. package/dist/materialAssignments.d.ts +63 -0
  75. package/dist/materialAssignments.js +84 -0
  76. package/dist/mcp/fieldSpec.d.ts +32 -0
  77. package/dist/mcp/fieldSpec.js +173 -0
  78. package/dist/mcp/rpcRequest.d.ts +8 -5
  79. package/dist/mcp/rpcRequest.js +72 -93
  80. package/dist/mcp/tools.js +104 -912
  81. package/dist/networkSnapshot.d.ts +82 -0
  82. package/dist/networkSnapshot.js +73 -0
  83. package/dist/parentCandidates.d.ts +12 -0
  84. package/dist/parentCandidates.js +25 -0
  85. package/dist/session.d.ts +77 -4
  86. package/dist/session.js +36 -1017
  87. package/dist/shell/AnimationPanel.d.ts +17 -0
  88. package/dist/shell/AnimationPanel.js +134 -0
  89. package/dist/shell/AssetThumbnail.d.ts +12 -0
  90. package/dist/shell/AssetThumbnail.js +23 -0
  91. package/dist/shell/BottomDock.d.ts +25 -0
  92. package/dist/shell/BottomDock.js +40 -0
  93. package/dist/shell/CommandPalette.d.ts +12 -0
  94. package/dist/shell/CommandPalette.js +56 -0
  95. package/dist/shell/ConsolePanel.d.ts +8 -0
  96. package/dist/shell/ConsolePanel.js +45 -0
  97. package/dist/shell/ContentBrowser.d.ts +21 -0
  98. package/dist/shell/ContentBrowser.js +130 -0
  99. package/dist/shell/HierarchyPanel.d.ts +26 -0
  100. package/dist/shell/HierarchyPanel.js +387 -0
  101. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  102. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  103. package/dist/shell/PlayModeBar.d.ts +12 -0
  104. package/dist/shell/PlayModeBar.js +27 -0
  105. package/dist/shell/ProfilerPanel.d.ts +10 -0
  106. package/dist/shell/ProfilerPanel.js +41 -0
  107. package/dist/shell/SceneToolbar.d.ts +43 -0
  108. package/dist/shell/SceneToolbar.js +122 -0
  109. package/dist/shell/StatusBar.d.ts +18 -0
  110. package/dist/shell/StatusBar.js +41 -0
  111. package/dist/shell/TopAppBar.d.ts +33 -0
  112. package/dist/shell/TopAppBar.js +64 -0
  113. package/dist/shell/ViewportOverlays.d.ts +23 -0
  114. package/dist/shell/ViewportOverlays.js +136 -0
  115. package/dist/shell/WorkspaceRail.d.ts +20 -0
  116. package/dist/shell/WorkspaceRail.js +31 -0
  117. package/dist/shell/cameraTelemetry.d.ts +27 -0
  118. package/dist/shell/cameraTelemetry.js +24 -0
  119. package/dist/shell/commandRegistry.d.ts +58 -0
  120. package/dist/shell/commandRegistry.js +133 -0
  121. package/dist/shell/consoleSink.d.ts +11 -0
  122. package/dist/shell/consoleSink.js +17 -0
  123. package/dist/shell/consoleStore.d.ts +27 -0
  124. package/dist/shell/consoleStore.js +43 -0
  125. package/dist/shell/fields.d.ts +39 -0
  126. package/dist/shell/fields.js +104 -0
  127. package/dist/shell/formatSavedRelative.d.ts +2 -0
  128. package/dist/shell/formatSavedRelative.js +14 -0
  129. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  130. package/dist/shell/glbThumbnailCache.js +241 -0
  131. package/dist/shell/hierarchyA11y.d.ts +21 -0
  132. package/dist/shell/hierarchyA11y.js +35 -0
  133. package/dist/shell/icons.d.ts +13 -0
  134. package/dist/shell/icons.js +85 -0
  135. package/dist/shell/layoutStore.d.ts +68 -0
  136. package/dist/shell/layoutStore.js +156 -0
  137. package/dist/shell/pathFlythrough.d.ts +37 -0
  138. package/dist/shell/pathFlythrough.js +54 -0
  139. package/dist/shell/perfHistory.d.ts +70 -0
  140. package/dist/shell/perfHistory.js +130 -0
  141. package/dist/shell/renameObject.d.ts +3 -0
  142. package/dist/shell/renameObject.js +25 -0
  143. package/dist/shell/theme.d.ts +36 -0
  144. package/dist/shell/theme.js +37 -0
  145. package/dist/shell/ui.d.ts +84 -0
  146. package/dist/shell/ui.js +90 -0
  147. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  148. package/dist/shell/useGlbThumbnail.js +19 -0
  149. package/dist/uiStore.d.ts +27 -0
  150. package/dist/uiStore.js +10 -0
  151. package/dist/viewportContextMenu.d.ts +4 -2
  152. package/dist/viewportContextMenu.js +5 -1
  153. package/package.json +4 -4
  154. package/skills/jgengine/SKILL.md +7 -4
  155. package/skills/jgengine/api.md +172 -79
  156. package/skills/jgengine/capabilities.md +104 -2
  157. package/skills/jgengine/recipes/minimal-game.md +88 -0
  158. package/skills/jgengine-editor/SKILL.md +18 -3
  159. package/skills/jgengine-editor/api.md +397 -18
  160. package/skills/jgengine-editor/capabilities.md +46 -0
  161. package/skills/jgengine-editor/reference.md +87 -0
  162. package/skills/level-design/SKILL.md +54 -0
  163. package/skills/level-design/agents/openai.yaml +4 -0
  164. package/skills/level-design/references/application-playbook.md +192 -0
  165. package/skills/level-design/references/field-guide.md +337 -0
  166. package/skills/level-design/references/live-editor-loop.md +179 -0
  167. package/dist/OutlinerPanel.d.ts +0 -14
  168. package/dist/OutlinerPanel.js +0 -84
  169. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,387 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
+ import { isEditorObjectCollectionLocked, isEditorObjectHidden, isEditorObjectLocked, isEditorObjectSelfLocked, } from "@jgengine/core/editor/index";
4
+ import { MATERIAL_DRAG_MIME } from "../AssetBrowser.js";
5
+ import { flattenOutliner } from "../outlinerModel.js";
6
+ import { shallowArrayEqual, useStoreSelector, virtualWindow } from "../useStoreSelector.js";
7
+ import { hierarchyActiveDescendantId, hierarchyRowDomId, selectableIds, } from "./hierarchyA11y.js";
8
+ import { Icon, kindIcon } from "./icons.js";
9
+ import { renameEditorObject } from "./renameObject.js";
10
+ import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
11
+ import { IconButton, Segmented } from "./ui.js";
12
+ export { hierarchyActiveDescendantId, hierarchyRowDomId, selectableIds, } from "./hierarchyA11y.js";
13
+ const ROW_HEIGHT = 26;
14
+ /** HTML5 drag payload for hierarchy reparenting via `set_parent`. */
15
+ export const OBJECT_DRAG_MIME = "application/x-jgengine-editor-object";
16
+ function dataTransferHas(types, mime) {
17
+ return types.includes(mime);
18
+ }
19
+ /**
20
+ * Mouse activation of row chrome must not steal focus from the tree container — focus stays on
21
+ * the list for roving tabindex / `aria-activedescendant`. Rename inputs still take focus.
22
+ */
23
+ function preventFocusSteal(event) {
24
+ const target = event.target;
25
+ if (target instanceof HTMLElement && target.closest("input") !== null)
26
+ return;
27
+ event.preventDefault();
28
+ }
29
+ /**
30
+ * Redesigned world outliner: searchable, virtualized, kind-iconed rows generated from the live
31
+ * document. Group headers carry real per-kind visibility toggles (the editor's layer system);
32
+ * rows expose per-object eye/lock toggles (`setObjectFlags` → document `hidden`/`locked`) and show
33
+ * a lock affordance when a locked collection also owns the id. Tree view supports drag-and-drop
34
+ * reparenting through the existing `set_parent` RPC. Keyboard navigation uses a single tab stop
35
+ * on the list (roving active row via `aria-activedescendant`; arrows / Home / End / Enter / F2),
36
+ * double-click rename, and row context menus (frame / duplicate / delete / prefab / parent to… /
37
+ * unparent) are supported. Selector-subscribed and memoized, so UI-only churn never rerenders it.
38
+ */
39
+ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd, onRowContextMenu, }) {
40
+ const document = useStoreSelector(session, (state) => state.document);
41
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
42
+ const selectedId = selection[0];
43
+ const [view, setView] = useState("kind");
44
+ const [query, setQuery] = useState("");
45
+ const [collapsedKinds, setCollapsedKinds] = useState({});
46
+ const [collapsedNodes, setCollapsedNodes] = useState({});
47
+ const [visibilityTick, setVisibilityTick] = useState(0);
48
+ useEffect(() => api.subscribeVisibility(() => setVisibilityTick((value) => value + 1)), [api]);
49
+ void visibilityTick;
50
+ const visibility = api.getVisibility();
51
+ const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
52
+ const total = document.markers.length + document.volumes.length + document.paths.length + document.annotations.length;
53
+ const scrollRef = useRef(null);
54
+ const [scrollTop, setScrollTop] = useState(0);
55
+ const [viewportHeight, setViewportHeight] = useState(320);
56
+ useEffect(() => {
57
+ const element = scrollRef.current;
58
+ if (element === null)
59
+ return;
60
+ const measure = () => setViewportHeight(element.clientHeight);
61
+ measure();
62
+ const observer = new ResizeObserver(measure);
63
+ observer.observe(element);
64
+ return () => observer.disconnect();
65
+ }, []);
66
+ const win = virtualWindow(scrollTop, viewportHeight, ROW_HEIGHT, rows.length);
67
+ const visible = rows.slice(win.start, win.end);
68
+ const selectRow = (id, additive) => {
69
+ if (additive) {
70
+ const current = session.getState().selection;
71
+ const next = current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id];
72
+ session.dispatch({ type: "select", ids: next });
73
+ return;
74
+ }
75
+ session.dispatch({ type: "select", ids: [id] });
76
+ api.handle({ method: "camera_goto", id });
77
+ };
78
+ const navigableIds = useMemo(() => selectableIds(rows), [rows]);
79
+ const [activeId, setActiveId] = useState(null);
80
+ const [renamingId, setRenamingId] = useState(null);
81
+ const [renameDraft, setRenameDraft] = useState("");
82
+ const renameInputRef = useRef(null);
83
+ useEffect(() => {
84
+ if (activeId !== null && !navigableIds.includes(activeId))
85
+ setActiveId(navigableIds[0] ?? null);
86
+ }, [activeId, navigableIds]);
87
+ useEffect(() => {
88
+ if (renamingId !== null)
89
+ renameInputRef.current?.focus();
90
+ }, [renamingId]);
91
+ const focusTree = () => {
92
+ scrollRef.current?.focus({ preventScroll: true });
93
+ };
94
+ const scrollRowIntoView = (id) => {
95
+ const rowIndex = rows.findIndex((row) => (row.type === "kindItem" && row.ids[0] === id) || (row.type === "treeItem" && row.id === id));
96
+ if (rowIndex < 0 || scrollRef.current === null)
97
+ return;
98
+ const top = rowIndex * ROW_HEIGHT;
99
+ const bottom = top + ROW_HEIGHT;
100
+ const viewTop = scrollRef.current.scrollTop;
101
+ const viewBottom = viewTop + scrollRef.current.clientHeight;
102
+ if (top < viewTop)
103
+ scrollRef.current.scrollTop = top;
104
+ else if (bottom > viewBottom)
105
+ scrollRef.current.scrollTop = bottom - scrollRef.current.clientHeight;
106
+ };
107
+ const activateId = (id, additive) => {
108
+ setActiveId(id);
109
+ selectRow(id, additive);
110
+ scrollRowIntoView(id);
111
+ };
112
+ const beginRename = (id, label) => {
113
+ setRenamingId(id);
114
+ setRenameDraft(label);
115
+ setActiveId(id);
116
+ };
117
+ const endRename = () => {
118
+ setRenamingId(null);
119
+ // Restore the single tree tab stop after the rename input unmounts.
120
+ queueMicrotask(focusTree);
121
+ };
122
+ const commitRename = () => {
123
+ if (renamingId === null)
124
+ return;
125
+ renameEditorObject(session, renamingId, renameDraft);
126
+ endRename();
127
+ };
128
+ const cancelRename = () => endRename();
129
+ const onListFocus = () => {
130
+ if (renamingId !== null)
131
+ return;
132
+ if (activeId !== null && navigableIds.includes(activeId))
133
+ return;
134
+ const seed = selectedId !== undefined && navigableIds.includes(selectedId)
135
+ ? selectedId
136
+ : (navigableIds[0] ?? null);
137
+ if (seed !== null)
138
+ setActiveId(seed);
139
+ };
140
+ const onListKeyDown = (event) => {
141
+ if (renamingId !== null)
142
+ return;
143
+ if (navigableIds.length === 0)
144
+ return;
145
+ const currentIndex = activeId === null ? -1 : navigableIds.indexOf(activeId);
146
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
147
+ event.preventDefault();
148
+ const delta = event.key === "ArrowDown" ? 1 : -1;
149
+ const nextIndex = currentIndex < 0
150
+ ? event.key === "ArrowDown"
151
+ ? 0
152
+ : navigableIds.length - 1
153
+ : Math.max(0, Math.min(navigableIds.length - 1, currentIndex + delta));
154
+ activateId(navigableIds[nextIndex], event.shiftKey);
155
+ return;
156
+ }
157
+ if (event.key === "Home") {
158
+ event.preventDefault();
159
+ activateId(navigableIds[0], event.shiftKey);
160
+ return;
161
+ }
162
+ if (event.key === "End") {
163
+ event.preventDefault();
164
+ activateId(navigableIds[navigableIds.length - 1], event.shiftKey);
165
+ return;
166
+ }
167
+ if (event.key === "Enter" && activeId !== null) {
168
+ event.preventDefault();
169
+ api.handle({ method: "camera_goto", id: activeId });
170
+ return;
171
+ }
172
+ if (event.key === "F2" && activeId !== null) {
173
+ event.preventDefault();
174
+ const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === activeId) ||
175
+ (entry.type === "treeItem" && entry.id === activeId));
176
+ if (row !== undefined && (row.type === "kindItem" || row.type === "treeItem")) {
177
+ beginRename(activeId, row.label);
178
+ }
179
+ }
180
+ };
181
+ const [materialDropTarget, setMaterialDropTarget] = useState(null);
182
+ const [reparentDropTarget, setReparentDropTarget] = useState(null);
183
+ const [draggingObjectId, setDraggingObjectId] = useState(null);
184
+ const acceptMaterialDrop = (event, _ids, key) => {
185
+ if (!dataTransferHas(event.dataTransfer.types, MATERIAL_DRAG_MIME))
186
+ return false;
187
+ event.preventDefault();
188
+ event.dataTransfer.dropEffect = "copy";
189
+ if (materialDropTarget !== key)
190
+ setMaterialDropTarget(key);
191
+ return true;
192
+ };
193
+ const commitMaterialDrop = (event, ids) => {
194
+ const materialId = event.dataTransfer.getData(MATERIAL_DRAG_MIME);
195
+ if (materialId.length === 0)
196
+ return false;
197
+ event.preventDefault();
198
+ setMaterialDropTarget(null);
199
+ api.handle({ method: "assign_material", ids: [...ids], materialId });
200
+ return true;
201
+ };
202
+ const acceptReparentDrop = (event, key) => {
203
+ if (!dataTransferHas(event.dataTransfer.types, OBJECT_DRAG_MIME))
204
+ return false;
205
+ if (dataTransferHas(event.dataTransfer.types, MATERIAL_DRAG_MIME))
206
+ return false;
207
+ event.preventDefault();
208
+ event.stopPropagation();
209
+ event.dataTransfer.dropEffect = "move";
210
+ if (reparentDropTarget !== key)
211
+ setReparentDropTarget(key);
212
+ return true;
213
+ };
214
+ const commitReparentDrop = (event, parentId) => {
215
+ const objectId = event.dataTransfer.getData(OBJECT_DRAG_MIME);
216
+ if (objectId.length === 0)
217
+ return false;
218
+ event.preventDefault();
219
+ event.stopPropagation();
220
+ setReparentDropTarget(null);
221
+ setDraggingObjectId(null);
222
+ if (parentId === objectId)
223
+ return true;
224
+ api.handle({ method: "set_parent", ids: [objectId], parentId });
225
+ return true;
226
+ };
227
+ const rowLocked = (ids) => ids.some((id) => isEditorObjectLocked(document, id));
228
+ const rowHidden = (ids) => ids.some((id) => isEditorObjectHidden(document, id));
229
+ const toggleHidden = (ids) => {
230
+ const nextHidden = !rowHidden(ids);
231
+ session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { hidden: nextHidden } });
232
+ };
233
+ /**
234
+ * Toggle object-level lock. When the object is only locked via a collection, do not silently
235
+ * invent an object lock — leave collection locks to the Collections panel.
236
+ */
237
+ const toggleLocked = (ids) => {
238
+ const anySelfLocked = ids.some((id) => isEditorObjectSelfLocked(document, id));
239
+ if (anySelfLocked) {
240
+ session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: false } });
241
+ return;
242
+ }
243
+ const onlyCollection = ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
244
+ if (onlyCollection)
245
+ return;
246
+ session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: true } });
247
+ };
248
+ const activeDescendant = hierarchyActiveDescendantId(activeId, navigableIds);
249
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.06] p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx(Icon, { name: "search", size: 12, className: "pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search\u2026", "aria-label": "Search scene objects", className: `h-7 w-full pl-7 pr-2 ${INPUT_CLS}` })] }), _jsx(IconButton, { icon: "plus", label: "Add object\u2026", onClick: onAdd })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Segmented, { ariaLabel: "Outliner grouping", value: view, onChange: setView, options: [
250
+ { value: "kind", label: "By kind", icon: "layers" },
251
+ { value: "tree", label: "Tree", icon: "folder" },
252
+ ] }), _jsxs("span", { className: `ml-auto text-[10px] text-neutral-600 ${NUMERIC}`, children: [total, " objects"] })] }), view === "tree" ? (_jsx("p", { className: "px-0.5 text-[10px] text-neutral-600", children: "Drag rows to reparent \u00B7 drop on empty list to unparent \u00B7 F2 rename" })) : null] }), _jsx("div", { ref: scrollRef, className: `min-h-0 flex-1 overflow-auto px-1.5 py-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-cyan-400/40 ${reparentDropTarget === "__root__" ? "bg-cyan-500/10 ring-1 ring-inset ring-cyan-400/30" : ""}`, tabIndex: 0, role: "tree", "aria-label": "Scene hierarchy", "aria-multiselectable": "true", "aria-activedescendant": activeDescendant, onScroll: (event) => setScrollTop(event.currentTarget.scrollTop), onFocus: onListFocus, onKeyDown: onListKeyDown, onDragOver: view === "tree"
253
+ ? (event) => {
254
+ acceptReparentDrop(event, "__root__");
255
+ }
256
+ : undefined, onDragLeave: view === "tree"
257
+ ? () => setReparentDropTarget((current) => (current === "__root__" ? null : current))
258
+ : undefined, onDrop: view === "tree"
259
+ ? (event) => {
260
+ commitReparentDrop(event, null);
261
+ }
262
+ : undefined, children: rows.length === 0 ? (_jsx("div", { className: "p-4 text-center text-[11px] text-neutral-600", children: query.length > 0 ? "No matching objects." : "No objects yet — use Add or place assets from the Content Browser." })) : (_jsx("div", { style: { height: win.totalHeight, position: "relative" }, role: "none", children: _jsx("div", { style: { transform: `translateY(${win.offsetTop}px)` }, role: "none", children: visible.map((row) => {
263
+ if (row.type === "group") {
264
+ const kindVisible = visibility[row.kind] !== false;
265
+ return (_jsxs("div", { role: "presentation", style: { height: ROW_HEIGHT }, className: "group flex items-center gap-0.5 pr-0.5", children: [_jsxs("button", { type: "button", tabIndex: -1, className: `flex h-full min-w-0 flex-1 items-center gap-1 rounded-[5px] px-1 text-left text-[11px] font-semibold text-neutral-300 transition-colors hover:bg-white/[0.05] ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedKinds((previous) => ({ ...previous, [row.kind]: !(previous[row.kind] === true) })), "aria-expanded": !row.collapsed, children: [_jsx(Icon, { name: row.collapsed ? "chevronRight" : "chevronDown", size: 10, className: "shrink-0 text-neutral-600" }), _jsx(Icon, { name: row.collapsed ? "folder" : "folderOpen", size: 13, className: "shrink-0 text-neutral-500" }), _jsx("span", { className: "truncate", children: row.kind }), _jsx("span", { className: `ml-auto pr-1 text-[10px] font-normal text-neutral-600 ${NUMERIC}`, children: row.total })] }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": kindVisible ? `Hide ${row.kind} layer` : `Show ${row.kind} layer`, "aria-pressed": kindVisible, title: kindVisible ? `Hide ${row.kind} layer` : `Show ${row.kind} layer`, onMouseDown: preventFocusSteal, onClick: () => api.setVisibility({ ...api.getVisibility(), [row.kind]: !kindVisible }), className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-white/[0.08] ${FOCUS_RING} ${kindVisible ? "text-neutral-500 opacity-0 group-hover:opacity-100 focus-visible:opacity-100" : "text-neutral-600"}`, children: _jsx(Icon, { name: kindVisible ? "eye" : "eyeOff", size: 12 }) })] }, row.key));
266
+ }
267
+ if (row.type === "kindItem") {
268
+ const primaryId = row.ids[0];
269
+ const rowSelected = selectedId !== undefined && row.ids.includes(selectedId);
270
+ const cycleIndex = rowSelected ? row.ids.indexOf(selectedId) + 1 : 0;
271
+ const dropActive = materialDropTarget === row.key;
272
+ const locked = rowLocked(row.ids);
273
+ const hidden = rowHidden(row.ids);
274
+ const collectionOnlyLock = locked &&
275
+ row.ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
276
+ const active = activeId === primaryId;
277
+ const renaming = renamingId === primaryId && row.ids.length === 1;
278
+ return (_jsxs("div", { id: hierarchyRowDomId(primaryId), role: "treeitem", "aria-selected": rowSelected, "aria-level": 2, style: { height: ROW_HEIGHT }, className: `group flex w-full items-center gap-0.5 rounded-[5px] pl-6 pr-0.5 text-[11px] transition-colors ${dropActive
279
+ ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
280
+ : rowSelected || active
281
+ ? "bg-cyan-500/15 text-cyan-100 ring-1 ring-inset ring-cyan-400/25"
282
+ : "text-neutral-300 hover:bg-white/[0.05]"} ${hidden ? "opacity-55" : ""}`, onDragOver: (event) => {
283
+ acceptMaterialDrop(event, row.ids, row.key);
284
+ }, onDragLeave: () => setMaterialDropTarget((current) => (current === row.key ? null : current)), onDrop: (event) => {
285
+ commitMaterialDrop(event, row.ids);
286
+ }, children: [_jsxs("button", { type: "button", tabIndex: -1, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] pr-1 text-left ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: (event) => {
287
+ setActiveId(primaryId);
288
+ selectRow(primaryId, event.ctrlKey || event.metaKey || event.shiftKey);
289
+ focusTree();
290
+ }, onDoubleClick: () => {
291
+ if (row.ids.length === 1)
292
+ beginRename(primaryId, row.label);
293
+ }, onContextMenu: (event) => {
294
+ event.preventDefault();
295
+ setActiveId(primaryId);
296
+ onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, primaryId);
297
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
298
+ event.stopPropagation();
299
+ if (event.key === "Enter") {
300
+ event.preventDefault();
301
+ commitRename();
302
+ }
303
+ else if (event.key === "Escape") {
304
+ event.preventDefault();
305
+ cancelRename();
306
+ }
307
+ }, onClick: (event) => event.stopPropagation(), className: `min-w-0 flex-1 rounded-[3px] border border-cyan-400/40 bg-black/40 px-1 py-0 text-[11px] text-neutral-100 outline-none ${FOCUS_RING}`, "aria-label": "Rename object" })) : (_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [row.label, row.ids.length > 1 ? (_jsxs("span", { className: `text-neutral-500 ${NUMERIC}`, children: [" ", "\u00D7", row.ids.length, rowSelected ? ` · ${cycleIndex}/${row.ids.length} · N next` : ""] })) : null] }))] }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": hidden ? "Show object in viewport" : "Hide object in viewport", "aria-pressed": !hidden, title: hidden ? "Show in viewport" : "Hide in viewport", onMouseDown: preventFocusSteal, onClick: (event) => {
308
+ event.stopPropagation();
309
+ toggleHidden(row.ids);
310
+ }, className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-white/[0.08] ${FOCUS_RING} ${hidden
311
+ ? "text-neutral-500"
312
+ : "text-neutral-500 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`, children: _jsx(Icon, { name: hidden ? "eyeOff" : "eye", size: 12 }) }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": collectionOnlyLock
313
+ ? "Locked by collection"
314
+ : locked
315
+ ? "Unlock object"
316
+ : "Lock object", "aria-pressed": locked, title: collectionOnlyLock
317
+ ? "Locked by collection — unlock in Collections panel"
318
+ : locked
319
+ ? "Unlock object"
320
+ : "Lock object", disabled: collectionOnlyLock, onMouseDown: preventFocusSteal, onClick: (event) => {
321
+ event.stopPropagation();
322
+ toggleLocked(row.ids);
323
+ }, className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60 ${FOCUS_RING} ${locked
324
+ ? "text-amber-400/80"
325
+ : "text-neutral-500 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`, children: _jsx(Icon, { name: locked ? "lock" : "unlock", size: 11 }) })] }, row.key));
326
+ }
327
+ const rowSelected = selection.includes(row.id);
328
+ const materialDrop = materialDropTarget === row.key;
329
+ const reparentDrop = reparentDropTarget === row.key && draggingObjectId !== row.id;
330
+ const locked = isEditorObjectLocked(document, row.id);
331
+ const hidden = isEditorObjectHidden(document, row.id);
332
+ const collectionOnlyLock = isEditorObjectCollectionLocked(document, row.id) && !isEditorObjectSelfLocked(document, row.id);
333
+ const dragging = draggingObjectId === row.id;
334
+ const active = activeId === row.id;
335
+ const renaming = renamingId === row.id;
336
+ const expanded = row.hasChildren ? collapsedNodes[row.id] !== true : undefined;
337
+ return (_jsxs("div", { id: hierarchyRowDomId(row.id), role: "treeitem", "aria-selected": rowSelected, "aria-level": row.depth + 1, "aria-expanded": expanded, style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: `group flex items-center ${hidden ? "opacity-55" : ""}`, children: [row.hasChildren ? (_jsx("button", { type: "button", tabIndex: -1, "aria-label": collapsedNodes[row.id] === true ? "Expand children" : "Collapse children", className: `flex h-4 w-4 shrink-0 items-center justify-center text-neutral-500 transition-colors hover:text-neutral-200 ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: _jsx(Icon, { name: collapsedNodes[row.id] === true ? "chevronRight" : "chevronDown", size: 10 }) })) : (_jsx("span", { className: "w-4 shrink-0", "aria-hidden": "true" })), _jsxs("button", { type: "button", tabIndex: -1, draggable: true, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] px-1.5 text-left text-[11px] transition-colors ${FOCUS_RING} ${materialDrop || reparentDrop
338
+ ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
339
+ : rowSelected || active
340
+ ? "bg-cyan-500/15 text-cyan-100 ring-1 ring-inset ring-cyan-400/25"
341
+ : "text-neutral-300 hover:bg-white/[0.05]"} ${dragging ? "opacity-50" : ""}`, onMouseDown: preventFocusSteal, onClick: (event) => {
342
+ setActiveId(row.id);
343
+ selectRow(row.id, event.ctrlKey || event.metaKey || event.shiftKey);
344
+ focusTree();
345
+ }, onDoubleClick: () => beginRename(row.id, row.label), onContextMenu: (event) => {
346
+ event.preventDefault();
347
+ setActiveId(row.id);
348
+ onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, row.id);
349
+ }, title: `${row.id} — drag to reparent · F2 rename · Enter frame · right-click menu`, onDragStart: (event) => {
350
+ event.dataTransfer.setData(OBJECT_DRAG_MIME, row.id);
351
+ event.dataTransfer.effectAllowed = "move";
352
+ setDraggingObjectId(row.id);
353
+ }, onDragEnd: () => {
354
+ setDraggingObjectId(null);
355
+ setReparentDropTarget(null);
356
+ }, onDragOver: (event) => {
357
+ if (acceptMaterialDrop(event, [row.id], row.key))
358
+ return;
359
+ acceptReparentDrop(event, row.key);
360
+ }, onDragLeave: () => {
361
+ setMaterialDropTarget((current) => (current === row.key ? null : current));
362
+ setReparentDropTarget((current) => (current === row.key ? null : current));
363
+ }, onDrop: (event) => {
364
+ if (commitMaterialDrop(event, [row.id]))
365
+ return;
366
+ commitReparentDrop(event, row.id);
367
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
368
+ event.stopPropagation();
369
+ if (event.key === "Enter") {
370
+ event.preventDefault();
371
+ commitRename();
372
+ }
373
+ else if (event.key === "Escape") {
374
+ event.preventDefault();
375
+ cancelRename();
376
+ }
377
+ }, onClick: (event) => event.stopPropagation(), className: `min-w-0 flex-1 rounded-[3px] border border-cyan-400/40 bg-black/40 px-1 py-0 text-[11px] text-neutral-100 outline-none ${FOCUS_RING}`, "aria-label": "Rename object" })) : (_jsx("span", { className: "min-w-0 flex-1 truncate", children: row.label })), _jsx("span", { className: "text-[9px] text-neutral-600", children: row.kind })] }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": hidden ? "Show object in viewport" : "Hide object in viewport", "aria-pressed": !hidden, title: hidden ? "Show in viewport" : "Hide in viewport", onMouseDown: preventFocusSteal, onClick: () => toggleHidden([row.id]), className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-white/[0.08] ${FOCUS_RING} ${hidden
378
+ ? "text-neutral-500"
379
+ : "text-neutral-500 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`, children: _jsx(Icon, { name: hidden ? "eyeOff" : "eye", size: 12 }) }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": collectionOnlyLock ? "Locked by collection" : locked ? "Unlock object" : "Lock object", "aria-pressed": locked, title: collectionOnlyLock
380
+ ? "Locked by collection — unlock in Collections panel"
381
+ : locked
382
+ ? "Unlock object"
383
+ : "Lock object", disabled: collectionOnlyLock, onMouseDown: preventFocusSteal, onClick: () => toggleLocked([row.id]), className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] transition-colors hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60 ${FOCUS_RING} ${locked
384
+ ? "text-amber-400/80"
385
+ : "text-neutral-500 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`, children: _jsx(Icon, { name: locked ? "lock" : "unlock", size: 11 }) })] }, row.key));
386
+ }) }) })) })] }));
387
+ });
@@ -0,0 +1,11 @@
1
+ import { type EditorNetworkSnapshot } from "../networkSnapshot.js";
2
+ /**
3
+ * Network workspace panel: inspects the game's declared multiplayer adapter and optional
4
+ * host-injected presence/session snapshot. Missing presence is an honest empty state — never
5
+ * fabricated actors or latency.
6
+ *
7
+ * @internal — mounted by `EditorChrome` when the Network workspace is active.
8
+ */
9
+ export declare function NetworkWorkspacePanel({ snapshot }: {
10
+ snapshot: EditorNetworkSnapshot;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isNetworkMultiplayerConfigured, } from "../networkSnapshot.js";
3
+ import { MICRO_LABEL, NUMERIC } from "./theme.js";
4
+ import { EmptyState } from "./ui.js";
5
+ function formatCoord(value) {
6
+ return Number.isFinite(value) ? value.toFixed(1) : "—";
7
+ }
8
+ function formatSeen(lastSeenAt, nowMs) {
9
+ if (lastSeenAt === undefined)
10
+ return "—";
11
+ const ageSec = Math.max(0, (nowMs - lastSeenAt) / 1000);
12
+ if (ageSec < 1)
13
+ return "just now";
14
+ if (ageSec < 60)
15
+ return `${ageSec.toFixed(0)}s ago`;
16
+ if (ageSec < 3600)
17
+ return `${Math.floor(ageSec / 60)}m ago`;
18
+ return new Date(lastSeenAt).toLocaleTimeString();
19
+ }
20
+ function Field({ label, value }) {
21
+ return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 border-b border-white/[0.04] py-1 last:border-b-0", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-600", children: label }), _jsx("span", { className: `min-w-0 truncate text-right text-[11px] text-neutral-200 ${NUMERIC}`, children: value })] }));
22
+ }
23
+ function PresenceRow({ actor, nowMs }) {
24
+ const pos = actor.position;
25
+ return (_jsxs("li", { className: "rounded-[6px] border border-white/[0.07] bg-white/[0.02] px-2.5 py-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400/90", "aria-hidden": "true" }), _jsx("span", { className: "min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100", children: actor.userId }), _jsx("span", { className: `shrink-0 text-[9px] text-neutral-600 ${NUMERIC}`, children: formatSeen(actor.lastSeenAt, nowMs) })] }), _jsxs("div", { className: `mt-0.5 text-[10px] text-neutral-500 ${NUMERIC}`, children: ["x ", formatCoord(pos.x), " \u00B7 y ", formatCoord(pos.y), " \u00B7 z ", formatCoord(pos.z), actor.rotationY !== undefined ? ` · yaw ${formatCoord(actor.rotationY)}` : null] })] }));
26
+ }
27
+ function authorityLabel(snapshot) {
28
+ if (snapshot.authority === null)
29
+ return "single-player";
30
+ if (snapshot.authority === "server")
31
+ return "server (host-authoritative)";
32
+ return "client (presence-only)";
33
+ }
34
+ /**
35
+ * Network workspace panel: inspects the game's declared multiplayer adapter and optional
36
+ * host-injected presence/session snapshot. Missing presence is an honest empty state — never
37
+ * fabricated actors or latency.
38
+ *
39
+ * @internal — mounted by `EditorChrome` when the Network workspace is active.
40
+ */
41
+ export function NetworkWorkspacePanel({ snapshot }) {
42
+ const nowMs = Date.now();
43
+ const multiplayer = isNetworkMultiplayerConfigured(snapshot);
44
+ const online = snapshot.online;
45
+ const hasSession = snapshot.session !== undefined;
46
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-2.5", "aria-label": "Network workspace", children: [_jsxs("div", { children: [_jsx("div", { className: MICRO_LABEL, children: "Session" }), _jsxs("div", { className: "mt-1.5 rounded-[6px] border border-white/[0.07] bg-white/[0.02] px-2.5 py-1.5", children: [_jsx(Field, { label: "Game", value: snapshot.gameId }), _jsx(Field, { label: "Adapter", value: snapshot.adapterKind }), _jsx(Field, { label: "Authority", value: authorityLabel(snapshot) }), snapshot.topology !== undefined ? _jsx(Field, { label: "Topology", value: snapshot.topology }) : null, snapshot.endpoint !== undefined ? _jsx(Field, { label: "Endpoint", value: snapshot.endpoint }) : null, hasSession ? (_jsxs(_Fragment, { children: [_jsx(Field, { label: "User", value: snapshot.session.userId }), snapshot.session.serverId !== undefined ? (_jsx(Field, { label: "Server", value: snapshot.session.serverId === null ? "(joined, id not reported)" : snapshot.session.serverId })) : null, snapshot.session.feedActions !== undefined && snapshot.session.feedActions.length > 0 ? (_jsx(Field, { label: "Feeds", value: snapshot.session.feedActions.join(", ") })) : null] })) : (_jsx(Field, { label: "Live session", value: multiplayer ? "not attached" : "n/a (offline)" }))] })] }), _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "mb-1.5 flex items-center gap-2", children: [_jsx("div", { className: MICRO_LABEL, children: "Presence" }), online !== undefined ? (_jsxs("span", { className: `text-[9px] text-neutral-600 ${NUMERIC}`, children: [online.length, " online"] })) : null, snapshot.updatedAt !== undefined ? (_jsxs("span", { className: `ml-auto text-[9px] text-neutral-600 ${NUMERIC}`, children: ["updated ", formatSeen(snapshot.updatedAt, nowMs)] })) : null] }), online === undefined ? (_jsx(EmptyState, { icon: "network", title: "No presence feed", description: multiplayer
47
+ ? "The host has not supplied a live presence snapshot. Attach a multiplayer backend (Convex/WS) and pass presence rows into the editor — nothing is invented here."
48
+ : "This game is offline / single-player. Presence inspection appears when the game declares a multiplayer adapter and the host injects a session feed." })) : online.length === 0 ? (_jsx(EmptyState, { icon: "network", title: "No players online", description: "The presence feed is connected but the roster is empty. Other clients will appear here when they sync poses." })) : (_jsx("ul", { className: "min-h-0 flex-1 space-y-1 overflow-auto pr-0.5", children: online.map((actor) => (_jsx(PresenceRow, { actor: actor, nowMs: nowMs }, actor.userId))) }))] })] }));
49
+ }
@@ -0,0 +1,12 @@
1
+ import type { EditorHostApi } from "../session.js";
2
+ /**
3
+ * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
4
+ * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
5
+ * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
6
+ * @internal
7
+ */
8
+ export declare function PlayModeBar({ gameId, api, onExit, }: {
9
+ gameId: string;
10
+ api: EditorHostApi;
11
+ onExit: () => void;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Icon } from "./icons.js";
4
+ import { BORDER, FOCUS_RING } from "./theme.js";
5
+ import { IconButton, ToolbarDivider } from "./ui.js";
6
+ /**
7
+ * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
8
+ * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
9
+ * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
10
+ * @internal
11
+ */
12
+ export function PlayModeBar({ gameId, api, onExit, }) {
13
+ const [play, setPlay] = useState(() => api.getPlayControl());
14
+ useEffect(() => api.subscribePlayControl(setPlay), [api]);
15
+ const pause = () => {
16
+ api.handle({ method: "runtime_pause" });
17
+ };
18
+ const resume = () => {
19
+ api.handle({ method: "runtime_resume" });
20
+ };
21
+ const step = () => {
22
+ api.handle({ method: "runtime_step", frames: 1 });
23
+ };
24
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, role: "banner", "aria-label": "Play mode", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-emerald-500/30 to-cyan-500/25 text-[11px] font-bold tracking-wide text-emerald-200 ring-1 ring-inset ring-emerald-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Play mode" })] }), _jsxs("span", { className: `ml-1 hidden items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:flex ${play.paused
25
+ ? "bg-amber-500/15 text-amber-200 ring-1 ring-inset ring-amber-400/25"
26
+ : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), "Editor"] })] })] }));
27
+ }
@@ -0,0 +1,10 @@
1
+ import type { PerfHistoryStore } from "./perfHistory.js";
2
+ /**
3
+ * Profiler dock tab over the real {@link PerfProbe} sample history: frame-time graph, current and
4
+ * average values, authoring-cost series when the probe reports one, sim/outside budget when
5
+ * `devtools.frame` has recorded frames, and JS heap memory when the browser exposes
6
+ * `performance.memory`. Series the host cannot measure are omitted entirely rather than fabricated.
7
+ */
8
+ export declare function ProfilerPanel({ history }: {
9
+ history: PerfHistoryStore;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { formatTriangles } from "./StatusBar.js";
4
+ import { latestPhases, samplesHaveFrameBudget, seriesAverage, seriesAverageDefined, sparklinePoints, } from "./perfHistory.js";
5
+ import { NUMERIC } from "./theme.js";
6
+ import { EmptyState, IconButton } from "./ui.js";
7
+ const GRAPH_WIDTH = 560;
8
+ const GRAPH_HEIGHT = 96;
9
+ function Stat({ label, value, average }) {
10
+ return (_jsxs("div", { className: "min-w-24 rounded-[6px] border border-white/[0.07] bg-white/[0.02] px-2.5 py-1.5", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: label }), _jsx("div", { className: `text-[15px] font-medium text-neutral-100 ${NUMERIC}`, children: value }), average !== undefined ? _jsxs("div", { className: `text-[9px] text-neutral-500 ${NUMERIC}`, children: ["avg ", average] }) : null] }));
11
+ }
12
+ function BudgetBar({ simMs, outsideMs }) {
13
+ const total = Math.max(1e-6, simMs + outsideMs);
14
+ const simPct = (simMs / total) * 100;
15
+ return (_jsxs("div", { className: "mt-2 space-y-1", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Frame budget (sim vs outside)" }), _jsxs("div", { className: "flex h-2 overflow-hidden rounded-[4px] border border-white/[0.07] bg-black/25", children: [_jsx("div", { className: "h-full bg-sky-400", style: { width: `${simPct}%` }, title: `sim ${simMs.toFixed(2)} ms` }), _jsx("div", { className: "h-full bg-amber-400/80", style: { width: `${100 - simPct}%` }, title: `outside ${outsideMs.toFixed(2)} ms` })] }), _jsxs("div", { className: `flex justify-between text-[9px] text-neutral-500 ${NUMERIC}`, children: [_jsxs("span", { className: "text-sky-300", children: ["sim ", simMs.toFixed(2), " ms"] }), _jsxs("span", { className: "text-amber-300", children: ["outside ", outsideMs.toFixed(2), " ms"] })] }), _jsx("div", { className: "text-[9px] leading-snug text-neutral-600", children: "Outside = frame \u2212 sim (render / React / GPU / GC). Same numbers as F2+D / debug_snapshot." })] }));
16
+ }
17
+ /**
18
+ * Profiler dock tab over the real {@link PerfProbe} sample history: frame-time graph, current and
19
+ * average values, authoring-cost series when the probe reports one, sim/outside budget when
20
+ * `devtools.frame` has recorded frames, and JS heap memory when the browser exposes
21
+ * `performance.memory`. Series the host cannot measure are omitted entirely rather than fabricated.
22
+ */
23
+ export function ProfilerPanel({ history }) {
24
+ const samples = useSyncExternalStore(history.subscribe, history.getSamples, history.getSamples);
25
+ const paused = useSyncExternalStore(history.subscribe, history.isPaused, history.isPaused);
26
+ const frameSeries = samples.map((sample) => sample.frameMs);
27
+ const authoringSeries = samples.map((sample) => sample.authoringMs ?? 0);
28
+ const memorySeries = samples.map((sample) => sample.memoryMb ?? 0);
29
+ const simSeries = samples.map((sample) => sample.simMs ?? 0);
30
+ const outsideSeries = samples.map((sample) => sample.outsideMs ?? 0);
31
+ const hasAuthoring = authoringSeries.some((value) => value > 0);
32
+ const hasMemory = samples.some((sample) => sample.memoryMb !== undefined);
33
+ const hasBudget = samplesHaveFrameBudget(samples);
34
+ const phases = latestPhases(samples);
35
+ const latest = samples[samples.length - 1];
36
+ const maxFrame = Math.max(33.4, ...frameSeries, ...(hasBudget ? [...simSeries, ...outsideSeries] : []));
37
+ const maxMemory = hasMemory ? Math.max(16, ...memorySeries) : 0;
38
+ const latestSim = latest?.simMs;
39
+ const latestOutside = latest?.outsideMs;
40
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Frame time" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [samples.length, " samples \u00B7 500ms window"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: paused ? "play" : "pause", label: paused ? "Resume sampling" : "Pause sampling", size: 12, active: paused, onClick: () => history.setPaused(!paused) }), _jsx(IconButton, { icon: "trash", label: "Clear samples", size: 12, onClick: () => history.clear(), disabled: samples.length === 0 })] })] }), samples.length === 0 ? (_jsx(EmptyState, { icon: "gauge", title: "No samples yet", description: "The in-viewport probe publishes a real frame sample every 500ms while the editor renders." })) : (_jsxs("div", { className: "flex min-h-0 flex-1 gap-3 overflow-auto p-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("svg", { viewBox: `0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`, preserveAspectRatio: "none", className: "h-full max-h-40 w-full rounded-[6px] border border-white/[0.07] bg-black/25", role: "img", "aria-label": "Frame time graph", children: [_jsx("line", { x1: 0, x2: GRAPH_WIDTH, y1: GRAPH_HEIGHT - (16.7 / maxFrame) * GRAPH_HEIGHT, y2: GRAPH_HEIGHT - (16.7 / maxFrame) * GRAPH_HEIGHT, stroke: "rgba(74,222,128,0.25)", strokeDasharray: "4 4" }), _jsx("line", { x1: 0, x2: GRAPH_WIDTH, y1: GRAPH_HEIGHT - (33.3 / maxFrame) * GRAPH_HEIGHT, y2: GRAPH_HEIGHT - (33.3 / maxFrame) * GRAPH_HEIGHT, stroke: "rgba(251,191,36,0.25)", strokeDasharray: "4 4" }), _jsx("polyline", { points: sparklinePoints(frameSeries, GRAPH_WIDTH, GRAPH_HEIGHT, maxFrame), fill: "none", stroke: "#22d3ee", strokeWidth: 1.5, vectorEffect: "non-scaling-stroke" }), hasBudget ? (_jsxs(_Fragment, { children: [_jsx("polyline", { points: sparklinePoints(simSeries, GRAPH_WIDTH, GRAPH_HEIGHT, maxFrame), fill: "none", stroke: "#38bdf8", strokeWidth: 1, vectorEffect: "non-scaling-stroke" }), _jsx("polyline", { points: sparklinePoints(outsideSeries, GRAPH_WIDTH, GRAPH_HEIGHT, maxFrame), fill: "none", stroke: "#fbbf24", strokeWidth: 1, vectorEffect: "non-scaling-stroke" })] })) : null, hasAuthoring ? (_jsx("polyline", { points: sparklinePoints(authoringSeries, GRAPH_WIDTH, GRAPH_HEIGHT, maxFrame), fill: "none", stroke: "#a78bfa", strokeWidth: 1, vectorEffect: "non-scaling-stroke" })) : null] }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-3 text-[9px] text-neutral-500", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-cyan-400" }), " frame ms"] }), hasBudget ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-sky-400" }), " sim ms"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-amber-400" }), " outside ms"] })] })) : null, hasAuthoring ? _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-violet-400" }), " authoring ms"] }) : null, _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-emerald-400/60" }), " 60 fps budget"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "h-0.5 w-3 bg-amber-400/60" }), " 30 fps budget"] })] }), hasBudget && latestSim !== undefined && latestOutside !== undefined ? (_jsx(BudgetBar, { simMs: latestSim, outsideMs: latestOutside })) : (_jsx("div", { className: "mt-2 text-[9px] leading-snug text-neutral-600", children: "Sim / outside split appears when the runtime FrameDriver records frames (Play mode / live tick) \u2014 same source as debug_snapshot. Edit-only idle leaves this series omitted." })), phases.length > 0 ? (_jsxs("div", { className: "mt-3 space-y-1", children: [_jsx("div", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Sim phases (avg)" }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: phases.map((phase) => (_jsxs("div", { className: `rounded border border-white/[0.07] bg-white/[0.02] px-2 py-1 text-[10px] text-neutral-300 ${NUMERIC}`, children: [_jsx("span", { className: "text-neutral-500", children: phase.name }), " ", phase.avgMs.toFixed(2), " ms"] }, phase.name))) })] })) : null, hasMemory ? (_jsxs("div", { className: "mt-3", children: [_jsx("div", { className: "mb-1 text-[9px] uppercase tracking-wider text-neutral-600", children: "JS heap (browser)" }), _jsx("svg", { viewBox: `0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`, preserveAspectRatio: "none", className: "h-20 w-full rounded-[6px] border border-white/[0.07] bg-black/25", role: "img", "aria-label": "JS heap memory graph", children: _jsx("polyline", { points: sparklinePoints(memorySeries, GRAPH_WIDTH, GRAPH_HEIGHT, maxMemory), fill: "none", stroke: "#fbbf24", strokeWidth: 1.5, vectorEffect: "non-scaling-stroke" }) })] })) : null] }), _jsxs("div", { className: "flex shrink-0 flex-col gap-1.5", children: [_jsx(Stat, { label: "FPS", value: latest === undefined ? "—" : latest.active ? latest.fps.toFixed(0) : "idle", average: `${seriesAverage(samples.map((sample) => sample.fps)).toFixed(0)}` }), _jsx(Stat, { label: "Frame", value: latest === undefined ? "—" : `${latest.frameMs.toFixed(2)} ms`, average: `${seriesAverage(frameSeries).toFixed(2)} ms` }), hasBudget ? (_jsxs(_Fragment, { children: [_jsx(Stat, { label: "Sim", value: latestSim === undefined ? "—" : `${latestSim.toFixed(2)} ms`, average: `${seriesAverageDefined(samples.map((sample) => sample.simMs)).toFixed(2)} ms` }), _jsx(Stat, { label: "Outside", value: latestOutside === undefined ? "—" : `${latestOutside.toFixed(2)} ms`, average: `${seriesAverageDefined(samples.map((sample) => sample.outsideMs)).toFixed(2)} ms` })] })) : null, _jsx(Stat, { label: "Draw calls", value: latest === undefined ? "—" : String(latest.drawCalls) }), _jsx(Stat, { label: "Triangles", value: latest === undefined ? "—" : formatTriangles(latest.triangles) }), hasAuthoring ? (_jsx(Stat, { label: "Authoring", value: `${(latest?.authoringMs ?? 0).toFixed(1)} ms`, average: `${seriesAverage(authoringSeries).toFixed(1)} ms` })) : null, hasMemory ? (_jsx(Stat, { label: "Heap", value: `${(latest?.memoryMb ?? 0).toFixed(1)} MB`, average: `${seriesAverage(memorySeries).toFixed(1)} MB` })) : null] })] }))] }));
41
+ }
@@ -0,0 +1,43 @@
1
+ import { type CameraProjectionMode, type EditorTool, type GizmoMode, type GizmoPivot, type GizmoSpace, type PlacementTool, type SnapMode } from "../uiStore.js";
2
+ /** Volume placement entries offered by the Add menu. */
3
+ export declare const ADD_VOLUME_ENTRIES: readonly {
4
+ label: string;
5
+ tool: PlacementTool;
6
+ }[];
7
+ /**
8
+ * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
9
+ * snapping, viewport overlays, framing, projection, and the Add menu.
10
+ */
11
+ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }: {
12
+ tool: EditorTool;
13
+ gizmoMode: GizmoMode;
14
+ gizmoSpace: GizmoSpace;
15
+ gizmoPivot: GizmoPivot;
16
+ snapMode: SnapMode;
17
+ gridSize: number;
18
+ rotationSnapDeg: number | null;
19
+ scaleSnap: number | null;
20
+ cameraProjection: CameraProjectionMode;
21
+ showGrid: boolean;
22
+ showContours: boolean;
23
+ showSurfaceGrid: boolean;
24
+ showElevation: boolean;
25
+ placementActive: boolean;
26
+ onSetTool: (tool: EditorTool) => void;
27
+ onSetGizmoMode: (mode: GizmoMode) => void;
28
+ onSetGizmoSpace: (space: GizmoSpace) => void;
29
+ onSetGizmoPivot: (pivot: GizmoPivot) => void;
30
+ onSetSnapMode: (mode: SnapMode) => void;
31
+ onSetGridSize: (size: number) => void;
32
+ onSetRotationSnapDeg: (deg: number | null) => void;
33
+ onSetScaleSnap: (snap: number | null) => void;
34
+ onSetCameraProjection: (projection: CameraProjectionMode) => void;
35
+ onToggleGrid: () => void;
36
+ onToggleContours: () => void;
37
+ onToggleSurfaceGrid: () => void;
38
+ onToggleElevation: () => void;
39
+ onFrame: () => void;
40
+ onStartPlacement: (tool: PlacementTool) => void;
41
+ onOpenAssistant: () => void;
42
+ assistantOpen: boolean;
43
+ }): import("react").JSX.Element;