@jgengine/editor 0.10.0 → 0.11.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 +1 -1
- package/dist/EditorApp.js +64 -5
- package/dist/EditorChrome.js +14 -2
- package/dist/PerfProbe.js +36 -2
- package/dist/TerrainReadout.d.ts +17 -0
- package/dist/TerrainReadout.js +163 -0
- package/dist/TerrainReadoutHud.d.ts +9 -0
- package/dist/TerrainReadoutHud.js +20 -0
- package/dist/mcp/rpcRequest.js +10 -0
- package/dist/mcp/tools.js +149 -0
- package/dist/perfPill.d.ts +21 -0
- package/dist/perfPill.js +20 -0
- package/dist/session.d.ts +90 -1
- package/dist/session.js +139 -1
- package/dist/terrainReadoutStore.d.ts +19 -0
- package/dist/terrainReadoutStore.js +36 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +3 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +32 -804
- package/skills/jgengine/api.md +20 -2
- package/skills/jgengine/capabilities.md +8 -0
- package/skills/jgengine-editor/SKILL.md +20 -368
- package/skills/jgengine-editor/api.md +122 -0
- package/skills/jgengine-editor/capabilities.md +45 -0
- package/skills/jgengine-editor/reference.md +26 -0
- package/skills/jgengine/reference-orphans.md +0 -9
- package/skills/jgengine/reference-systems.md +0 -60
package/CHANGELOG.md
CHANGED
package/dist/EditorApp.js
CHANGED
|
@@ -12,7 +12,10 @@ import { MaterialDropZone } from "./MaterialDropZone.js";
|
|
|
12
12
|
import { PerfProbe } from "./PerfProbe.js";
|
|
13
13
|
import { ScatterPreview } from "./ScatterPreview.js";
|
|
14
14
|
import { SelectionGizmo, ViewportSelect } from "./SelectionGizmo.js";
|
|
15
|
+
import { TerrainReadout } from "./TerrainReadout.js";
|
|
16
|
+
import { TerrainReadoutHud } from "./TerrainReadoutHud.js";
|
|
15
17
|
import { TerrainSculpt } from "./TerrainSculpt.js";
|
|
18
|
+
import { createTerrainReadoutStore } from "./terrainReadoutStore.js";
|
|
16
19
|
import { RuntimePlayInspectorChrome, RuntimePlayPublisher } from "./RuntimePlayBridge.js";
|
|
17
20
|
import { createEditorHost } from "./session.js";
|
|
18
21
|
import { createEditorUiStore } from "./uiStore.js";
|
|
@@ -82,7 +85,29 @@ function savePrefs(gameId, prefs) {
|
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
|
-
|
|
88
|
+
/** Default readout region half-extent when a world declares no terrain bounds. */
|
|
89
|
+
const DEFAULT_READOUT_HALF = 64;
|
|
90
|
+
/**
|
|
91
|
+
* Derives the XZ region the terrain-readability overlay samples: the sculpt snapshot's rect if the
|
|
92
|
+
* world carries one, else the terrain descriptor's centered bounds, else a default square. Guides are
|
|
93
|
+
* editor visualization, not authored content — the region is inferred, never stored in the scene.
|
|
94
|
+
*/
|
|
95
|
+
function readoutRegionFor(world) {
|
|
96
|
+
if (world !== undefined && world.kind === "environment") {
|
|
97
|
+
const sculpt = world.sculpt;
|
|
98
|
+
if (sculpt !== undefined) {
|
|
99
|
+
return { minX: sculpt.bounds.minX, minZ: sculpt.bounds.minZ, maxX: sculpt.bounds.maxX, maxZ: sculpt.bounds.maxZ };
|
|
100
|
+
}
|
|
101
|
+
const bounds = world.terrain?.bounds;
|
|
102
|
+
if (bounds !== undefined) {
|
|
103
|
+
return { minX: -bounds.w / 2, minZ: -bounds.d / 2, maxX: bounds.w / 2, maxZ: bounds.d / 2 };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { minX: -DEFAULT_READOUT_HALF, minZ: -DEFAULT_READOUT_HALF, maxX: DEFAULT_READOUT_HALF, maxZ: DEFAULT_READOUT_HALF };
|
|
107
|
+
}
|
|
108
|
+
/** Idle window before the terrain overlay rebuilds off the edited field — bounds rebuild frequency during sculpting. */
|
|
109
|
+
const READOUT_REBUILD_MS = 250;
|
|
110
|
+
function EditorWorldOverlay({ api, ui, world, readout, }) {
|
|
86
111
|
const session = api.getSession();
|
|
87
112
|
const ctx = useGameContext();
|
|
88
113
|
const visibilityStore = useMemo(() => ({ getState: api.getVisibility, subscribe: api.subscribeVisibility }), [api]);
|
|
@@ -90,10 +115,33 @@ function EditorWorldOverlay({ api, ui, world, }) {
|
|
|
90
115
|
const selection = useStoreSelector(session, (s) => s.selection, shallowArrayEqual);
|
|
91
116
|
const visibility = useStoreSelector(visibilityStore, (v) => v);
|
|
92
117
|
const showGrid = useStoreSelector(ui, (s) => s.showGrid);
|
|
118
|
+
const showContours = useStoreSelector(ui, (s) => s.showContours);
|
|
119
|
+
const showSurfaceGrid = useStoreSelector(ui, (s) => s.showSurfaceGrid);
|
|
120
|
+
const showElevation = useStoreSelector(ui, (s) => s.showElevation);
|
|
93
121
|
const pathPoint = useStoreSelector(ui, (s) => s.pathPoint);
|
|
94
122
|
const pathDraft = useStoreSelector(ui, (s) => s.pathDraft);
|
|
95
123
|
const groundHeightAt = useCallback((x, z) => ctx.world.groundHeightAt(x, z), [ctx.world]);
|
|
96
|
-
|
|
124
|
+
const readoutRegion = useMemo(() => readoutRegionFor(world), [world]);
|
|
125
|
+
const readoutActive = showContours || showSurfaceGrid || showElevation;
|
|
126
|
+
// Debounced terrain version: rebuilds the overlay after sculpt edits settle, not every stroke frame.
|
|
127
|
+
const [terrainVersion, setTerrainVersion] = useState(0);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!readoutActive)
|
|
130
|
+
return;
|
|
131
|
+
setTerrainVersion((value) => value + 1);
|
|
132
|
+
let timer = null;
|
|
133
|
+
const unsubscribe = session.subscribe(() => {
|
|
134
|
+
if (timer !== null)
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
timer = setTimeout(() => setTerrainVersion((value) => value + 1), READOUT_REBUILD_MS);
|
|
137
|
+
});
|
|
138
|
+
return () => {
|
|
139
|
+
if (timer !== null)
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
unsubscribe();
|
|
142
|
+
};
|
|
143
|
+
}, [session, readoutActive]);
|
|
144
|
+
return (_jsxs(_Fragment, { children: [_jsx(PerfProbe, { api: api }), _jsx(EditorCameraDriver, { api: api }), _jsx(ViewportSelect, { api: api, ui: ui }), _jsx(MaterialDropZone, { api: api }), _jsx(TerrainSculpt, { api: api, ui: ui, world: world }), _jsx(ScatterPreview, { 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, 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 })] }));
|
|
97
145
|
}
|
|
98
146
|
/** Default skin for the play/walk escape chip; swap via `EditorAppProps.modeChip`, or pass `null` to hide it (F2+E still exits). */
|
|
99
147
|
function EditorModeChip({ mode, onExit }) {
|
|
@@ -146,6 +194,10 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip }
|
|
|
146
194
|
if (uiStoreRef.current === null)
|
|
147
195
|
uiStoreRef.current = createEditorUiStore();
|
|
148
196
|
const ui = uiStoreRef.current;
|
|
197
|
+
const readoutStoreRef = useRef(null);
|
|
198
|
+
if (readoutStoreRef.current === null)
|
|
199
|
+
readoutStoreRef.current = createTerrainReadoutStore();
|
|
200
|
+
const readout = readoutStoreRef.current;
|
|
149
201
|
const [mode, setModeState] = useState("edit");
|
|
150
202
|
const catalogAssets = useMemo(() => {
|
|
151
203
|
try {
|
|
@@ -211,6 +263,9 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip }
|
|
|
211
263
|
...(prefs.snapMode === undefined ? {} : { snapMode: prefs.snapMode }),
|
|
212
264
|
...(prefs.gridSize === undefined ? {} : { gridSize: prefs.gridSize }),
|
|
213
265
|
...(prefs.showGrid === undefined ? {} : { showGrid: prefs.showGrid }),
|
|
266
|
+
...(prefs.showContours === undefined ? {} : { showContours: prefs.showContours }),
|
|
267
|
+
...(prefs.showSurfaceGrid === undefined ? {} : { showSurfaceGrid: prefs.showSurfaceGrid }),
|
|
268
|
+
...(prefs.showElevation === undefined ? {} : { showElevation: prefs.showElevation }),
|
|
214
269
|
});
|
|
215
270
|
const persist = () => {
|
|
216
271
|
const state = ui.getState();
|
|
@@ -219,6 +274,9 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip }
|
|
|
219
274
|
snapMode: state.snapMode,
|
|
220
275
|
gridSize: state.gridSize,
|
|
221
276
|
showGrid: state.showGrid,
|
|
277
|
+
showContours: state.showContours,
|
|
278
|
+
showSurfaceGrid: state.showSurfaceGrid,
|
|
279
|
+
showElevation: state.showElevation,
|
|
222
280
|
});
|
|
223
281
|
};
|
|
224
282
|
const unsubscribeVisibility = host.api.subscribeVisibility(persist);
|
|
@@ -289,7 +347,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip }
|
|
|
289
347
|
};
|
|
290
348
|
}
|
|
291
349
|
const WorldOverlay = function EditorOverlay() {
|
|
292
|
-
return _jsx(EditorWorldOverlay, { api: host.api, ui: ui, world: playable.game.world });
|
|
350
|
+
return _jsx(EditorWorldOverlay, { api: host.api, ui: ui, world: playable.game.world, readout: readout });
|
|
293
351
|
};
|
|
294
352
|
const GameUI = function EditorUi() {
|
|
295
353
|
return _jsx(EditorChrome, { gameId: gameId, session: host.api.getSession(), api: host.api, assets: catalogAssets, ui: ui, baselineJson: host.baselineJson, save: saveFn });
|
|
@@ -328,6 +386,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip }
|
|
|
328
386
|
},
|
|
329
387
|
},
|
|
330
388
|
};
|
|
331
|
-
}, [playable, host, gameId, initialCamera, catalogAssets, ui, mode, saveFn, resolvedModeChip]);
|
|
332
|
-
|
|
389
|
+
}, [playable, host, gameId, initialCamera, catalogAssets, ui, readout, mode, saveFn, resolvedModeChip]);
|
|
390
|
+
const showElevation = useStoreSelector(ui, (s) => s.showElevation);
|
|
391
|
+
return (_jsxs("div", { className: "relative h-full w-full bg-neutral-950", "data-jg-editor": "1", "data-jg-editor-game": gameId, children: [_jsx(GamePlayerShell, { playable: editorPlayable }), mode === "edit" && showElevation ? _jsx(TerrainReadoutHud, { readout: readout }) : null, pendingDraft !== null && mode === "edit" ? (_jsxs("div", { className: "absolute left-1/2 top-14 z-[60] flex -translate-x-1/2 items-center gap-3 rounded-xl border border-amber-400/30 bg-amber-950/90 px-4 py-2 text-xs text-amber-100 shadow-2xl shadow-black/50 backdrop-blur-md", children: [_jsx("span", { children: "Unsaved edits from a previous session found." }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-amber-500 to-amber-600 px-2.5 py-1 font-semibold text-white shadow-md shadow-amber-950/50 transition-colors hover:from-amber-400 hover:to-amber-500", onClick: restoreDraft, children: "Restore" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.07] px-2.5 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15", onClick: discardDraft, children: "Discard" })] })) : null] }));
|
|
333
392
|
}
|
package/dist/EditorChrome.js
CHANGED
|
@@ -13,6 +13,7 @@ import { OutlinerPanel } from "./OutlinerPanel.js";
|
|
|
13
13
|
import { PrefabsPanel } from "./PrefabsPanel.js";
|
|
14
14
|
import { buildEditorContextMenu, } from "./viewportContextMenu.js";
|
|
15
15
|
import { buildOutlinerGroups } from "./outlinerModel.js";
|
|
16
|
+
import { classifyEditorPerf } from "./perfPill.js";
|
|
16
17
|
import { newPlacementId } from "./uiStore.js";
|
|
17
18
|
import { useF2Chord } from "./useF2Chord.js";
|
|
18
19
|
import { BTN, MICRO } from "./chromeStyles.js";
|
|
@@ -50,6 +51,17 @@ function formatTriangles(count) {
|
|
|
50
51
|
return `${(count / 1_000).toFixed(0)}k`;
|
|
51
52
|
return String(count);
|
|
52
53
|
}
|
|
54
|
+
const PERF_TONE_CLASS = {
|
|
55
|
+
idle: "text-neutral-500",
|
|
56
|
+
healthy: "text-emerald-400",
|
|
57
|
+
busy: "text-rose-400",
|
|
58
|
+
};
|
|
59
|
+
/** Toolbar perf pill: neutral "idle" when the loop is throttled/at rest, red only for active low fps. */
|
|
60
|
+
function PerfPill({ perf }) {
|
|
61
|
+
const tone = classifyEditorPerf(perf);
|
|
62
|
+
const rate = tone === "idle" ? "idle" : `${perf.fps.toFixed(0)} fps`;
|
|
63
|
+
return (_jsxs("span", { className: `ml-3 ${PERF_TONE_CLASS[tone]}`, children: [rate, " \u00B7 ", perf.drawCalls, " draws \u00B7 ", formatTriangles(perf.triangles), " tris"] }));
|
|
64
|
+
}
|
|
53
65
|
let clipboardFragment = null;
|
|
54
66
|
const SHORTCUTS = [
|
|
55
67
|
{ keys: "W / E / R", action: "Move · rotate · scale gizmo" },
|
|
@@ -534,7 +546,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
|
|
|
534
546
|
: { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Other" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "note" }), children: "Note" })] })) : null] }), _jsxs("button", { type: "button", className: BTN, onClick: () => {
|
|
535
547
|
const at = SNAP_MODES.findIndex((entry) => entry.mode === uiState.snapMode);
|
|
536
548
|
ui.patch({ snapMode: SNAP_MODES[(at + 1) % SNAP_MODES.length].mode });
|
|
537
|
-
}, children: [SNAP_MODES.find((entry) => entry.mode === uiState.snapMode)?.label, uiState.snapMode === "grid" ? ` ${uiState.gridSize}` : ""] }), uiState.snapMode === "grid" ? (_jsx("button", { type: "button", className: BTN, onClick: () => ui.patch({ gridSize: uiState.gridSize >= 8 ? 0.5 : uiState.gridSize * 2 }), children: "\u00B1" })) : null, _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showGrid: !uiState.showGrid }), children: "Grid G" }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.handle({ method: "camera_frame" }), children: "Frame all" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "redo" }), disabled: !session.canRedo(), children: "Redo" }), _jsx("button", { type: "button", className: BTN, onClick: () => showPanel("assets"), children: "Assets" }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx("button", { type: "button", className: BTN, onClick: () => setRightOpen((value) => !value), children: "Inspector" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset transition-colors ${agentOpen
|
|
549
|
+
}, children: [SNAP_MODES.find((entry) => entry.mode === uiState.snapMode)?.label, uiState.snapMode === "grid" ? ` ${uiState.gridSize}` : ""] }), uiState.snapMode === "grid" ? (_jsx("button", { type: "button", className: BTN, onClick: () => ui.patch({ gridSize: uiState.gridSize >= 8 ? 0.5 : uiState.gridSize * 2 }), children: "\u00B1" })) : null, _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showGrid: !uiState.showGrid }), children: "Grid G" }), _jsx("button", { type: "button", title: "Surface-following iso-elevation contours", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showContours ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showContours: !uiState.showContours }), children: "Contours" }), _jsx("button", { type: "button", title: "Terrain-draped reference grid", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showSurfaceGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), children: "Drape" }), _jsx("button", { type: "button", title: "Measurable elevation readout", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showElevation ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showElevation: !uiState.showElevation }), children: "Elev" }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.handle({ method: "camera_frame" }), children: "Frame all" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "redo" }), disabled: !session.canRedo(), children: "Redo" }), _jsx("button", { type: "button", className: BTN, onClick: () => showPanel("assets"), children: "Assets" }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx("button", { type: "button", className: BTN, onClick: () => setRightOpen((value) => !value), children: "Inspector" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset transition-colors ${agentOpen
|
|
538
550
|
? "bg-violet-500/25 text-violet-100 ring-violet-400/40"
|
|
539
551
|
: "bg-white/[0.04] text-neutral-300 ring-white/[0.06] hover:bg-white/10 hover:text-neutral-100"}`, onClick: () => setAgentOpen((value) => !value), title: "Embedded agent panel \u2014 same RPC/undo as the GUI", children: "Agent" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.setMode("walk"), children: "Walk" }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-emerald-500 to-emerald-600 px-3 py-1 font-semibold text-white shadow-md shadow-emerald-950/50 transition-colors hover:from-emerald-400 hover:to-emerald-500", onClick: () => api.setMode("play"), children: "\u25B6 Play" }), _jsx("button", { type: "button", className: BTN, onClick: () => importInputRef.current?.click(), children: "Import" }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
|
|
540
552
|
const file = event.target.files?.[0];
|
|
@@ -555,5 +567,5 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, s
|
|
|
555
567
|
hitId: uiState.contextMenu.hitId,
|
|
556
568
|
selection: state.selection,
|
|
557
569
|
canPaste: clipboardFragment !== null,
|
|
558
|
-
}), onPick: runContextAction, onClose: closeContextMenu })) : null, _jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/[0.08] bg-black/70 px-4 py-1.5 text-[11px] text-neutral-400 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("span", { children: "RMB orbit \u00B7 MMB pan \u00B7 click select \u00B7 RMB menu \u00B7 W/E/R \u00B7 Ctrl+C/V \u00B7 F frame \u00B7 ? help" }), _jsxs("span", { className: "ml-3 text-neutral-500", children: [sceneStats.objects, " objs", sceneStats.foliage > 0 ? ` · ≈${formatTriangles(sceneStats.foliage)} foliage` : ""] }), perf !== null ?
|
|
570
|
+
}), onPick: runContextAction, onClose: closeContextMenu })) : null, _jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/[0.08] bg-black/70 px-4 py-1.5 text-[11px] text-neutral-400 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("span", { children: "RMB orbit \u00B7 MMB pan \u00B7 click select \u00B7 RMB menu \u00B7 W/E/R \u00B7 Ctrl+C/V \u00B7 F frame \u00B7 ? help" }), _jsxs("span", { className: "ml-3 text-neutral-500", children: [sceneStats.objects, " objs", sceneStats.foliage > 0 ? ` · ≈${formatTriangles(sceneStats.foliage)} foliage` : ""] }), perf !== null ? _jsx(PerfPill, { perf: perf }) : null] })] }), rightOpen ? _jsx(InspectorPanel, { session: session, ui: ui, onClose: () => setRightOpen(false) }) : null, agentOpen ? _jsx(AgentPanel, { api: api, onClose: () => setAgentOpen(false) }) : null] }), bottomOpen ? (_jsxs("section", { className: "pointer-events-auto flex h-64 min-h-40 max-h-[55vh] resize-y flex-col overflow-hidden border-t border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center border-b border-white/[0.08] px-3 py-2", children: [_jsx("div", { className: MICRO, children: "Asset browser" }), _jsxs("span", { className: "ml-2 text-neutral-600", children: [assets.length, " assets \u00B7 Ctrl+B"] }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setBottomOpen(false), "aria-label": "Close asset browser", children: "\u00D7" })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-hidden p-2", children: _jsx(AssetBrowser, { assets: assets, session: session, onPlace: placeAsset }) })] })) : null] }));
|
|
559
571
|
}
|
package/dist/PerfProbe.js
CHANGED
|
@@ -2,33 +2,67 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { memo, useRef } from "react";
|
|
3
3
|
import { editorPerfMarks } from "./perfMarks.js";
|
|
4
4
|
const SAMPLE_WINDOW_MS = 500;
|
|
5
|
+
/** Sum-of-abs delta on camera position/orientation above which a frame counts as camera activity. */
|
|
6
|
+
const CAMERA_MOVE_EPSILON = 1e-4;
|
|
5
7
|
/** In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. */
|
|
6
8
|
export const PerfProbe = memo(function PerfProbe({ api }) {
|
|
7
9
|
const gl = useThree((state) => state.gl);
|
|
10
|
+
const camera = useThree((state) => state.camera);
|
|
8
11
|
const framesRef = useRef(0);
|
|
9
12
|
const windowStartRef = useRef(0);
|
|
13
|
+
const activeRef = useRef(false);
|
|
14
|
+
const lastCamRef = useRef({ px: 0, py: 0, pz: 0, qx: 0, qy: 0, qz: 0, qw: 1 });
|
|
15
|
+
const lastDrawRef = useRef(-1);
|
|
16
|
+
const lastTriRef = useRef(-1);
|
|
10
17
|
useFrame(() => {
|
|
11
18
|
const now = performance.now();
|
|
12
19
|
if (windowStartRef.current === 0)
|
|
13
20
|
windowStartRef.current = now;
|
|
14
21
|
framesRef.current += 1;
|
|
22
|
+
// Detect camera activity frame-to-frame so a single moved frame inside the window still counts.
|
|
23
|
+
const cam = lastCamRef.current;
|
|
24
|
+
const dPos = Math.abs(camera.position.x - cam.px) +
|
|
25
|
+
Math.abs(camera.position.y - cam.py) +
|
|
26
|
+
Math.abs(camera.position.z - cam.pz);
|
|
27
|
+
const dRot = Math.abs(camera.quaternion.x - cam.qx) +
|
|
28
|
+
Math.abs(camera.quaternion.y - cam.qy) +
|
|
29
|
+
Math.abs(camera.quaternion.z - cam.qz) +
|
|
30
|
+
Math.abs(camera.quaternion.w - cam.qw);
|
|
31
|
+
if (dPos > CAMERA_MOVE_EPSILON || dRot > CAMERA_MOVE_EPSILON)
|
|
32
|
+
activeRef.current = true;
|
|
33
|
+
cam.px = camera.position.x;
|
|
34
|
+
cam.py = camera.position.y;
|
|
35
|
+
cam.pz = camera.position.z;
|
|
36
|
+
cam.qx = camera.quaternion.x;
|
|
37
|
+
cam.qy = camera.quaternion.y;
|
|
38
|
+
cam.qz = camera.quaternion.z;
|
|
39
|
+
cam.qw = camera.quaternion.w;
|
|
15
40
|
const elapsed = now - windowStartRef.current;
|
|
16
41
|
if (elapsed < SAMPLE_WINDOW_MS)
|
|
17
42
|
return;
|
|
18
43
|
const fps = (framesRef.current * 1000) / elapsed;
|
|
19
44
|
const authoring = editorPerfMarks.flush();
|
|
45
|
+
const drawCalls = gl.info.render.calls;
|
|
46
|
+
const triangles = gl.info.render.triangles;
|
|
47
|
+
// Edit activity: viewport raycasts / preview-mesh rebuilds, or the rendered scene changed.
|
|
48
|
+
const sceneChanged = lastDrawRef.current >= 0 && (drawCalls !== lastDrawRef.current || triangles !== lastTriRef.current);
|
|
49
|
+
const active = activeRef.current || authoring.authoringMs > 0 || sceneChanged;
|
|
50
|
+
lastDrawRef.current = drawCalls;
|
|
51
|
+
lastTriRef.current = triangles;
|
|
20
52
|
api.setPerf({
|
|
21
53
|
fps: Math.round(fps * 10) / 10,
|
|
22
54
|
frameMs: Math.round((elapsed / framesRef.current) * 100) / 100,
|
|
23
|
-
drawCalls
|
|
24
|
-
triangles
|
|
55
|
+
drawCalls,
|
|
56
|
+
triangles,
|
|
25
57
|
sampledAt: now,
|
|
58
|
+
active,
|
|
26
59
|
raycastMs: authoring.raycastMs,
|
|
27
60
|
rebuildMs: authoring.rebuildMs,
|
|
28
61
|
authoringMs: authoring.authoringMs,
|
|
29
62
|
});
|
|
30
63
|
framesRef.current = 0;
|
|
31
64
|
windowStartRef.current = now;
|
|
65
|
+
activeRef.current = false;
|
|
32
66
|
});
|
|
33
67
|
return null;
|
|
34
68
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type GuideRegion } from "@jgengine/core/world/terrainGuides";
|
|
2
|
+
import type { TerrainReadoutStore } from "./terrainReadoutStore.js";
|
|
3
|
+
/**
|
|
4
|
+
* Surface-following terrain-readability overlay: draws iso-elevation contour lines and an optional
|
|
5
|
+
* terrain-draped reference grid from the live ground field, and tracks the cursor's elevation. All
|
|
6
|
+
* geometry comes from headless `@jgengine/core/world/terrainGuides` math; this component only turns it
|
|
7
|
+
* into three.js lines and publishes the measured feedback to the readout store.
|
|
8
|
+
*/
|
|
9
|
+
export declare function TerrainReadout({ groundHeightAt, region, showContours, showSurfaceGrid, version, readout, }: {
|
|
10
|
+
groundHeightAt: (x: number, z: number) => number;
|
|
11
|
+
region: GuideRegion;
|
|
12
|
+
showContours: boolean;
|
|
13
|
+
showSurfaceGrid: boolean;
|
|
14
|
+
/** Bumped by the host when the terrain changes, so the overlay rebuilds off the fresh field. */
|
|
15
|
+
version: number;
|
|
16
|
+
readout: TerrainReadoutStore;
|
|
17
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { extractContours, sampleElevation, summarizeElevation, surfaceGridLines, surfaceRing, chooseContourInterval, } from "@jgengine/core/world/terrainGuides";
|
|
6
|
+
/** How often (seconds) the cursor readout re-samples — a bounded per-frame cost, not every frame. */
|
|
7
|
+
const CURSOR_SAMPLE_INTERVAL = 1 / 20;
|
|
8
|
+
/** Cap on marching-squares samples per axis — the overlay's explicit performance budget. */
|
|
9
|
+
const READOUT_MAX_SAMPLES = 200;
|
|
10
|
+
/** Small lift keeping guide lines off the surface so they never z-fight the terrain mesh. */
|
|
11
|
+
const DEPTH_BIAS = 0.08;
|
|
12
|
+
const MAJOR_CONTOUR_COLOR = "#f2c14e";
|
|
13
|
+
const MINOR_CONTOUR_COLOR = "#8aa0b8";
|
|
14
|
+
const MAJOR_GRID_COLOR = "#5b6b7d";
|
|
15
|
+
const MINOR_GRID_COLOR = "#39434f";
|
|
16
|
+
const CURSOR_RING_COLOR = "#ffd479";
|
|
17
|
+
/** Expands a flat `[x,y,z,...]` polyline into consecutive `LineSegments` vertex pairs. */
|
|
18
|
+
function polylineToSegments(points, into) {
|
|
19
|
+
const vertexCount = Math.floor(points.length / 3);
|
|
20
|
+
for (let i = 0; i < vertexCount - 1; i += 1) {
|
|
21
|
+
const a = i * 3;
|
|
22
|
+
const b = (i + 1) * 3;
|
|
23
|
+
into.push(points[a], points[a + 1], points[a + 2]);
|
|
24
|
+
into.push(points[b], points[b + 1], points[b + 2]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function lineSegments(positions, color, opacity) {
|
|
28
|
+
if (positions.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
const geometry = new THREE.BufferGeometry();
|
|
31
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
32
|
+
const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthTest: true });
|
|
33
|
+
return new THREE.LineSegments(geometry, material);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Surface-following terrain-readability overlay: draws iso-elevation contour lines and an optional
|
|
37
|
+
* terrain-draped reference grid from the live ground field, and tracks the cursor's elevation. All
|
|
38
|
+
* geometry comes from headless `@jgengine/core/world/terrainGuides` math; this component only turns it
|
|
39
|
+
* into three.js lines and publishes the measured feedback to the readout store.
|
|
40
|
+
*/
|
|
41
|
+
export function TerrainReadout({ groundHeightAt, region, showContours, showSurfaceGrid, version, readout, }) {
|
|
42
|
+
const regionKey = `${region.minX},${region.minZ},${region.maxX},${region.maxZ}`;
|
|
43
|
+
const guides = useMemo(() => {
|
|
44
|
+
const summary = summarizeElevation(groundHeightAt, region, 96, READOUT_MAX_SAMPLES);
|
|
45
|
+
const interval = chooseContourInterval(summary.range);
|
|
46
|
+
const objects = [];
|
|
47
|
+
if (showContours && interval > 0) {
|
|
48
|
+
const contours = extractContours(groundHeightAt, {
|
|
49
|
+
region,
|
|
50
|
+
interval,
|
|
51
|
+
resolution: 128,
|
|
52
|
+
maxSamples: READOUT_MAX_SAMPLES,
|
|
53
|
+
});
|
|
54
|
+
const major = [];
|
|
55
|
+
const minor = [];
|
|
56
|
+
for (const line of contours) {
|
|
57
|
+
const target = line.major ? major : minor;
|
|
58
|
+
for (let i = 0; i < line.segments.length; i += 2) {
|
|
59
|
+
target.push(line.segments[i], line.level + DEPTH_BIAS, line.segments[i + 1]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const majorObj = lineSegments(major, MAJOR_CONTOUR_COLOR, 0.9);
|
|
63
|
+
const minorObj = lineSegments(minor, MINOR_CONTOUR_COLOR, 0.5);
|
|
64
|
+
if (majorObj !== null)
|
|
65
|
+
objects.push(majorObj);
|
|
66
|
+
if (minorObj !== null)
|
|
67
|
+
objects.push(minorObj);
|
|
68
|
+
}
|
|
69
|
+
if (showSurfaceGrid) {
|
|
70
|
+
const spacing = interval > 0 ? Math.max(region.maxX - region.minX, region.maxZ - region.minZ) / 16 : 8;
|
|
71
|
+
const lines = surfaceGridLines(groundHeightAt, {
|
|
72
|
+
region,
|
|
73
|
+
spacing,
|
|
74
|
+
drape: { spacing: Math.max(1, spacing / 8), offset: DEPTH_BIAS },
|
|
75
|
+
});
|
|
76
|
+
const major = [];
|
|
77
|
+
const minor = [];
|
|
78
|
+
for (const line of lines)
|
|
79
|
+
polylineToSegments(line.points, line.major ? major : minor);
|
|
80
|
+
const majorObj = lineSegments(major, MAJOR_GRID_COLOR, 0.7);
|
|
81
|
+
const minorObj = lineSegments(minor, MINOR_GRID_COLOR, 0.4);
|
|
82
|
+
if (majorObj !== null)
|
|
83
|
+
objects.push(majorObj);
|
|
84
|
+
if (minorObj !== null)
|
|
85
|
+
objects.push(minorObj);
|
|
86
|
+
}
|
|
87
|
+
return { summary, interval, objects };
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [groundHeightAt, regionKey, showContours, showSurfaceGrid, version]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
readout.setSummary(guides.summary, guides.interval);
|
|
92
|
+
}, [guides, readout]);
|
|
93
|
+
useEffect(() => () => {
|
|
94
|
+
for (const object of guides.objects) {
|
|
95
|
+
if (object instanceof THREE.LineSegments) {
|
|
96
|
+
object.geometry.dispose();
|
|
97
|
+
object.material.dispose();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}, [guides]);
|
|
101
|
+
// Cursor ring — a surface-following placement guide draped under the pointer.
|
|
102
|
+
const ring = useMemo(() => {
|
|
103
|
+
const geometry = new THREE.BufferGeometry();
|
|
104
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(48 * 2 * 3), 3));
|
|
105
|
+
const material = new THREE.LineBasicMaterial({ color: CURSOR_RING_COLOR, transparent: true, opacity: 0.85 });
|
|
106
|
+
const segments = new THREE.LineSegments(geometry, material);
|
|
107
|
+
segments.visible = false;
|
|
108
|
+
return segments;
|
|
109
|
+
}, []);
|
|
110
|
+
useEffect(() => () => {
|
|
111
|
+
ring.geometry.dispose();
|
|
112
|
+
ring.material.dispose();
|
|
113
|
+
}, [ring]);
|
|
114
|
+
const { camera, pointer, raycaster } = useThree();
|
|
115
|
+
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
|
|
116
|
+
const hitRef = useRef(new THREE.Vector3());
|
|
117
|
+
const accum = useRef(0);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
planeRef.current.constant = -(guides.summary.mean ?? 0);
|
|
120
|
+
}, [guides.summary]);
|
|
121
|
+
useFrame((_, delta) => {
|
|
122
|
+
accum.current += delta;
|
|
123
|
+
if (accum.current < CURSOR_SAMPLE_INTERVAL)
|
|
124
|
+
return;
|
|
125
|
+
accum.current = 0;
|
|
126
|
+
raycaster.setFromCamera(pointer, camera);
|
|
127
|
+
const hit = raycaster.ray.intersectPlane(planeRef.current, hitRef.current);
|
|
128
|
+
if (hit === null ||
|
|
129
|
+
hit.x < region.minX ||
|
|
130
|
+
hit.x > region.maxX ||
|
|
131
|
+
hit.z < region.minZ ||
|
|
132
|
+
hit.z > region.maxZ) {
|
|
133
|
+
readout.setCursor(null);
|
|
134
|
+
if (ring.visible)
|
|
135
|
+
ring.visible = false;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const elevation = sampleElevation(groundHeightAt, hit.x, hit.z);
|
|
139
|
+
readout.setCursor(elevation);
|
|
140
|
+
const radius = Math.max(1.5, (region.maxX - region.minX) / 48);
|
|
141
|
+
const ringPoints = surfaceRing(groundHeightAt, [hit.x, hit.z], radius, 48, { offset: DEPTH_BIAS });
|
|
142
|
+
const attribute = ring.geometry.getAttribute("position");
|
|
143
|
+
const array = attribute.array;
|
|
144
|
+
let w = 0;
|
|
145
|
+
const vertexCount = Math.floor(ringPoints.length / 3);
|
|
146
|
+
for (let i = 0; i < vertexCount - 1 && w + 6 <= array.length; i += 1) {
|
|
147
|
+
const a = i * 3;
|
|
148
|
+
const b = (i + 1) * 3;
|
|
149
|
+
array[w++] = ringPoints[a];
|
|
150
|
+
array[w++] = ringPoints[a + 1];
|
|
151
|
+
array[w++] = ringPoints[a + 2];
|
|
152
|
+
array[w++] = ringPoints[b];
|
|
153
|
+
array[w++] = ringPoints[b + 1];
|
|
154
|
+
array[w++] = ringPoints[b + 2];
|
|
155
|
+
}
|
|
156
|
+
while (w < array.length)
|
|
157
|
+
array[w++] = 0;
|
|
158
|
+
attribute.needsUpdate = true;
|
|
159
|
+
ring.geometry.setDrawRange(0, Math.max(0, (vertexCount - 1) * 2));
|
|
160
|
+
ring.visible = true;
|
|
161
|
+
});
|
|
162
|
+
return (_jsxs("group", { children: [guides.objects.map((object, index) => (_jsx("primitive", { object: object }, index))), _jsx("primitive", { object: ring })] }));
|
|
163
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TerrainReadoutStore } from "./terrainReadoutStore.js";
|
|
2
|
+
/**
|
|
3
|
+
* The measurable terrain-readability legend: cursor elevation and delta from the `y = 0` reference,
|
|
4
|
+
* plus the region's min/max/mean relief and the active contour interval. Reads live measurements from
|
|
5
|
+
* the readout store the {@link TerrainReadout} overlay publishes; renders nothing until shown.
|
|
6
|
+
*/
|
|
7
|
+
export declare function TerrainReadoutHud({ readout }: {
|
|
8
|
+
readout: TerrainReadoutStore;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useStoreSelector } from "./useStoreSelector.js";
|
|
3
|
+
function fmt(value) {
|
|
4
|
+
return (Math.round(value * 100) / 100).toFixed(2);
|
|
5
|
+
}
|
|
6
|
+
function signed(value) {
|
|
7
|
+
const text = fmt(value);
|
|
8
|
+
return value > 0 ? `+${text}` : text;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The measurable terrain-readability legend: cursor elevation and delta from the `y = 0` reference,
|
|
12
|
+
* plus the region's min/max/mean relief and the active contour interval. Reads live measurements from
|
|
13
|
+
* the readout store the {@link TerrainReadout} overlay publishes; renders nothing until shown.
|
|
14
|
+
*/
|
|
15
|
+
export function TerrainReadoutHud({ readout }) {
|
|
16
|
+
const cursor = useStoreSelector(readout, (s) => s.cursor);
|
|
17
|
+
const summary = useStoreSelector(readout, (s) => s.summary);
|
|
18
|
+
const interval = useStoreSelector(readout, (s) => s.interval);
|
|
19
|
+
return (_jsxs("div", { className: "pointer-events-none absolute bottom-3 left-3 z-40 min-w-[180px] rounded-lg border border-white/[0.08] bg-black/70 px-3 py-2 font-mono text-[11px] leading-relaxed text-neutral-200 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("div", { className: "mb-1 text-[10px] font-semibold uppercase tracking-widest text-neutral-500", children: "Elevation" }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "cursor" }), _jsx("span", { children: cursor === null ? "—" : `${fmt(cursor.height)} (${signed(cursor.delta)})` })] }), summary !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "min / max" }), _jsxs("span", { children: [fmt(summary.min), " / ", fmt(summary.max)] })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "mean / relief" }), _jsxs("span", { children: [fmt(summary.mean), " / ", fmt(summary.range)] })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "contour" }), _jsx("span", { children: interval > 0 ? `${fmt(interval)} m` : "flat" })] })] })) : (_jsx("div", { className: "text-neutral-500", children: "no terrain sampled" }))] }));
|
|
20
|
+
}
|
package/dist/mcp/rpcRequest.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/** Every `method` an {@link EditorBridgeRequest} may carry — kept in lockstep with the union in `session.ts`. */
|
|
2
2
|
const EDITOR_BRIDGE_METHODS = new Set([
|
|
3
3
|
"add_foliage",
|
|
4
|
+
"add_grid_layer",
|
|
4
5
|
"add_to_collection",
|
|
5
6
|
"assign_material",
|
|
7
|
+
"fill_grid_rect",
|
|
8
|
+
"flood_fill_grid",
|
|
9
|
+
"get_grid_cell",
|
|
10
|
+
"import_grid",
|
|
11
|
+
"list_grids",
|
|
12
|
+
"paint_grid_cells",
|
|
13
|
+
"remove_grid_layer",
|
|
14
|
+
"resize_grid_layer",
|
|
15
|
+
"set_grid_layer",
|
|
6
16
|
"auto_paint",
|
|
7
17
|
"batch_set_properties",
|
|
8
18
|
"blend_terrain",
|