@jgengine/editor 0.13.0 → 0.15.0-next.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 (53) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ClipPreviewLayer.d.ts +15 -0
  3. package/dist/ClipPreviewLayer.js +60 -0
  4. package/dist/EditorApp.js +4 -3
  5. package/dist/EditorCameraDriver.d.ts +6 -1
  6. package/dist/EditorCameraDriver.js +25 -1
  7. package/dist/EditorChrome.js +4 -6
  8. package/dist/InspectorPanel.d.ts +4 -1
  9. package/dist/InspectorPanel.js +47 -5
  10. package/dist/ParentPickerMenu.js +2 -6
  11. package/dist/RuntimePlayBridge.js +10 -15
  12. package/dist/SchemaInspector.d.ts +3 -1
  13. package/dist/SchemaInspector.js +63 -32
  14. package/dist/TerrainPanel.js +3 -4
  15. package/dist/TerrainReadout.js +1 -3
  16. package/dist/agent/AgentPanel.js +7 -11
  17. package/dist/camera/orbitFraming.d.ts +62 -0
  18. package/dist/camera/orbitFraming.js +69 -0
  19. package/dist/chromeFields.js +9 -4
  20. package/dist/handlers/document.d.ts +1 -1
  21. package/dist/handlers/document.js +78 -9
  22. package/dist/mcp/fieldSpec.js +4 -2
  23. package/dist/mcp/tools.js +4 -2
  24. package/dist/modelAnimationAuthoring.d.ts +75 -0
  25. package/dist/modelAnimationAuthoring.js +164 -0
  26. package/dist/session.d.ts +54 -15
  27. package/dist/shell/AnimationPanel.d.ts +10 -8
  28. package/dist/shell/AnimationPanel.js +120 -26
  29. package/dist/shell/BottomDock.d.ts +3 -1
  30. package/dist/shell/BottomDock.js +2 -2
  31. package/dist/shell/CommandPalette.js +1 -5
  32. package/dist/shell/HierarchyPanel.js +17 -26
  33. package/dist/shell/PlayModeBar.d.ts +7 -0
  34. package/dist/shell/PlayModeBar.js +12 -3
  35. package/dist/shell/SceneToolbar.js +9 -5
  36. package/dist/shell/clipPreview.d.ts +85 -0
  37. package/dist/shell/clipPreview.js +82 -0
  38. package/dist/shell/fields.js +8 -8
  39. package/dist/shell/glbThumbnailCache.js +39 -8
  40. package/dist/shell/scatterCoverage.d.ts +22 -0
  41. package/dist/shell/scatterCoverage.js +40 -0
  42. package/dist/uiStore.d.ts +6 -0
  43. package/dist/uiStore.js +1 -0
  44. package/dist/useStoreSelector.d.ts +12 -0
  45. package/dist/useStoreSelector.js +23 -0
  46. package/package.json +5 -5
  47. package/skills/ce-handoff/SKILL.md +149 -0
  48. package/skills/jgengine/SKILL.md +7 -3
  49. package/skills/jgengine/api.md +17 -7
  50. package/skills/jgengine/capabilities.md +12 -0
  51. package/skills/jgengine/recipes/minimal-game.md +7 -4
  52. package/skills/jgengine-editor/api.md +56 -4
  53. package/skills/jgengine-editor/reference.md +44 -0
