@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.
- package/CHANGELOG.md +529 -1
- package/dist/ClipPreviewLayer.d.ts +15 -0
- package/dist/ClipPreviewLayer.js +60 -0
- package/dist/EditorApp.js +4 -3
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +4 -6
- package/dist/InspectorPanel.d.ts +4 -1
- package/dist/InspectorPanel.js +47 -5
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.d.ts +3 -1
- package/dist/SchemaInspector.js +63 -32
- package/dist/TerrainPanel.js +3 -4
- package/dist/TerrainReadout.js +1 -3
- package/dist/agent/AgentPanel.js +7 -11
- package/dist/camera/orbitFraming.d.ts +62 -0
- package/dist/camera/orbitFraming.js +69 -0
- package/dist/chromeFields.js +9 -4
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +78 -9
- package/dist/mcp/fieldSpec.js +4 -2
- package/dist/mcp/tools.js +4 -2
- package/dist/modelAnimationAuthoring.d.ts +75 -0
- package/dist/modelAnimationAuthoring.js +164 -0
- package/dist/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.d.ts +10 -8
- package/dist/shell/AnimationPanel.js +120 -26
- package/dist/shell/BottomDock.d.ts +3 -1
- package/dist/shell/BottomDock.js +2 -2
- package/dist/shell/CommandPalette.js +1 -5
- package/dist/shell/HierarchyPanel.js +17 -26
- package/dist/shell/PlayModeBar.d.ts +7 -0
- package/dist/shell/PlayModeBar.js +12 -3
- package/dist/shell/SceneToolbar.js +9 -5
- package/dist/shell/clipPreview.d.ts +85 -0
- package/dist/shell/clipPreview.js +82 -0
- package/dist/shell/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.d.ts +22 -0
- package/dist/shell/scatterCoverage.js +40 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +1 -0
- package/dist/useStoreSelector.d.ts +12 -0
- package/dist/useStoreSelector.js +23 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-editor/api.md +56 -4
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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));
|
package/dist/EditorChrome.js
CHANGED
|
@@ -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
|
}
|
package/dist/InspectorPanel.d.ts
CHANGED
|
@@ -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;
|
package/dist/InspectorPanel.js
CHANGED
|
@@ -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: [
|
|
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
|
|
53
|
-
|
|
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;
|
package/dist/ParentPickerMenu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
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", {
|
|
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
|
|
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
|
-
|
|
103
|
-
if (selectedId === null)
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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;
|