@@ -0,0 +1,15 @@
1
+ import type { EditorHostApi } from "./session.js";
2
+ import type { EditorUiStore } from "./uiStore.js";
3
+ /**
4
+ * Viewport layer that renders the active clip-preview asset playing the selected clip, mounted by
5
+ * `EditorApp` inside the editor's R3F scene + GameContext. Reuses the shell's shared GLTF loader,
6
+ * scene clone, and `useModelAnimation` mixer — the same driver `EntityModel` runs at play time — so
7
+ * the preview IS the runtime playback. Reads the live session from the editor UI store and publishes
8
+ * the selected clip's measured duration back so the dock scrubber can normalize.
9
+ *
10
+ * @internal — not a game-author entry point.
11
+ */
12
+ export declare function ClipPreviewLayer({ api, ui }: {
13
+ api: EditorHostApi;
14
+ ui: EditorUiStore;
15
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, useEffect, useMemo } from "react";
3
+ import { useLoader } from "@react-three/fiber";
4
+ import * as THREE from "three";
5
+ import { sharedGltfLoader } from "@jgengine/shell/render/modelLoad";
6
+ import { cloneModelScene, disposeClonedMaterials } from "@jgengine/shell/render/modelRender";
7
+ import { useModelAnimation } from "@jgengine/shell/render/useModelAnimation";
8
+ import { previewAnimationConfig } from "./shell/clipPreview.js";
9
+ import { useStoreSelector } from "./useStoreSelector.js";
10
+ /** World-unit height the previewed model is normalized to, so tiny/huge rigs are both framed. */
11
+ const PREVIEW_TARGET_HEIGHT = 2;
12
+ /**
13
+ * Viewport layer that renders the active clip-preview asset playing the selected clip, mounted by
14
+ * `EditorApp` inside the editor's R3F scene + GameContext. Reuses the shell's shared GLTF loader,
15
+ * scene clone, and `useModelAnimation` mixer — the same driver `EntityModel` runs at play time — so
16
+ * the preview IS the runtime playback. Reads the live session from the editor UI store and publishes
17
+ * the selected clip's measured duration back so the dock scrubber can normalize.
18
+ *
19
+ * @internal — not a game-author entry point.
20
+ */
21
+ export function ClipPreviewLayer({ api, ui }) {
22
+ const session = useStoreSelector(ui, (state) => state.clipPreview);
23
+ if (session === null)
24
+ return null;
25
+ return (_jsx(Suspense, { fallback: null, children: _jsx(ClipPreviewModel, { session: session, api: api, ui: ui }, session.source.assetId) }));
26
+ }
27
+ function ClipPreviewModel({ session, api, ui, }) {
28
+ const { source, driver } = session;
29
+ const gltf = useLoader(sharedGltfLoader, source.url);
30
+ const scene = useMemo(() => cloneModelScene(gltf.scene), [gltf]);
31
+ useEffect(() => () => disposeClonedMaterials(scene), [scene]);
32
+ // Normalize height and center/ground so any rig frames nicely at the camera focus point.
33
+ const placement = useMemo(() => {
34
+ const box = new THREE.Box3().setFromObject(scene);
35
+ const height = box.max.y - box.min.y;
36
+ if (!Number.isFinite(height) || height <= 0)
37
+ return { scale: 1, minY: 0, centerX: 0, centerZ: 0 };
38
+ return {
39
+ scale: PREVIEW_TARGET_HEIGHT / height,
40
+ minY: box.min.y,
41
+ centerX: (box.min.x + box.max.x) / 2,
42
+ centerZ: (box.min.z + box.max.z) / 2,
43
+ };
44
+ }, [scene]);
45
+ const config = useMemo(() => previewAnimationConfig(driver), [driver]);
46
+ useModelAnimation(scene, gltf.animations, config, undefined);
47
+ // Publish the selected clip's duration once loaded so the dock scrubber can span it (0 = unknown).
48
+ const clipName = driver.clipName;
49
+ useEffect(() => {
50
+ const clip = clipName === null ? undefined : gltf.animations.find((entry) => entry.name === clipName);
51
+ const duration = clip?.duration ?? 0;
52
+ const current = ui.getState().clipPreview;
53
+ if (current !== null && current.source.assetId === source.assetId && current.duration !== duration) {
54
+ ui.patch({ clipPreview: { ...current, duration } });
55
+ }
56
+ }, [gltf, clipName, ui, source.assetId]);
57
+ const focus = api.getFocusTarget() ?? { x: 0, y: 0, z: 0 };
58
+ const s = placement.scale;
59
+ return (_jsx("group", { position: [focus.x, focus.y, focus.z], children: _jsx("primitive", { object: scene, scale: s, position: [-s * placement.centerX, -s * placement.minY, -s * placement.centerZ] }) }));
60
+ }
package/dist/EditorApp.js CHANGED
@@ -15,6 +15,7 @@ import { MaterialDropZone } from "./MaterialDropZone.js";
15
15
  import { PerfProbe } from "./PerfProbe.js";
16
16
  import { ScatterPreview } from "./ScatterPreview.js";
17
17
  import { CityPreview } from "./CityPreview.js";
18
+ import { ClipPreviewLayer } from "./ClipPreviewLayer.js";
18
19
  import { SelectionGizmo, ViewportSelect } from "./SelectionGizmo.js";
19
20
  import { TerrainReadout } from "./TerrainReadout.js";
20
21
  import { TerrainReadoutHud } from "./TerrainReadoutHud.js";
@@ -153,7 +154,7 @@ function EditorWorldOverlay({ api, ui, world, readout, }) {
153
154
  unsubscribe();
154
155
  };
155
156
  }, [session, readoutActive]);
156
- return (_jsxs(_Fragment, { children: [_jsx(PerfProbe, { api: api }), _jsx(EditorCameraDriver, { api: api }), _jsx(ViewportSelect, { api: api, ui: ui }), _jsx(MaterialDropZone, { api: api }), _jsx(AssetDropZone, { api: api }), _jsx(TerrainSculpt, { api: api, ui: ui, world: world }), _jsx(ScatterPreview, { api: api }), _jsx(CityPreview, { api: api }), showGrid ? _jsx("gridHelper", { args: [400, 80, "#3b4252", "#20242e"], position: [0, 0.05, 0] }) : null, readoutActive ? (_jsx(TerrainReadout, { groundHeightAt: groundHeightAt, region: readoutRegion, showContours: showContours, showSurfaceGrid: showSurfaceGrid, version: terrainVersion, readout: readout })) : null, _jsx(EditorLayerOverlays, { document: document, visibility: visibility, selection: selection, hoverId: hoverId, onSelect: (id) => session.dispatch({ type: "select", ids: [id] }), activePathPoint: pathPoint, groundHeightAt: groundHeightAt }), pathDraft.length > 0 ? _jsx(PathDraftPreview, { points: pathDraft }) : null, _jsx(SelectionGizmo, { session: session, ui: ui, groundSnap: groundHeightAt })] }));
157
+ return (_jsxs(_Fragment, { children: [_jsx(PerfProbe, { api: api }), _jsx(EditorCameraDriver, { api: api }), _jsx(ViewportSelect, { api: api, ui: ui }), _jsx(MaterialDropZone, { api: api }), _jsx(AssetDropZone, { api: api }), _jsx(TerrainSculpt, { api: api, ui: ui, world: world }), _jsx(ScatterPreview, { api: api }), _jsx(CityPreview, { api: api }), _jsx(ClipPreviewLayer, { api: api, ui: ui }), showGrid ? _jsx("gridHelper", { args: [400, 80, "#3b4252", "#20242e"], position: [0, 0.05, 0] }) : null, readoutActive ? (_jsx(TerrainReadout, { groundHeightAt: groundHeightAt, region: readoutRegion, showContours: showContours, showSurfaceGrid: showSurfaceGrid, version: terrainVersion, readout: readout })) : null, _jsx(EditorLayerOverlays, { document: document, visibility: visibility, selection: selection, hoverId: hoverId, onSelect: (id) => session.dispatch({ type: "select", ids: [id] }), activePathPoint: pathPoint, groundHeightAt: groundHeightAt }), pathDraft.length > 0 ? _jsx(PathDraftPreview, { points: pathDraft }) : null, _jsx(SelectionGizmo, { session: session, ui: ui, groundSnap: groundHeightAt })] }));
157
158
  }
158
159
  /** Default skin for the play/walk escape chip; swap via `EditorAppProps.modeChip`, or pass `null` to hide it (F2+E still exits). */
159
160
  function EditorModeChip({ mode, onExit }) {
@@ -260,7 +261,6 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
260
261
  if (readoutStoreRef.current === null)
261
262
  readoutStoreRef.current = createTerrainReadoutStore();
262
263
  const readout = readoutStoreRef.current;
263
- const [mode, setModeState] = useState("edit");
264
264
  const catalogAssets = useMemo(() => {
265
265
  try {
266
266
  const ids = playable.game.assets.ids();
@@ -285,7 +285,8 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
285
285
  };
286
286
  }, [gameId, resolvedLayers, catalogs, catalogAssets]);
287
287
  useEffect(() => host.dispose, [host]);
288
- useEffect(() => host.api.subscribeMode(setModeState), [host]);
288
+ const modeStore = useMemo(() => ({ getState: host.api.getMode, subscribe: host.api.subscribeMode }), [host]);
289
+ const mode = useStoreSelector(modeStore, (m) => m);
289
290
  const [pendingDraft, setPendingDraft] = useState(null);
290
291
  useEffect(() => {
291
292
  const draft = readDraft(gameId);
@@ -1,5 +1,10 @@
1
1
  import type { EditorHostApi } from "./session.js";
2
- /** Smoothly pans the orbit camera to the editor host's focus target when it changes. */
2
+ /**
3
+ * Drives the editor orbit camera from the host's focus target. When the target carries only a
4
+ * point, the camera pans to keep it centered (the historical behavior). When it also carries
5
+ * placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
6
+ * how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
7
+ */
3
8
  export declare const EditorCameraDriver: import("react").MemoExoticComponent<({ api }: {
4
9
  api: EditorHostApi;
5
10
  }) => null>;
@@ -1,6 +1,12 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { memo, useEffect } from "react";
3
- /** Smoothly pans the orbit camera to the editor host's focus target when it changes. */
3
+ import { orbitCameraPosition } from "./camera/orbitFraming.js";
4
+ /**
5
+ * Drives the editor orbit camera from the host's focus target. When the target carries only a
6
+ * point, the camera pans to keep it centered (the historical behavior). When it also carries
7
+ * placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
8
+ * how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
9
+ */
4
10
  export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
5
11
  const controls = useThree((state) => state.controls);
6
12
  const camera = useThree((state) => state.camera);
@@ -8,6 +14,24 @@ export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
8
14
  return api.subscribeFocus((target) => {
9
15
  if (target === null || controls === null || controls.target === undefined)
10
16
  return;
17
+ const hasPlacement = target.distance !== undefined ||
18
+ target.pitch !== undefined ||
19
+ target.yaw !== undefined ||
20
+ target.height !== undefined;
21
+ if (hasPlacement) {
22
+ controls.target.set(target.x, target.y, target.z);
23
+ const distance = target.distance ?? camera.position.distanceTo(controls.target);
24
+ const pose = orbitCameraPosition({
25
+ target,
26
+ distance,
27
+ pitchDeg: target.pitch ?? 45,
28
+ yawDeg: target.yaw,
29
+ height: target.height,
30
+ });
31
+ camera.position.set(pose.x, pose.y, pose.z);
32
+ controls.update();
33
+ return;
34
+ }
11
35
  const previous = controls.target.clone();
12
36
  controls.target.set(target.x, target.y, target.z);
13
37
  camera.position.add(controls.target.clone().sub(previous));
@@ -15,6 +15,7 @@ import { buildOutlinerGroups } from "./outlinerModel.js";
15
15
  import { importAssetToHost, loadDroppedAssets, } from "./assetImport.js";
16
16
  import { newPlacementId } from "./uiStore.js";
17
17
  import { useF2Chord } from "./useF2Chord.js";
18
+ import { useStoreVersion } from "./useStoreSelector.js";
18
19
  import { TerrainPanel } from "./TerrainPanel.js";
19
20
  import { LightingPanel } from "./LightingPanel.js";
20
21
  import { InspectorPanel } from "./InspectorPanel.js";
@@ -112,10 +113,10 @@ const LEFT_PAGES = [
112
113
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
113
114
  */
114
115
  export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
115
- const [, setTick] = useState(0);
116
116
  const layoutRef = useRef(null);
117
117
  layoutRef.current ??= createShellLayoutStore(gameId);
118
118
  const layout = layoutRef.current;
119
+ useStoreVersion(session, ui, layout);
119
120
  const consoleRef = useRef(null);
120
121
  consoleRef.current ??= createEditorConsoleStore();
121
122
  const consoleStore = consoleRef.current;
@@ -171,9 +172,6 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
171
172
  useF2Chord("KeyE", () => api.setMode("play"));
172
173
  // F2+Q leaves the editor entirely (back to the game) — only meaningful when summoned over one.
173
174
  useF2Chord("KeyQ", useCallback(() => onExitEditor?.(), [onExitEditor]));
174
- useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
175
- useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
176
- useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
177
175
  const copySelection = useCallback(() => {
178
176
  const current = session.getState();
179
177
  if (current.selection.length === 0)
@@ -745,11 +743,11 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
745
743
  ? `Parented to ${parentId}`
746
744
  : `Parented ${ids.length} objects to ${parentId}`);
747
745
  }
748
- }, onClose: () => setParentPicker(null) })) : null] }), layoutState.bottomOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "horizontal", label: "Resize bottom dock", sign: -1, onResize: (delta) => layout.resize("bottomHeight", delta) }), _jsx("div", { className: "flex shrink-0 flex-col", style: { height: layoutState.bottomHeight }, children: _jsx(BottomDock, { tab: layoutState.bottomTab, onSelectTab: (tab) => layout.patch({ bottomTab: tab }), onClose: () => layout.patch({ bottomOpen: false }), assets: liveAssets, session: session, api: api, consoleStore: consoleStore, perfHistory: perfHistory, browserView: layoutState.browserView, onSetBrowserView: (view) => layout.patch({ browserView: view }), onPlaceAsset: placeAsset, onImportModels: importModels, importBusy: importBusy }) })] })) : (_jsxs("div", { className: `pointer-events-auto flex h-7 shrink-0 items-center gap-1 border-t ${BORDER} bg-[#0e1014] px-1.5`, children: [[
746
+ }, onClose: () => setParentPicker(null) })) : null] }), layoutState.bottomOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "horizontal", label: "Resize bottom dock", sign: -1, onResize: (delta) => layout.resize("bottomHeight", delta) }), _jsx("div", { className: "flex shrink-0 flex-col", style: { height: layoutState.bottomHeight }, children: _jsx(BottomDock, { tab: layoutState.bottomTab, onSelectTab: (tab) => layout.patch({ bottomTab: tab }), onClose: () => layout.patch({ bottomOpen: false }), assets: liveAssets, session: session, api: api, ui: ui, consoleStore: consoleStore, perfHistory: perfHistory, browserView: layoutState.browserView, onSetBrowserView: (view) => layout.patch({ browserView: view }), onPlaceAsset: placeAsset, onImportModels: importModels, importBusy: importBusy }) })] })) : (_jsxs("div", { className: `pointer-events-auto flex h-7 shrink-0 items-center gap-1 border-t ${BORDER} bg-[#0e1014] px-1.5`, children: [[
749
747
  ["content", "Content Browser"],
750
748
  ["console", "Console"],
751
749
  ["profiler", "Profiler"],
752
750
  ["animation", "Animation"],
753
751
  ["assistant", "AI Assistant"],
754
- ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
752
+ ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
755
753
  }
@@ -1,4 +1,5 @@
1
1
  import { type EditorSession } from "@jgengine/core/editor/index";
2
+ import { type EditorAssetEntry } from "./AssetBrowser.js";
2
3
  import type { EditorHostApi } from "./session.js";
3
4
  import type { EditorUiStore } from "./uiStore.js";
4
5
  import type { InspectorTab } from "./shell/layoutStore.js";
@@ -9,12 +10,14 @@ import type { InspectorTab } from "./shell/layoutStore.js";
9
10
  * `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
10
11
  * @internal — mounted by `EditorChrome` inside the right dock.
11
12
  */
12
- export declare function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, collapsed, onToggleSection, }: {
13
+ export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }: {
13
14
  session: EditorSession;
14
15
  ui: EditorUiStore;
15
16
  onClose?: () => void;
16
17
  /** Host API for RPC-backed actions (material assignment, camera). */
17
18
  api?: EditorHostApi;
19
+ /** Live browser asset list, used to resolve a placed marker's rig clips for animation authoring. */
20
+ assets?: readonly EditorAssetEntry[];
18
21
  /** Controlled active tab; omit for local state. */
19
22
  tab?: InspectorTab;
20
23
  onSelectTab?: (tab: InspectorTab) => void;
@@ -4,10 +4,14 @@ import { editorParentOf, findEditorNote, findEditorPath, isEditorObjectLocked, }
4
4
  import { readVegetationSettings, vegetationFootprint } from "@jgengine/core/world/vegetation";
5
5
  import { getSceneKind, isSceneKind, parseParams, } from "@jgengine/core/scene/sceneKinds";
6
6
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
7
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
7
8
  import { useGameContext } from "@jgengine/react/provider";
9
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
8
10
  import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatch, defaultTriggerInstallPatch, hasAuthoredTrigger, hasMaterialAssignment, } from "./authoredComponentMeta.js";
9
11
  import { SchemaInspector } from "./SchemaInspector.js";
10
12
  import { TriggerInspector } from "./TriggerInspector.js";
13
+ import { describeClipRoles } from "./AssetBrowser.js";
14
+ import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS, readAnimationSetting, setAnimationMode, setLocomotionClip, setLocomotionNumber, setOneShotClip, } from "./modelAnimationAuthoring.js";
11
15
  import { listParentCandidates } from "./parentCandidates.js";
12
16
  import { TERRAIN_MATERIALS } from "./uiStore.js";
13
17
  import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
@@ -15,8 +19,19 @@ import { INPUT } from "./chromeStyles.js";
15
19
  import { NumberField } from "./chromeFields.js";
16
20
  import { AxisNumberField, FieldRow, SectionAction } from "./shell/fields.js";
17
21
  import { Icon, kindIcon } from "./shell/icons.js";
22
+ import { CoverageSection } from "./shell/scatterCoverage.js";
18
23
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
19
24
  import { CollapsibleSection, EmptyState, IconButton, PanelTabs } from "./shell/ui.js";
25
+ /** Live-mirrored, debounced density slider — grass regen only fires on pause/release, not per step (#1372). */
26
+ function VegetationDensitySlider({ density, sliderMax, onMeta, }) {
27
+ const { value: local, onInput, flush } = useDebouncedCommit(density, (next) => onMeta({ density: next }, "veg:density"));
28
+ return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: local.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(local, sliderMax), onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
29
+ }
30
+ /** Live-mirrored, debounced item/seed text — avoids a scene patch (and grass regen) per keystroke (#1372). */
31
+ function VegetationTextField({ label, field, value, placeholder, onMeta, }) {
32
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field]: next }, `veg:${field}`));
33
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { className: `w-32 ${INPUT}`, value: local, placeholder: placeholder, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
34
+ }
20
35
  function VegetationFields({ volume, onMeta, }) {
21
36
  const settings = readVegetationSettings(volume);
22
37
  if (settings === null)
@@ -25,7 +40,7 @@ function VegetationFields({ volume, onMeta, }) {
25
40
  const areaM2 = (footprint.maxX - footprint.minX) * (footprint.maxZ - footprint.minZ);
26
41
  const sliderMax = settings.item === "grass" ? 12 : 1;
27
42
  const estimated = Math.floor(areaM2 * settings.density);
28
- return (_jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "item" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.item, placeholder: "grass", onChange: (event) => onMeta({ item: event.target.value }, "veg:item") })] }), _jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: settings.density.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(settings.density, sliderMax), onChange: (event) => onMeta({ density: Number(event.target.value) }, "veg:density") })] }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "seed" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.seed, placeholder: "reroll\u2026", onChange: (event) => onMeta({ seed: event.target.value }, "veg:seed") })] }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
43
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(VegetationTextField, { label: "item", field: "item", value: settings.item, placeholder: "grass", onMeta: onMeta }), _jsx(VegetationDensitySlider, { density: settings.density, sliderMax: sliderMax, onMeta: onMeta }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsx(VegetationTextField, { label: "seed", field: "seed", value: settings.seed, placeholder: "reroll\u2026", onMeta: onMeta }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
29
44
  }
30
45
  /** Builds the resolver-facing view of a document object for a registered scene kind's inspector. */
31
46
  function markerObject(marker) {
@@ -49,8 +64,11 @@ function KindInspector({ object, meta, onMeta }) {
49
64
  const definition = getSceneKind(object.kind);
50
65
  if (definition === undefined)
51
66
  return null;
52
- const note = definition.note?.(object, parseParams(definition.schema, object.meta));
53
- return (_jsx(SchemaInspector, { schema: definition.schema, label: definition.label, meta: meta, onMeta: onMeta, ...(definition.accent === undefined ? {} : { accent: definition.accent }), ...(note === undefined ? {} : { note }) }));
67
+ const params = parseParams(definition.schema, object.meta);
68
+ const note = definition.note?.(object, params);
69
+ const coverage = definition.coverage;
70
+ const accent = definition.accent ?? "#34d399";
71
+ return (_jsxs("div", { className: "space-y-2", children: [coverage !== undefined ? (_jsx(CoverageSection, { schema: definition.schema, coverage: coverage, object: object, params: params, accent: accent, onMeta: onMeta, ...(note === undefined ? {} : { note }) })) : null, _jsx(SchemaInspector, { schema: definition.schema, label: definition.label, meta: meta, onMeta: onMeta, ...(definition.accent === undefined ? {} : { accent: definition.accent }), ...(coverage === undefined && note !== undefined ? { note } : {}), ...(coverage === undefined ? {} : { hideKeys: [coverage.densityKey] }) })] }));
54
72
  }
55
73
  /** Auto-generated inspector for a placed generator asset's params (building/bookcase/…). */
56
74
  function GeneratorInspector({ meta, onMeta }) {
@@ -107,6 +125,26 @@ function ComponentAddBar({ target, meta, onMeta, api, selectionIds, }) {
107
125
  event.currentTarget.value = "";
108
126
  }, children: [_jsx("option", { value: "", children: "+ Material\u2026" }), TERRAIN_MATERIALS.map((material) => (_jsx("option", { value: material.id, children: material.label }, material.id)))] })] })) : null] })] }));
109
127
  }
128
+ /** A labeled clip dropdown (never free-text) for a role/event; "— none —" clears the binding. */
129
+ function ClipSelectRow({ label, clips, value, onChange, }) {
130
+ return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
131
+ }
132
+ const ANIMATION_MODE_HINT = {
133
+ default: "Inherits the catalog default: a rigged asset auto-animates from its clip roles.",
134
+ auto: "Forces derivation of idle/walk/run + hit/death/attack from this rig's clip names.",
135
+ none: "Renders the bind pose — no clip playback.",
136
+ custom: "Author explicit role→clip bindings below; unset roles fall back to the rig's defaults.",
137
+ };
138
+ /**
139
+ * Inspector section authoring a placement's `ModelConfig.animation` for a rigged catalog asset.
140
+ * Persists to `marker.meta.animation` through the passed `onChange` (an undoable `setMarker` dispatch).
141
+ * All clip picks are dropdowns over the asset's real clip list — never free-text.
142
+ */
143
+ function ModelAnimationSection({ clips, setting, onChange, }) {
144
+ const mode = animationMode(setting);
145
+ const config = mode === "custom" && typeof setting === "object" ? setting : null;
146
+ return (_jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "text-[10px] text-neutral-500", children: [clips.length, " clips \u00B7 ", describeClipRoles(clips)] }), _jsx(FieldRow, { label: "Mode", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: mode, "aria-label": "Animation mode", onChange: (event) => onChange(setAnimationMode(setting, event.target.value, clips)), children: [_jsx("option", { value: "default", children: "Default (catalog)" }), _jsx("option", { value: "auto", children: "Auto (derive)" }), _jsx("option", { value: "none", children: "None (bind pose)" }), _jsx("option", { value: "custom", children: "Custom" })] }) }), config === null ? (_jsx("div", { className: "text-[10px] leading-relaxed text-neutral-600", children: ANIMATION_MODE_HINT[mode] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Locomotion states" }), LOCOMOTION_ROLES.map((role) => (_jsx(ClipSelectRow, { label: role, clips: clips, value: config.states?.[role] ?? null, onChange: (clip) => onChange(setLocomotionClip(setting, role, clip)) }, role))), _jsx(FieldRow, { label: "walkSpeed", title: "Speed (u/s) above which the entity counts as moving (walk).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.1, value: config.states?.walkSpeed ?? 0.5, onCommit: (value) => onChange(setLocomotionNumber(setting, "walkSpeed", Math.max(0, value))) }) }), _jsx(FieldRow, { label: "runSpeed", title: "Speed above which the run clip plays (when a run clip is set).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.5, value: config.states?.runSpeed ?? 6, onCommit: (value) => onChange(setLocomotionNumber(setting, "runSpeed", Math.max(0, value))) }) }), _jsx(FieldRow, { label: "fadeSec", title: "Crossfade duration (s) when the locomotion state changes.", children: _jsx(AxisNumberField, { label: "s", step: 0.05, value: config.states?.fadeSec ?? 0.2, onCommit: (value) => onChange(setLocomotionNumber(setting, "fadeSec", Math.max(0, value))) }) })] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "One-shot events" }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "hit / death auto-fire on damage/death; others via playEntityAnimation." }), ONE_SHOT_EVENTS.map((event) => (_jsx(ClipSelectRow, { label: event, clips: clips, value: config.oneShots?.[event] ?? null, onChange: (clip) => onChange(setOneShotClip(setting, event, clip)) }, event)))] })] }))] }));
147
+ }
110
148
  function Section({ id, title, icon, sections, children, trailing, }) {
111
149
  return (_jsx(CollapsibleSection, { title: title, ...(icon === undefined ? {} : { icon }), open: sections.collapsed[id] !== true, onToggle: () => sections.toggle(id), ...(trailing === undefined ? {} : { trailing }), children: children }));
112
150
  }
@@ -127,7 +165,7 @@ function MaterialsTab({ api, selection, meta, }) {
127
165
  * `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
128
166
  * @internal — mounted by `EditorChrome` inside the right dock.
129
167
  */
130
- export function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, collapsed, onToggleSection, }) {
168
+ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }) {
131
169
  const document = useStoreSelector(session, (state) => state.document);
132
170
  const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
133
171
  const pathPoint = useStoreSelector(ui, (state) => state.pathPoint);
@@ -198,12 +236,16 @@ export function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, co
198
236
  else if (selectedMarker !== undefined) {
199
237
  const marker = selectedMarker;
200
238
  const onMeta = (patch, coalesce) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...patch } } }, { coalesce: `${coalesce}:${marker.id}` });
239
+ // Rig clips for animation authoring: this placement's catalog asset must carry animation clips.
240
+ const markerCatalog = markerCatalogId(marker);
241
+ const markerRigAsset = markerCatalog === null ? undefined : assets?.find((entry) => entry.id === markerCatalog);
242
+ const markerRigClips = markerRigAsset?.clips !== undefined && markerRigAsset.clips.length > 0 ? markerRigAsset.clips : null;
201
243
  const hasTrigger = hasAuthoredTrigger(marker.meta);
202
244
  const hasMaterial = hasMaterialAssignment(marker.meta);
203
245
  const hasGenerator = typeof marker.meta?.["assetId"] === "string" && getAssetGenerator(marker.meta["assetId"]) !== undefined;
204
246
  const hasKindParams = isSceneKind(marker.kind);
205
247
  const componentCards = (_jsxs(_Fragment, { children: [_jsx(ComponentAddBar, { target: "marker", meta: marker.meta, onMeta: onMeta, api: api, selectionIds: [marker.id] }), hasKindParams ? (_jsx(Section, { id: "kindParams", title: "Kind parameters", icon: "settings", sections: sections, children: _jsx(KindInspector, { object: markerObject(marker), meta: marker.meta, onMeta: onMeta }) })) : null, hasTrigger ? (_jsx(Section, { id: "trigger", title: "Trigger", icon: "target", sections: sections, trailing: _jsx(SectionAction, { label: "Remove trigger component", onClick: () => onMeta(clearTriggerInstallPatch(), "component:remove-trigger"), children: "remove" }), children: _jsx(TriggerInspector, { target: "marker", meta: marker.meta, onMeta: onMeta }) })) : null, hasMaterial ? (_jsx(Section, { id: "material", title: "Material", icon: "sphere", sections: sections, trailing: _jsx(SectionAction, { label: "Remove material assignment", onClick: () => onMeta(clearMaterialAssignmentPatch(), "component:remove-material"), children: "remove" }), children: _jsxs("div", { className: "text-[11px] text-neutral-300", children: ["materialId: ", _jsx("span", { className: NUMERIC, children: String(marker.meta?.["materialId"]) })] }) })) : null, hasGenerator ? (_jsx(Section, { id: "generator", title: "Generator", icon: "cube", sections: sections, children: _jsx(GeneratorInspector, { meta: marker.meta, onMeta: onMeta }) })) : null, !hasKindParams && !hasTrigger && !hasMaterial && !hasGenerator && !canAuthorTrigger("marker") ? (_jsx(EmptyState, { icon: "settings", title: "No components", description: "This marker has no optional components. Games that register trigger actions enable Add trigger here." })) : null] }));
206
- body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
248
+ body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips, setting: readAnimationSetting(marker.meta), onChange: (next) => onMeta(animationMetaPatch(next), "animation") }) })) : null, marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
207
249
  }
208
250
  else if (selectedVolume !== undefined) {
209
251
  const volume = selectedVolume;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
4
4
  /**
5
5
  * Searchable parent-target menu for hierarchy / viewport "Parent to…".
@@ -8,17 +8,13 @@ import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
8
8
  */
9
9
  export function ParentPickerMenu({ x, y, candidates, onPick, onClose, }) {
10
10
  const [query, setQuery] = useState("");
11
- const inputRef = useRef(null);
12
- useEffect(() => {
13
- inputRef.current?.focus();
14
- }, []);
15
11
  const filtered = useMemo(() => {
16
12
  const q = query.trim().toLowerCase();
17
13
  if (q.length === 0)
18
14
  return candidates;
19
15
  return candidates.filter((entry) => entry.label.toLowerCase().includes(q) || entry.id.toLowerCase().includes(q));
20
16
  }, [candidates, query]);
21
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", { ref: inputRef, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
17
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", { autoFocus: true, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
22
18
  if (event.key === "Escape") {
23
19
  event.preventDefault();
24
20
  onClose();
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { useFrame } from "@react-three/fiber";
4
4
  import { useGameContext } from "@jgengine/react/provider";
5
5
  import { MICRO } from "./chromeStyles.js";
6
- import { PlayModeBar } from "./shell/PlayModeBar.js";
6
+ import { PlayModeBar, usePlayControl } from "./shell/PlayModeBar.js";
7
7
  const PUBLISH_MS = 100;
8
8
  function metaValues(meta) {
9
9
  if (meta === null || typeof meta !== "object" || Array.isArray(meta))
@@ -83,11 +83,9 @@ export function RuntimePlayPublisher({ api }) {
83
83
  * @internal
84
84
  */
85
85
  export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
86
- const [play, setPlay] = useState(() => api.getPlayControl());
86
+ const play = usePlayControl(api);
87
87
  const [summary, setSummary] = useState(null);
88
88
  const [selectedId, setSelectedId] = useState(null);
89
- const [detail, setDetail] = useState("");
90
- useEffect(() => api.subscribePlayControl(setPlay), [api]);
91
89
  useEffect(() => {
92
90
  const tick = () => {
93
91
  const result = api.handle({ method: "runtime_summary" });
@@ -99,17 +97,14 @@ export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
99
97
  const timer = setInterval(tick, PUBLISH_MS);
100
98
  return () => clearInterval(timer);
101
99
  }, [api]);
102
- useEffect(() => {
103
- if (selectedId === null) {
104
- setDetail("");
105
- return;
106
- }
100
+ const detail = useMemo(() => {
101
+ if (selectedId === null)
102
+ return "";
107
103
  const got = api.handle({ method: "runtime_get", id: selectedId });
108
- if (!got.ok) {
109
- setDetail(got.error ?? "missing");
110
- return;
111
- }
112
- setDetail(JSON.stringify(got.result, null, 2));
104
+ if (!got.ok)
105
+ return got.error ?? "missing";
106
+ return JSON.stringify(got.result, null, 2);
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- entityCount/play are poll-tick cache keys
113
108
  }, [api, selectedId, summary?.entityCount, play.pendingSteps, play.paused]);
114
109
  const nudgeSelected = (axis, delta) => {
115
110
  if (selectedId === null)
@@ -9,11 +9,13 @@ export type MetaPatch = (patch: Record<string, unknown>, coalesce: string) => vo
9
9
  * seam owns the inspector. Replaces the hand-written per-kind field blocks (`ScatterFields`).
10
10
  * @internal — mounted by `EditorChrome`'s inspector; not a game-author entry point.
11
11
  */
12
- export declare function SchemaInspector({ schema, label, accent, note, meta, onMeta, }: {
12
+ export declare function SchemaInspector({ schema, label, accent, note, meta, onMeta, hideKeys, }: {
13
13
  schema: ParamSchema;
14
14
  label: string;
15
15
  accent?: string;
16
16
  note?: string;
17
17
  meta: Record<string, unknown> | undefined;
18
18
  onMeta: MetaPatch;
19
+ /** Field keys to omit from this body — hoisted elsewhere (e.g. the density slider in `CoverageSection`). */
20
+ hideKeys?: readonly string[];
19
21
  }): import("react").JSX.Element;