@jgengine/editor 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -0
  3. package/dist/AssetBrowser.d.ts +33 -1
  4. package/dist/AssetBrowser.js +64 -2
  5. package/dist/AssetDropZone.d.ts +9 -0
  6. package/dist/AssetDropZone.js +65 -0
  7. package/dist/CatalogsPanel.d.ts +4 -2
  8. package/dist/CatalogsPanel.js +162 -9
  9. package/dist/CityPreview.d.ts +11 -0
  10. package/dist/CityPreview.js +45 -0
  11. package/dist/CollectionsPanel.js +5 -5
  12. package/dist/DebugDraw.d.ts +3 -1
  13. package/dist/DebugDraw.js +37 -19
  14. package/dist/EditorApp.d.ts +15 -1
  15. package/dist/EditorApp.js +147 -35
  16. package/dist/EditorChrome.d.ts +27 -6
  17. package/dist/EditorChrome.js +314 -130
  18. package/dist/InspectorPanel.d.ts +17 -7
  19. package/dist/InspectorPanel.js +173 -27
  20. package/dist/LightingPanel.d.ts +11 -0
  21. package/dist/LightingPanel.js +99 -0
  22. package/dist/MaterialsWorkspacePanel.d.ts +12 -0
  23. package/dist/MaterialsWorkspacePanel.js +86 -0
  24. package/dist/ParentPickerMenu.d.ts +13 -0
  25. package/dist/ParentPickerMenu.js +33 -0
  26. package/dist/PerfProbe.js +33 -0
  27. package/dist/PrefabsPanel.js +5 -5
  28. package/dist/RuntimePlayBridge.d.ts +5 -3
  29. package/dist/RuntimePlayBridge.js +6 -13
  30. package/dist/ScatterPreview.d.ts +3 -1
  31. package/dist/ScatterPreview.js +10 -7
  32. package/dist/SchemaInspector.js +21 -8
  33. package/dist/ScriptingPanel.d.ts +15 -0
  34. package/dist/ScriptingPanel.js +46 -0
  35. package/dist/SelectionGizmo.js +114 -30
  36. package/dist/StandaloneEditor.d.ts +16 -9
  37. package/dist/StandaloneEditor.js +19 -20
  38. package/dist/TerrainPanel.d.ts +3 -2
  39. package/dist/TerrainPanel.js +16 -5
  40. package/dist/TerrainSculpt.js +98 -59
  41. package/dist/agent/AgentPanel.d.ts +5 -2
  42. package/dist/agent/AgentPanel.js +11 -6
  43. package/dist/agent/toolBridge.js +5 -0
  44. package/dist/assetImport.d.ts +36 -0
  45. package/dist/assetImport.js +66 -0
  46. package/dist/authoredComponentMeta.d.ts +25 -0
  47. package/dist/authoredComponentMeta.js +65 -0
  48. package/dist/gizmoPivot.d.ts +4 -0
  49. package/dist/gizmoPivot.js +34 -0
  50. package/dist/handlers/catalogs.d.ts +3 -0
  51. package/dist/handlers/catalogs.js +137 -0
  52. package/dist/handlers/collections.d.ts +3 -0
  53. package/dist/handlers/collections.js +57 -0
  54. package/dist/handlers/context.d.ts +36 -0
  55. package/dist/handlers/context.js +1 -0
  56. package/dist/handlers/document.d.ts +3 -0
  57. package/dist/handlers/document.js +350 -0
  58. package/dist/handlers/grids.d.ts +3 -0
  59. package/dist/handlers/grids.js +142 -0
  60. package/dist/handlers/hierarchy.d.ts +3 -0
  61. package/dist/handlers/hierarchy.js +55 -0
  62. package/dist/handlers/index.d.ts +8 -0
  63. package/dist/handlers/index.js +25 -0
  64. package/dist/handlers/minimap.d.ts +9 -0
  65. package/dist/handlers/minimap.js +32 -0
  66. package/dist/handlers/runtime.d.ts +3 -0
  67. package/dist/handlers/runtime.js +191 -0
  68. package/dist/handlers/streets.d.ts +3 -0
  69. package/dist/handlers/streets.js +159 -0
  70. package/dist/handlers/terrain.d.ts +3 -0
  71. package/dist/handlers/terrain.js +204 -0
  72. package/dist/index.d.ts +3 -2
  73. package/dist/index.js +3 -2
  74. package/dist/listDocumentTriggers.d.ts +42 -0
  75. package/dist/listDocumentTriggers.js +49 -0
  76. package/dist/materialAssignments.d.ts +63 -0
  77. package/dist/materialAssignments.js +84 -0
  78. package/dist/mcp/fieldSpec.d.ts +32 -0
  79. package/dist/mcp/fieldSpec.js +174 -0
  80. package/dist/mcp/rpcRequest.d.ts +8 -5
  81. package/dist/mcp/rpcRequest.js +72 -93
  82. package/dist/mcp/tools.js +105 -912
  83. package/dist/networkSnapshot.d.ts +82 -0
  84. package/dist/networkSnapshot.js +73 -0
  85. package/dist/parentCandidates.d.ts +12 -0
  86. package/dist/parentCandidates.js +25 -0
  87. package/dist/session.d.ts +91 -4
  88. package/dist/session.js +36 -1017
  89. package/dist/shell/AnimationPanel.d.ts +17 -0
  90. package/dist/shell/AnimationPanel.js +134 -0
  91. package/dist/shell/AssetThumbnail.d.ts +12 -0
  92. package/dist/shell/AssetThumbnail.js +23 -0
  93. package/dist/shell/BottomDock.d.ts +25 -0
  94. package/dist/shell/BottomDock.js +40 -0
  95. package/dist/shell/CommandPalette.d.ts +12 -0
  96. package/dist/shell/CommandPalette.js +56 -0
  97. package/dist/shell/ConsolePanel.d.ts +8 -0
  98. package/dist/shell/ConsolePanel.js +45 -0
  99. package/dist/shell/ContentBrowser.d.ts +21 -0
  100. package/dist/shell/ContentBrowser.js +130 -0
  101. package/dist/shell/HierarchyPanel.d.ts +26 -0
  102. package/dist/shell/HierarchyPanel.js +387 -0
  103. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  104. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  105. package/dist/shell/PlayModeBar.d.ts +12 -0
  106. package/dist/shell/PlayModeBar.js +27 -0
  107. package/dist/shell/ProfilerPanel.d.ts +10 -0
  108. package/dist/shell/ProfilerPanel.js +41 -0
  109. package/dist/shell/SceneToolbar.d.ts +43 -0
  110. package/dist/shell/SceneToolbar.js +122 -0
  111. package/dist/shell/StatusBar.d.ts +18 -0
  112. package/dist/shell/StatusBar.js +41 -0
  113. package/dist/shell/TopAppBar.d.ts +35 -0
  114. package/dist/shell/TopAppBar.js +64 -0
  115. package/dist/shell/ViewportOverlays.d.ts +23 -0
  116. package/dist/shell/ViewportOverlays.js +136 -0
  117. package/dist/shell/WorkspaceRail.d.ts +20 -0
  118. package/dist/shell/WorkspaceRail.js +31 -0
  119. package/dist/shell/cameraTelemetry.d.ts +27 -0
  120. package/dist/shell/cameraTelemetry.js +24 -0
  121. package/dist/shell/commandRegistry.d.ts +58 -0
  122. package/dist/shell/commandRegistry.js +133 -0
  123. package/dist/shell/consoleSink.d.ts +11 -0
  124. package/dist/shell/consoleSink.js +17 -0
  125. package/dist/shell/consoleStore.d.ts +27 -0
  126. package/dist/shell/consoleStore.js +43 -0
  127. package/dist/shell/fields.d.ts +39 -0
  128. package/dist/shell/fields.js +104 -0
  129. package/dist/shell/formatSavedRelative.d.ts +2 -0
  130. package/dist/shell/formatSavedRelative.js +14 -0
  131. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  132. package/dist/shell/glbThumbnailCache.js +241 -0
  133. package/dist/shell/hierarchyA11y.d.ts +21 -0
  134. package/dist/shell/hierarchyA11y.js +35 -0
  135. package/dist/shell/icons.d.ts +13 -0
  136. package/dist/shell/icons.js +85 -0
  137. package/dist/shell/layoutStore.d.ts +68 -0
  138. package/dist/shell/layoutStore.js +156 -0
  139. package/dist/shell/pathFlythrough.d.ts +37 -0
  140. package/dist/shell/pathFlythrough.js +54 -0
  141. package/dist/shell/perfHistory.d.ts +70 -0
  142. package/dist/shell/perfHistory.js +130 -0
  143. package/dist/shell/renameObject.d.ts +3 -0
  144. package/dist/shell/renameObject.js +25 -0
  145. package/dist/shell/theme.d.ts +36 -0
  146. package/dist/shell/theme.js +37 -0
  147. package/dist/shell/ui.d.ts +84 -0
  148. package/dist/shell/ui.js +90 -0
  149. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  150. package/dist/shell/useGlbThumbnail.js +19 -0
  151. package/dist/uiStore.d.ts +27 -0
  152. package/dist/uiStore.js +10 -0
  153. package/dist/viewportContextMenu.d.ts +4 -2
  154. package/dist/viewportContextMenu.js +5 -1
  155. package/package.json +4 -4
  156. package/skills/jgengine/SKILL.md +8 -4
  157. package/skills/jgengine/api.md +182 -81
  158. package/skills/jgengine/capabilities.md +104 -2
  159. package/skills/jgengine/recipes/minimal-game.md +88 -0
  160. package/skills/jgengine-editor/SKILL.md +18 -3
  161. package/skills/jgengine-editor/api.md +403 -20
  162. package/skills/jgengine-editor/capabilities.md +46 -0
  163. package/skills/jgengine-editor/reference.md +87 -0
  164. package/skills/level-design/SKILL.md +54 -0
  165. package/skills/level-design/agents/openai.yaml +4 -0
  166. package/skills/level-design/references/application-playbook.md +192 -0
  167. package/skills/level-design/references/field-guide.md +337 -0
  168. package/skills/level-design/references/live-editor-loop.md +179 -0
  169. package/dist/OutlinerPanel.d.ts +0 -14
  170. package/dist/OutlinerPanel.js +0 -84
  171. package/skills/jgengine/reference-cartridge.md +0 -72
package/dist/session.js CHANGED
@@ -1,52 +1,10 @@
1
- import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, editorChildren, editorDocumentBounds, editorParentOf, editorRoots, findEditorCatalog, findEditorCatalogEntry, findEditorCollection, findEditorPrefab, getRuntimeInspectorValue, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, planRuntimeInspectorSet, runtimeEntityMetaWriteBackCommand, runtimeEntityWriteBackCommand, seedEditorCatalogs, summarizeEditorSession, summarizeRuntimeInspector, createGridLayer, eyedropGridCell, gridCellCount, importAsciiGrid, importCsvGrid, } from "@jgengine/core/editor/index";
2
- import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
3
- import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
4
- import { resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, SCATTER_PATH_KIND, } from "@jgengine/core/world/scatterRegion";
1
+ import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
5
2
  import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKinds";
6
- import { getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
7
- import { TERRAIN_MATERIALS } from "./uiStore.js";
3
+ import { EDITOR_RPC_HANDLERS } from "./handlers/index.js";
4
+ import { emitEditorConsole } from "./shell/consoleSink.js";
8
5
  registerBuiltinSceneKinds();
9
- /** Which document collection an object lives in, plus its current kind + meta ΓÇö for generic `set_meta`. */
10
- function findMetaTarget(doc, id) {
11
- const marker = doc.markers.find((m) => m.id === id);
12
- if (marker !== undefined)
13
- return { target: "marker", kind: marker.kind, meta: marker.meta };
14
- const volume = doc.volumes.find((v) => v.id === id);
15
- if (volume !== undefined)
16
- return { target: "volume", kind: volume.kind, meta: volume.meta };
17
- const path = doc.paths.find((p) => p.id === id);
18
- if (path !== undefined)
19
- return { target: "path", kind: path.kind, meta: path.meta };
20
- const note = doc.annotations.find((n) => n.id === id);
21
- if (note !== undefined)
22
- return { target: "note", kind: "note", meta: note.meta };
23
- return null;
24
- }
25
- /** Compact grid-layer summary returned by the grid RPC verbs — never the full sparse cell map. */
26
- function gridLayerSummary(layer) {
27
- return {
28
- id: layer.id,
29
- kind: layer.kind,
30
- ...(layer.label === undefined ? {} : { label: layer.label }),
31
- cols: layer.cols,
32
- rows: layer.rows,
33
- cellSize: layer.cellSize,
34
- cellCount: gridCellCount(layer),
35
- visible: layer.visible ?? true,
36
- paletteIds: (layer.palette ?? []).map((entry) => entry.id),
37
- };
38
- }
39
- /** Validates a merged meta bag against a registered kind's schema; returns an error string or null. */
40
- function validateMetaForKind(kind, meta) {
41
- const definition = getSceneKind(kind);
42
- if (definition === undefined || meta === undefined)
43
- return null;
44
- const issues = validateParams(definition.schema, meta);
45
- if (issues.length === 0)
46
- return null;
47
- return `invalid ${kind} params: ${issues.map((issue) => `${issue.key} (${issue.message})`).join(", ")}`;
48
- }
49
- const EDITOR_RUN_MODES = ["edit", "walk", "play"];
6
+ /** The accepted run modes, in canonical order — the runtime guard behind {@link EditorRunMode}. @internal */
7
+ export const EDITOR_RUN_MODES = ["edit", "walk", "play", "hud"];
50
8
  const GLOBAL_KEY = "__jgengineEditorHost";
51
9
  /** Publishes an editor host globally so devtools and MCP agents can reach it; returns a cleanup fn. */
52
10
  export function installEditorHost(api) {
@@ -65,7 +23,6 @@ export function getEditorHost() {
65
23
  /** Builds and installs an editor host for a game: session, visibility, assets, and RPC handling. @internal */
66
24
  export function createEditorHost(options) {
67
25
  const catalogDefinitions = options.catalogs ?? [];
68
- const catalogById = new Map(catalogDefinitions.map((definition) => [definition.id, definition]));
69
26
  const document = seedEditorCatalogs(normalizeEditorLayers(options.layers), catalogDefinitions);
70
27
  const session = createEditorSession(document);
71
28
  const liveSync = createDocumentLiveSync(document);
@@ -81,6 +38,7 @@ export function createEditorHost(options) {
81
38
  let focusTarget = null;
82
39
  let assets = [...(options.assets ?? [])];
83
40
  let perf = null;
41
+ let terrainSampler = null;
84
42
  let mode = "edit";
85
43
  let playControl = createRuntimePlayControl(false);
86
44
  const visibilityListeners = new Set();
@@ -142,11 +100,15 @@ export function createEditorHost(options) {
142
100
  setAssets(next) {
143
101
  assets = [...next];
144
102
  },
145
- getCatalogDefinitions: () => catalogDefinitions,
103
+ getCatalogDefinitions: () => resolveCatalogDefinitions(session.getState().document, catalogDefinitions),
146
104
  getPerf: () => perf,
147
105
  setPerf(sample) {
148
106
  perf = sample;
149
107
  },
108
+ getTerrainSampler: () => terrainSampler,
109
+ setTerrainSampler(field) {
110
+ terrainSampler = field;
111
+ },
150
112
  getMode: () => mode,
151
113
  setMode(next) {
152
114
  if (next === mode)
@@ -174,6 +136,9 @@ export function createEditorHost(options) {
174
136
  };
175
137
  for (const listener of playControlListeners)
176
138
  listener(playControl);
139
+ // Return the normalized control so callers thread the applied value instead of re-reading a
140
+ // binding that may have been reassigned meanwhile (keeps a published delta in step with it).
141
+ return playControl;
177
142
  },
178
143
  subscribePlayControl(listener) {
179
144
  playControlListeners.add(listener);
@@ -182,980 +147,34 @@ export function createEditorHost(options) {
182
147
  };
183
148
  },
184
149
  handle(request) {
150
+ // One shared context + one try/catch envelope; each verb lives in its per-domain handler module,
151
+ // dispatched through the method → handler table so a missing verb is a compile error, not a fall-through.
152
+ // Recompute the merged catalog set per request so a catalog just added via `add_catalog`
153
+ // (carried on the document, not the static game export) is RPC-addressable immediately.
154
+ const defs = resolveCatalogDefinitions(session.getState().document, catalogDefinitions);
155
+ const ctx = {
156
+ api,
157
+ session,
158
+ liveSync,
159
+ gameId: options.gameId,
160
+ catalogDefinitions: defs,
161
+ catalogById: new Map(defs.map((definition) => [definition.id, definition])),
162
+ dispatchGuarded,
163
+ getTerrainSampler: api.getTerrainSampler,
164
+ };
185
165
  try {
186
- switch (request.method) {
187
- case "editor_status":
188
- return {
189
- ok: true,
190
- result: {
191
- gameId: options.gameId,
192
- connected: true,
193
- ...summarizeEditorSession(session.getState()),
194
- canUndo: session.canUndo(),
195
- canRedo: session.canRedo(),
196
- perf,
197
- mode,
198
- },
199
- };
200
- case "set_mode": {
201
- if (!EDITOR_RUN_MODES.includes(request.mode)) {
202
- return { ok: false, error: `unknown mode: ${String(request.mode)} (edit | walk | play)` };
203
- }
204
- api.setMode(request.mode);
205
- return { ok: true, result: { mode: request.mode } };
206
- }
207
- case "perf_report": {
208
- const devtoolsGlobal = globalThis.__JG_DEVTOOLS;
209
- if (devtoolsGlobal?.snapshot === undefined) {
210
- return {
211
- ok: false,
212
- error: "engine devtools not mounted ΓÇö open the browser editor (F2+D panel) first",
213
- };
214
- }
215
- return { ok: true, result: { perf, report: devtoolsGlobal.snapshot() } };
216
- }
217
- case "list_layers": {
218
- const state = session.getState();
219
- return {
220
- ok: true,
221
- result: {
222
- kinds: listEditorKinds(state.document),
223
- visibility,
224
- document: state.document,
225
- },
226
- };
227
- }
228
- case "list_catalogs": {
229
- const state = session.getState();
230
- const catalogs = catalogDefinitions.map((definition) => {
231
- const data = findEditorCatalog(state.document, definition.id);
232
- return {
233
- id: definition.id,
234
- label: definition.label,
235
- schema: definition.schema,
236
- entryCount: data?.entries.length ?? definition.entries.length,
237
- entries: (data?.entries ?? definition.entries).map((entry) => ({
238
- id: entry.id,
239
- label: entry.label,
240
- })),
241
- };
242
- });
243
- return { ok: true, result: { catalogs } };
244
- }
245
- case "get_catalog_entry": {
246
- const definition = catalogById.get(request.catalogId);
247
- if (definition === undefined)
248
- return { ok: false, error: `catalog not found: ${request.catalogId}` };
249
- const entry = findEditorCatalogEntry(session.getState().document, request.catalogId, request.entryId);
250
- if (entry === undefined)
251
- return { ok: false, error: `catalog entry not found: ${request.catalogId}/${request.entryId}` };
252
- return {
253
- ok: true,
254
- result: {
255
- catalogId: request.catalogId,
256
- label: definition.label,
257
- schema: definition.schema,
258
- entry,
259
- },
260
- };
261
- }
262
- case "set_catalog_entry": {
263
- const definition = catalogById.get(request.catalogId);
264
- if (definition === undefined)
265
- return { ok: false, error: `catalog not found: ${request.catalogId}` };
266
- const current = findEditorCatalogEntry(session.getState().document, request.catalogId, request.entryId);
267
- if (current === undefined)
268
- return { ok: false, error: `catalog entry not found: ${request.catalogId}/${request.entryId}` };
269
- const merged = { ...current.meta, ...request.patch };
270
- const invalid = validateParams(definition.schema, merged);
271
- if (invalid.length > 0) {
272
- return {
273
- ok: false,
274
- error: `invalid ${request.catalogId} params: ${invalid.map((issue) => `${issue.key} (${issue.message})`).join(", ")}`,
275
- };
276
- }
277
- const coalesceKey = Object.keys(request.patch).length === 1
278
- ? `catalog:${request.catalogId}:${request.entryId}:${Object.keys(request.patch)[0]}`
279
- : `catalog:${request.catalogId}:${request.entryId}`;
280
- const before = session.getState();
281
- const state = session.dispatch({
282
- type: "setCatalogEntry",
283
- catalogId: request.catalogId,
284
- entryId: request.entryId,
285
- patch: {
286
- meta: merged,
287
- ...(request.label === undefined ? {} : { label: request.label }),
288
- },
289
- }, { coalesce: coalesceKey });
290
- if (state === before) {
291
- return { ok: false, error: `set_catalog_entry rejected: ${request.catalogId}/${request.entryId}` };
292
- }
293
- const entry = findEditorCatalogEntry(state.document, request.catalogId, request.entryId);
294
- return { ok: true, result: { catalogId: request.catalogId, entry } };
295
- }
296
- case "list_selection":
297
- return { ok: true, result: { selection: session.getState().selection } };
298
- case "get_marker": {
299
- const marker = session.getState().document.markers.find((m) => m.id === request.id);
300
- return marker === undefined
301
- ? { ok: false, error: `marker not found: ${request.id}` }
302
- : { ok: true, result: marker };
303
- }
304
- case "get_volume": {
305
- const volume = session.getState().document.volumes.find((v) => v.id === request.id);
306
- return volume === undefined
307
- ? { ok: false, error: `volume not found: ${request.id}` }
308
- : { ok: true, result: volume };
309
- }
310
- case "set_transform": {
311
- const marker = session.getState().document.markers.find((m) => m.id === request.id);
312
- const volume = session.getState().document.volumes.find((v) => v.id === request.id);
313
- const base = marker?.position ?? volume?.center;
314
- if (base === undefined)
315
- return { ok: false, error: `id not found: ${request.id}` };
316
- const { applied, state } = dispatchGuarded({
317
- type: "setTransform",
318
- id: request.id,
319
- position: {
320
- x: request.x ?? base.x,
321
- y: request.y ?? base.y,
322
- z: request.z ?? base.z,
323
- },
324
- ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
325
- });
326
- if (!applied)
327
- return { ok: false, error: `set_transform rejected: ${request.id} is locked` };
328
- return { ok: true, result: summarizeEditorSession(state) };
329
- }
330
- case "set_volume": {
331
- const volume = session.getState().document.volumes.find((v) => v.id === request.id);
332
- if (volume === undefined)
333
- return { ok: false, error: `volume not found: ${request.id}` };
334
- session.dispatch({
335
- type: "setVolume",
336
- id: request.id,
337
- patch: {
338
- ...(request.radius === undefined ? {} : { radius: request.radius }),
339
- ...(request.height === undefined ? {} : { height: request.height }),
340
- ...(request.x === undefined && request.y === undefined && request.z === undefined
341
- ? {}
342
- : {
343
- center: {
344
- x: request.x ?? volume.center.x,
345
- y: request.y ?? volume.center.y,
346
- z: request.z ?? volume.center.z,
347
- },
348
- }),
349
- },
350
- });
351
- return { ok: true, result: session.getState().document.volumes.find((v) => v.id === request.id) };
352
- }
353
- case "set_path": {
354
- const doc = session.getState().document;
355
- const path = doc.paths.find((p) => p.id === request.id);
356
- if (path === undefined)
357
- return { ok: false, error: `path not found: ${request.id}` };
358
- const merged = request.meta === undefined ? undefined : { ...path.meta, ...request.meta };
359
- const kind = request.kind ?? path.kind;
360
- const invalid = validateMetaForKind(kind, merged);
361
- if (invalid !== null)
362
- return { ok: false, error: invalid };
363
- session.dispatch({
364
- type: "setPath",
365
- id: request.id,
366
- patch: {
367
- ...(request.kind === undefined ? {} : { kind: request.kind }),
368
- ...(request.width === undefined ? {} : { width: request.width }),
369
- ...(request.color === undefined ? {} : { color: request.color }),
370
- ...(request.label === undefined ? {} : { label: request.label }),
371
- ...(merged === undefined ? {} : { meta: merged }),
372
- },
373
- });
374
- return { ok: true, result: session.getState().document.paths.find((p) => p.id === request.id) };
375
- }
376
- case "set_marker": {
377
- const doc = session.getState().document;
378
- const marker = doc.markers.find((m) => m.id === request.id);
379
- if (marker === undefined)
380
- return { ok: false, error: `marker not found: ${request.id}` };
381
- const merged = request.meta === undefined ? undefined : { ...marker.meta, ...request.meta };
382
- const kind = request.kind ?? marker.kind;
383
- const invalid = validateMetaForKind(kind, merged);
384
- if (invalid !== null)
385
- return { ok: false, error: invalid };
386
- session.dispatch({
387
- type: "setMarker",
388
- id: request.id,
389
- patch: {
390
- ...(request.kind === undefined ? {} : { kind: request.kind }),
391
- ...(request.color === undefined ? {} : { color: request.color }),
392
- ...(request.label === undefined ? {} : { label: request.label }),
393
- ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
394
- ...(merged === undefined ? {} : { meta: merged }),
395
- },
396
- });
397
- return { ok: true, result: session.getState().document.markers.find((m) => m.id === request.id) };
398
- }
399
- case "set_note": {
400
- const doc = session.getState().document;
401
- const note = doc.annotations.find((n) => n.id === request.id);
402
- if (note === undefined)
403
- return { ok: false, error: `note not found: ${request.id}` };
404
- const merged = request.meta === undefined ? undefined : { ...note.meta, ...request.meta };
405
- session.dispatch({
406
- type: "setNote",
407
- id: request.id,
408
- patch: {
409
- ...(request.text === undefined ? {} : { text: request.text }),
410
- ...(merged === undefined ? {} : { meta: merged }),
411
- },
412
- });
413
- return { ok: true, result: session.getState().document.annotations.find((n) => n.id === request.id) };
414
- }
415
- case "set_meta": {
416
- const target = findMetaTarget(session.getState().document, request.id);
417
- if (target === null)
418
- return { ok: false, error: `object not found: ${request.id}` };
419
- const merged = { ...target.meta, ...request.patch };
420
- const invalid = validateMetaForKind(target.kind, merged);
421
- if (invalid !== null)
422
- return { ok: false, error: invalid };
423
- session.dispatch(target.target === "marker"
424
- ? { type: "setMarker", id: request.id, patch: { meta: merged } }
425
- : target.target === "volume"
426
- ? { type: "setVolume", id: request.id, patch: { meta: merged } }
427
- : target.target === "path"
428
- ? { type: "setPath", id: request.id, patch: { meta: merged } }
429
- : { type: "setNote", id: request.id, patch: { meta: merged } });
430
- return { ok: true, result: { id: request.id, kind: target.kind, meta: merged } };
431
- }
432
- case "select":
433
- session.dispatch({ type: "select", ids: request.ids });
434
- return { ok: true, result: { selection: session.getState().selection } };
435
- case "clear_selection":
436
- session.dispatch({ type: "clearSelection" });
437
- return { ok: true, result: { selection: [] } };
438
- case "camera_goto": {
439
- if (request.id !== undefined) {
440
- const state = session.getState();
441
- const marker = state.document.markers.find((m) => m.id === request.id);
442
- const volume = state.document.volumes.find((v) => v.id === request.id);
443
- const point = marker?.position ?? volume?.center;
444
- if (point === undefined)
445
- return { ok: false, error: `id not found: ${request.id}` };
446
- api.setFocusTarget({ ...point });
447
- session.dispatch({ type: "select", ids: [request.id] });
448
- return { ok: true, result: { target: point } };
449
- }
450
- if (request.x === undefined || request.z === undefined) {
451
- return { ok: false, error: "camera_goto requires id or x/z" };
452
- }
453
- const target = { x: request.x, y: request.y ?? 0, z: request.z };
454
- api.setFocusTarget(target);
455
- return { ok: true, result: { target } };
456
- }
457
- case "camera_frame": {
458
- const bounds = editorDocumentBounds(session.getState().document);
459
- if (bounds === null)
460
- return { ok: false, error: "document is empty" };
461
- const target = {
462
- x: (bounds.min.x + bounds.max.x) / 2,
463
- y: (bounds.min.y + bounds.max.y) / 2,
464
- z: (bounds.min.z + bounds.max.z) / 2,
465
- };
466
- api.setFocusTarget(target);
467
- return { ok: true, result: { target, bounds } };
468
- }
469
- case "scene_summary":
470
- return {
471
- ok: true,
472
- result: {
473
- gameId: options.gameId,
474
- kinds: listEditorKinds(session.getState().document),
475
- ...summarizeEditorSession(session.getState()),
476
- bounds: editorDocumentBounds(session.getState().document),
477
- },
478
- };
479
- case "export_document":
480
- return { ok: true, result: { json: session.exportJson(true) } };
481
- case "import_document":
482
- session.dispatch({ type: "importJson", json: request.json });
483
- return { ok: true, result: summarizeEditorSession(session.getState()) };
484
- case "push_document_patch": {
485
- const force = request.force === true;
486
- const patch = request.patch;
487
- if (!force && patch.baseRevision !== liveSync.getRevision()) {
488
- return {
489
- ok: false,
490
- error: `baseRevision mismatch: patch=${patch.baseRevision} current=${liveSync.getRevision()}`,
491
- };
492
- }
493
- if (patch.type === "snapshot") {
494
- session.dispatch({ type: "replaceDocument", document: patch.document });
495
- return {
496
- ok: true,
497
- result: {
498
- revision: liveSync.getRevision(),
499
- ...summarizeEditorSession(session.getState()),
500
- },
501
- };
502
- }
503
- if (patch.commands.length === 0)
504
- return { ok: false, error: "commands patch is empty" };
505
- for (const command of patch.commands) {
506
- const { applied } = dispatchGuarded(command);
507
- if (!applied && command.type !== "select" && command.type !== "clearSelection") {
508
- return { ok: false, error: `${command.type} rejected while applying patch` };
509
- }
510
- }
511
- return {
512
- ok: true,
513
- result: {
514
- revision: liveSync.getRevision(),
515
- ...summarizeEditorSession(session.getState()),
516
- },
517
- };
518
- }
519
- case "pull_document_patches":
520
- return {
521
- ok: true,
522
- result: {
523
- revision: liveSync.getRevision(),
524
- patches: liveSync.pullPatches(request.sinceRevision ?? 0),
525
- },
526
- };
527
- case "document_revision":
528
- return {
529
- ok: true,
530
- result: {
531
- revision: liveSync.getRevision(),
532
- ...(request.includeDocument === true ? { document: liveSync.getDocument() } : {}),
533
- },
534
- };
535
- case "push_runtime_delta": {
536
- const delta = liveSync.pushRuntimeDelta({
537
- at: request.at ?? Date.now(),
538
- ...(request.entities === undefined ? {} : { entities: request.entities }),
539
- ...(request.removeIds === undefined ? {} : { removeIds: request.removeIds }),
540
- ...(request.tunables === undefined ? {} : { tunables: request.tunables }),
541
- });
542
- return { ok: true, result: delta };
543
- }
544
- case "pull_runtime_deltas":
545
- return {
546
- ok: true,
547
- result: {
548
- seq: liveSync.getRuntimeState().seq,
549
- deltas: liveSync.pullRuntimeDeltas(request.sinceSeq ?? 0),
550
- ...(request.includeSnapshot === true ? { snapshot: liveSync.getRuntimeState() } : {}),
551
- },
552
- };
553
- case "runtime_snapshot":
554
- return { ok: true, result: liveSync.getRuntimeState() };
555
- case "runtime_summary":
556
- return {
557
- ok: true,
558
- result: summarizeRuntimeInspector(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), playControl),
559
- };
560
- case "runtime_get": {
561
- if (request.id.length === 0)
562
- return { ok: false, error: "runtime_get requires id" };
563
- const got = getRuntimeInspectorValue(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), request.id, request.path);
564
- if (got.kind === "missing") {
565
- return { ok: false, error: `runtime entity/tunable not found: ${request.id}` };
566
- }
567
- return { ok: true, result: got };
568
- }
569
- case "runtime_set": {
570
- if (request.id.length === 0)
571
- return { ok: false, error: "runtime_set requires id" };
572
- const plan = planRuntimeInspectorSet(session.getState().document, {
573
- id: request.id,
574
- ...(request.path === undefined ? {} : { path: request.path }),
575
- ...(request.value === undefined ? {} : { value: request.value }),
576
- ...(request.position === undefined ? {} : { position: request.position }),
577
- ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
578
- ...(request.values === undefined ? {} : { values: request.values }),
579
- ...(request.writeBack === undefined ? {} : { writeBack: request.writeBack }),
580
- });
581
- if (plan.error !== undefined)
582
- return { ok: false, error: plan.error };
583
- if (plan.tunable !== undefined) {
584
- liveSync.pushRuntimeDelta({
585
- at: Date.now(),
586
- tunables: { [plan.tunable.key]: plan.tunable.value },
587
- });
588
- return {
589
- ok: true,
590
- result: {
591
- kind: "tunable",
592
- key: plan.tunable.key,
593
- value: plan.tunable.value,
594
- writeBack: false,
595
- ...summarizeRuntimeInspector(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), playControl),
596
- },
597
- };
598
- }
599
- if (plan.entity === undefined)
600
- return { ok: false, error: "runtime_set produced no entity" };
601
- liveSync.setRuntimeOverride(plan.entity);
602
- liveSync.pushRuntimeDelta({ at: Date.now(), entities: [plan.entity] });
603
- let wroteBack = false;
604
- let lastState = session.getState();
605
- for (const command of plan.writeBackCommands) {
606
- const { applied, state } = dispatchGuarded(command);
607
- if (!applied) {
608
- return { ok: false, error: `runtime_set write-back rejected: ${command.type}` };
609
- }
610
- lastState = state;
611
- wroteBack = true;
612
- }
613
- if (wroteBack)
614
- liveSync.clearRuntimeOverride(plan.entity.id);
615
- return {
616
- ok: true,
617
- result: {
618
- kind: "entity",
619
- entity: plan.entity,
620
- writeBack: wroteBack,
621
- revision: liveSync.getRevision(),
622
- ...summarizeEditorSession(lastState),
623
- },
624
- };
625
- }
626
- case "runtime_pause": {
627
- api.setPlayControl({ paused: true, pendingSteps: 0 });
628
- liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true } });
629
- return { ok: true, result: { play: playControl } };
630
- }
631
- case "runtime_resume": {
632
- api.setPlayControl({ paused: false, pendingSteps: 0 });
633
- liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: false } });
634
- return { ok: true, result: { play: playControl } };
635
- }
636
- case "runtime_step": {
637
- const frames = request.frames === undefined ? 1 : Math.max(1, Math.floor(request.frames));
638
- api.setPlayControl({ paused: true, pendingSteps: playControl.pendingSteps + frames });
639
- liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true, pendingSteps: playControl.pendingSteps } });
640
- return { ok: true, result: { play: playControl } };
641
- }
642
- case "set_runtime_override": {
643
- liveSync.setRuntimeOverride(request.entity);
644
- return { ok: true, result: { overrides: liveSync.getRuntimeOverrides() } };
645
- }
646
- case "clear_runtime_override": {
647
- liveSync.clearRuntimeOverride(request.id);
648
- return { ok: true, result: { overrides: liveSync.getRuntimeOverrides() } };
649
- }
650
- case "write_back_override": {
651
- const entity = liveSync.getRuntimeOverrides()[request.id];
652
- if (entity === undefined) {
653
- return { ok: false, error: `no runtime override for "${request.id}"` };
654
- }
655
- const transform = runtimeEntityWriteBackCommand(session.getState().document, entity);
656
- const meta = runtimeEntityMetaWriteBackCommand(session.getState().document, entity);
657
- if (transform === null && meta === null) {
658
- return {
659
- ok: false,
660
- error: `override "${request.id}" has nothing to write back into the document`,
661
- };
662
- }
663
- let lastState = session.getState();
664
- for (const command of [transform, meta]) {
665
- if (command === null)
666
- continue;
667
- const { applied, state } = dispatchGuarded(command);
668
- if (!applied)
669
- return { ok: false, error: `write_back_override rejected for "${request.id}"` };
670
- lastState = state;
671
- }
672
- liveSync.clearRuntimeOverride(request.id);
673
- return {
674
- ok: true,
675
- result: {
676
- revision: liveSync.getRevision(),
677
- ...summarizeEditorSession(lastState),
678
- },
679
- };
680
- }
681
- case "dispatch": {
682
- const { applied, state } = dispatchGuarded(request.command);
683
- if (!applied)
684
- return { ok: false, error: `${request.command.type} rejected: no effect` };
685
- return { ok: true, result: summarizeEditorSession(state) };
686
- }
687
- case "undo": {
688
- const { applied, state } = dispatchGuarded({ type: "undo" });
689
- if (!applied)
690
- return { ok: false, error: "nothing to undo" };
691
- return { ok: true, result: summarizeEditorSession(state) };
692
- }
693
- case "redo": {
694
- const { applied, state } = dispatchGuarded({ type: "redo" });
695
- if (!applied)
696
- return { ok: false, error: "nothing to redo" };
697
- return { ok: true, result: summarizeEditorSession(state) };
698
- }
699
- case "list_assets":
700
- return { ok: true, result: { assets } };
701
- case "place_asset": {
702
- const focus = focusTarget ?? { x: 0, y: 0, z: 0 };
703
- const asset = assets.find((entry) => entry.id === request.id);
704
- const placed = resolvePlaceAsset({
705
- assetId: request.id,
706
- position: {
707
- x: request.x ?? focus.x,
708
- y: request.y ?? focus.y,
709
- z: request.z ?? focus.z,
710
- },
711
- kind: request.kind,
712
- knownKind: asset?.kind,
713
- knownLabel: asset?.label,
714
- knownUrl: asset?.url,
715
- });
716
- const marker = toEditorMarker(placed);
717
- session.dispatch({ type: "addMarker", marker });
718
- return {
719
- ok: true,
720
- result: {
721
- id: marker.id,
722
- position: marker.position,
723
- marker: session.getState().document.markers.find((entry) => entry.id === marker.id),
724
- },
725
- };
726
- }
727
- case "create_terrain": {
728
- const width = request.width ?? 200;
729
- const depth = request.depth ?? 200;
730
- const cx = request.centerX ?? 0;
731
- const cz = request.centerZ ?? 0;
732
- const terrain = createTerrainSnapshot({
733
- bounds: { minX: cx - width / 2, minZ: cz - depth / 2, maxX: cx + width / 2, maxZ: cz + depth / 2 },
734
- cellSize: request.cellSize ?? 2,
735
- });
736
- session.dispatch({ type: "setTerrain", terrain });
737
- return { ok: true, result: { cols: terrain.cols, rows: terrain.rows, cellSize: terrain.cellSize } };
738
- }
739
- case "sculpt_terrain": {
740
- const terrain = session.getState().document.terrain;
741
- if (terrain === undefined)
742
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
743
- const live = editableTerrainFromSnapshot(terrain);
744
- const edit = {
745
- mode: request.mode,
746
- center: [request.x, request.z],
747
- radius: request.radius ?? 8,
748
- strength: request.strength ?? 1,
749
- ...(request.target === undefined ? {} : { target: request.target }),
750
- ...(request.toX === undefined || request.toZ === undefined ? {} : { to: [request.toX, request.toZ] }),
751
- ...(request.seed === undefined ? {} : { seed: request.seed }),
752
- ...(request.shape === undefined ? {} : { shape: request.shape }),
753
- };
754
- const delta = live.editDelta(edit);
755
- if (delta.indices.length === 0)
756
- return { ok: false, error: "brush touched no vertices (check radius/position)" };
757
- session.dispatch({ type: "sculptTerrain", delta });
758
- return { ok: true, result: { changed: delta.indices.length, canUndo: session.canUndo() } };
759
- }
760
- case "terrain_summary": {
761
- const terrain = session.getState().document.terrain;
762
- if (terrain === undefined)
763
- return { ok: true, result: { terrain: null } };
764
- let min = Infinity;
765
- let max = -Infinity;
766
- let nonZero = 0;
767
- for (const value of terrain.offsets) {
768
- if (value < min)
769
- min = value;
770
- if (value > max)
771
- max = value;
772
- if (value !== 0)
773
- nonZero += 1;
774
- }
775
- return {
776
- ok: true,
777
- result: {
778
- cols: terrain.cols,
779
- rows: terrain.rows,
780
- cellSize: terrain.cellSize,
781
- bounds: terrain.bounds,
782
- minOffset: terrain.offsets.length === 0 ? 0 : min,
783
- maxOffset: terrain.offsets.length === 0 ? 0 : max,
784
- editedVertices: nonZero,
785
- paintedCells: terrain.surfaces.filter((surface) => surface !== null).length,
786
- },
787
- };
788
- }
789
- case "paint_terrain": {
790
- const terrain = session.getState().document.terrain;
791
- if (terrain === undefined)
792
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
793
- const live = editableTerrainFromSnapshot(terrain);
794
- const delta = live.paintDelta({
795
- mode: "paint",
796
- center: [request.x, request.z],
797
- radius: request.radius ?? 8,
798
- surface: request.surface,
799
- ...(request.shape === undefined ? {} : { shape: request.shape }),
800
- });
801
- if (delta.indices.length === 0)
802
- return { ok: false, error: "paint touched no cells (check radius/position)" };
803
- session.dispatch({ type: "paintTerrain", delta });
804
- return { ok: true, result: { changed: delta.indices.length, canUndo: session.canUndo() } };
805
- }
806
- case "fill_terrain": {
807
- const terrain = session.getState().document.terrain;
808
- if (terrain === undefined)
809
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
810
- const live = editableTerrainFromSnapshot(terrain);
811
- const delta = live.fillSurfaceDelta(request.surface);
812
- if (delta.indices.length === 0)
813
- return { ok: true, result: { changed: 0 } };
814
- session.dispatch({ type: "paintTerrain", delta });
815
- return { ok: true, result: { changed: delta.indices.length } };
816
- }
817
- case "auto_paint": {
818
- const terrain = session.getState().document.terrain;
819
- if (terrain === undefined)
820
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
821
- const live = editableTerrainFromSnapshot(terrain);
822
- const rule = {
823
- surface: request.surface,
824
- ...(request.minSlope === undefined ? {} : { minSlope: request.minSlope }),
825
- ...(request.maxSlope === undefined ? {} : { maxSlope: request.maxSlope }),
826
- ...(request.minHeight === undefined ? {} : { minHeight: request.minHeight }),
827
- ...(request.maxHeight === undefined ? {} : { maxHeight: request.maxHeight }),
828
- };
829
- const delta = live.autoPaintDelta(rule);
830
- if (delta.indices.length === 0)
831
- return { ok: true, result: { changed: 0 } };
832
- session.dispatch({ type: "paintTerrain", delta });
833
- return { ok: true, result: { changed: delta.indices.length } };
834
- }
835
- case "terrain_materials":
836
- return { ok: true, result: { materials: TERRAIN_MATERIALS } };
837
- case "terrain_layers": {
838
- const terrain = session.getState().document.terrain;
839
- return { ok: true, result: { layers: terrain?.layers ?? [] } };
840
- }
841
- case "set_terrain_layers": {
842
- if (session.getState().document.terrain === undefined) {
843
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
844
- }
845
- session.dispatch({ type: "setTerrainLayers", layers: request.layers });
846
- return { ok: true, result: { layers: session.getState().document.terrain?.layers ?? [] } };
847
- }
848
- case "blend_terrain": {
849
- const terrain = session.getState().document.terrain;
850
- if (terrain === undefined)
851
- return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
852
- // Auto-add the surface as a layer if the stack does not carry it yet.
853
- const layers = terrain.layers ?? [];
854
- if (!layers.some((layer) => layer.surface === request.surface)) {
855
- const next = [...layers, { id: request.surface, surface: request.surface }];
856
- session.dispatch({ type: "setTerrainLayers", layers: next });
857
- }
858
- const live = editableTerrainFromSnapshot(session.getState().document.terrain);
859
- const delta = live.blendPaintDelta({
860
- mode: "paint",
861
- center: [request.x, request.z],
862
- radius: request.radius ?? 8,
863
- surface: request.surface,
864
- strength: request.strength ?? 1,
865
- ...(request.shape === undefined ? {} : { shape: request.shape }),
866
- });
867
- if (delta.indices.length === 0)
868
- return { ok: false, error: "blend touched no cells (check radius/position)" };
869
- session.dispatch({ type: "blendTerrain", delta });
870
- return { ok: true, result: { changed: delta.indices.length, layers: session.getState().document.terrain?.layers ?? [] } };
871
- }
872
- case "convert_scatter": {
873
- const doc = session.getState().document;
874
- const path = doc.paths.find((entry) => entry.id === request.pathId);
875
- if (path === undefined)
876
- return { ok: false, error: `path not found: ${request.pathId}` };
877
- const region = scatterRegionFromPath(path);
878
- if (region === null)
879
- return { ok: false, error: `not a scatter region: ${request.pathId}` };
880
- const terrainSnapshot = doc.terrain;
881
- const terrain = terrainSnapshot === undefined ? undefined : editableTerrainFromSnapshot(terrainSnapshot);
882
- const instances = resolveScatterRegion(region, terrain);
883
- const markers = instances.map((instance) => ({
884
- id: instance.id.replace(/[^a-zA-Z0-9_]/g, "_"),
885
- kind: "prop",
886
- position: { x: instance.x, y: instance.y, z: instance.z },
887
- rotationY: instance.rotationY,
888
- meta: { item: instance.item, scale: instance.scale, fromScatter: request.pathId },
889
- }));
890
- session.dispatch({ type: "convertScatterToObjects", pathId: request.pathId, markers });
891
- return { ok: true, result: { created: markers.length, removedPath: request.pathId } };
892
- }
893
- case "add_foliage": {
894
- if (request.points.length < 3)
895
- return { ok: false, error: "add_foliage needs at least 3 polygon points" };
896
- const id = `foliage_${Date.now().toString(36)}`;
897
- session.dispatch({
898
- type: "addPath",
899
- path: {
900
- id,
901
- kind: SCATTER_PATH_KIND,
902
- points: request.points.map((point) => ({ x: point.x, y: 0, z: point.z })),
903
- label: "foliage",
904
- meta: {
905
- density: request.density ?? 0.15,
906
- ...(request.item === undefined ? {} : { item: request.item }),
907
- ...(request.seed === undefined ? {} : { seed: request.seed }),
908
- ...(request.minSpacing === undefined ? {} : { minSpacing: request.minSpacing }),
909
- },
910
- },
911
- });
912
- const path = session.getState().document.paths.find((p) => p.id === id);
913
- return { ok: true, result: { id, estimate: scatterRegionEstimate(path) } };
914
- }
915
- case "scatter_summary": {
916
- const doc = session.getState().document;
917
- const regions = doc.paths.filter((path) => path.kind === SCATTER_PATH_KIND).length;
918
- const instances = resolveScatter(doc).length;
919
- return { ok: true, result: { regions, instances } };
920
- }
921
- case "set_parent": {
922
- const { applied, state } = dispatchGuarded({
923
- type: "setParent",
924
- ids: request.ids,
925
- parentId: request.parentId,
926
- });
927
- if (!applied)
928
- return { ok: false, error: "set_parent rejected: cyclic parent or empty selection" };
929
- const doc = state.document;
930
- return {
931
- ok: true,
932
- result: { roots: editorRoots(doc), parents: request.ids.map((id) => ({ id, parentId: editorParentOf(doc, id) ?? null })) },
933
- };
934
- }
935
- case "hierarchy": {
936
- const doc = session.getState().document;
937
- const roots = editorRoots(doc);
938
- return {
939
- ok: true,
940
- result: { roots, tree: roots.map((id) => ({ id, children: editorChildren(doc, id) })) },
941
- };
942
- }
943
- case "list_prefabs":
944
- return { ok: true, result: { prefabs: session.getState().document.prefabs } };
945
- case "create_prefab": {
946
- if (request.ids.length === 0)
947
- return { ok: false, error: "create_prefab needs at least one id" };
948
- session.dispatch({ type: "createPrefab", id: request.id, name: request.name, ids: request.ids });
949
- const prefab = findEditorPrefab(session.getState().document, request.id);
950
- return prefab === undefined
951
- ? { ok: false, error: `prefab not created: ${request.id}` }
952
- : { ok: true, result: prefab };
953
- }
954
- case "insert_prefab": {
955
- const prefab = findEditorPrefab(session.getState().document, request.prefabId);
956
- if (prefab === undefined)
957
- return { ok: false, error: `prefab not found: ${request.prefabId}` };
958
- const at = focusTarget ?? { x: 0, y: 0, z: 0 };
959
- session.dispatch({
960
- type: "insertPrefab",
961
- prefabId: request.prefabId,
962
- at: { x: request.x ?? at.x, y: request.y ?? at.y, z: request.z ?? at.z },
963
- });
964
- return { ok: true, result: summarizeEditorSession(session.getState()) };
965
- }
966
- case "detach_prefab_instance":
967
- session.dispatch({ type: "detachPrefabInstance", instanceId: request.instanceId });
968
- return { ok: true, result: summarizeEditorSession(session.getState()) };
969
- case "delete_prefab":
970
- session.dispatch({ type: "deletePrefab", prefabId: request.prefabId });
971
- return { ok: true, result: { prefabs: session.getState().document.prefabs } };
972
- case "list_collections":
973
- return { ok: true, result: { collections: session.getState().document.collections } };
974
- case "create_collection":
975
- session.dispatch({
976
- type: "createCollection",
977
- id: request.id,
978
- name: request.name,
979
- ...(request.memberIds === undefined ? {} : { memberIds: request.memberIds }),
980
- });
981
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
982
- case "rename_collection":
983
- session.dispatch({ type: "renameCollection", id: request.id, name: request.name });
984
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
985
- case "delete_collection":
986
- session.dispatch({ type: "deleteCollection", id: request.id });
987
- return { ok: true, result: { collections: session.getState().document.collections } };
988
- case "set_collection_members":
989
- session.dispatch({ type: "setCollectionMembers", id: request.id, memberIds: request.memberIds });
990
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
991
- case "add_to_collection":
992
- session.dispatch({ type: "addToCollection", id: request.id, ids: request.ids });
993
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
994
- case "remove_from_collection":
995
- session.dispatch({ type: "removeFromCollection", id: request.id, ids: request.ids });
996
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
997
- case "set_collection_flags":
998
- session.dispatch({
999
- type: "setCollectionFlags",
1000
- id: request.id,
1001
- patch: {
1002
- ...(request.color === undefined ? {} : { color: request.color }),
1003
- ...(request.locked === undefined ? {} : { locked: request.locked }),
1004
- ...(request.visible === undefined ? {} : { visible: request.visible }),
1005
- },
1006
- });
1007
- return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
1008
- case "select_collection": {
1009
- const collection = findEditorCollection(session.getState().document, request.id);
1010
- if (collection === undefined)
1011
- return { ok: false, error: `collection not found: ${request.id}` };
1012
- session.dispatch({ type: "selectCollection", id: request.id });
1013
- return { ok: true, result: { selection: session.getState().selection } };
1014
- }
1015
- case "batch_set_properties":
1016
- session.dispatch({
1017
- type: "batchSetProperties",
1018
- ids: request.ids,
1019
- patch: {
1020
- ...(request.color === undefined ? {} : { color: request.color }),
1021
- ...(request.label === undefined ? {} : { label: request.label }),
1022
- ...(request.meta === undefined ? {} : { meta: request.meta }),
1023
- },
1024
- });
1025
- return { ok: true, result: summarizeEditorSession(session.getState()) };
1026
- case "assign_material":
1027
- session.dispatch({ type: "assignMaterial", ids: request.ids, materialId: request.materialId });
1028
- return { ok: true, result: summarizeEditorSession(session.getState()) };
1029
- case "list_grids": {
1030
- const grids = session.getState().document.grids ?? [];
1031
- return { ok: true, result: { grids: grids.map(gridLayerSummary) } };
1032
- }
1033
- case "get_grid_cell": {
1034
- const layer = (session.getState().document.grids ?? []).find((entry) => entry.id === request.id);
1035
- if (layer === undefined)
1036
- return { ok: false, error: `grid layer not found: ${request.id}` };
1037
- return { ok: true, result: { id: request.id, col: request.col, row: request.row, value: eyedropGridCell(layer, request.col, request.row) } };
1038
- }
1039
- case "add_grid_layer": {
1040
- const layer = createGridLayer({
1041
- id: request.id,
1042
- kind: request.kind,
1043
- cols: request.cols,
1044
- rows: request.rows,
1045
- ...(request.label === undefined ? {} : { label: request.label }),
1046
- ...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
1047
- ...(request.origin === undefined ? {} : { origin: request.origin }),
1048
- ...(request.axes === undefined ? {} : { axes: request.axes }),
1049
- ...(request.empty === undefined ? {} : { empty: request.empty }),
1050
- ...(request.palette === undefined ? {} : { palette: request.palette }),
1051
- });
1052
- const { applied } = dispatchGuarded({ type: "addGridLayer", layer });
1053
- if (!applied)
1054
- return { ok: false, error: "add_grid_layer rejected: no effect" };
1055
- return { ok: true, result: gridLayerSummary(layer) };
1056
- }
1057
- case "remove_grid_layer": {
1058
- const { applied } = dispatchGuarded({ type: "removeGridLayer", id: request.id });
1059
- if (!applied)
1060
- return { ok: false, error: `grid layer not found: ${request.id}` };
1061
- return { ok: true, result: { removed: request.id } };
1062
- }
1063
- case "set_grid_layer": {
1064
- const patch = {};
1065
- if (request.label !== undefined)
1066
- patch.label = request.label;
1067
- if (request.kind !== undefined)
1068
- patch.kind = request.kind;
1069
- if (request.visible !== undefined)
1070
- patch.visible = request.visible;
1071
- if (request.empty !== undefined)
1072
- patch.empty = request.empty;
1073
- if (request.cellSize !== undefined)
1074
- patch.cellSize = request.cellSize;
1075
- if (request.origin !== undefined)
1076
- patch.origin = request.origin;
1077
- if (request.axes !== undefined)
1078
- patch.axes = request.axes;
1079
- if (request.palette !== undefined)
1080
- patch.palette = request.palette;
1081
- const { applied, state } = dispatchGuarded({ type: "setGridLayer", id: request.id, patch });
1082
- if (!applied)
1083
- return { ok: false, error: `set_grid_layer rejected: no effect (${request.id})` };
1084
- const layer = (state.document.grids ?? []).find((entry) => entry.id === request.id);
1085
- return { ok: true, result: gridLayerSummary(layer) };
1086
- }
1087
- case "paint_grid_cells": {
1088
- const { applied, state } = dispatchGuarded({ type: "paintGridCells", id: request.id, cells: request.cells });
1089
- if (!applied)
1090
- return { ok: false, error: `paint_grid_cells rejected: no effect (${request.id})` };
1091
- const layer = (state.document.grids ?? []).find((entry) => entry.id === request.id);
1092
- return { ok: true, result: gridLayerSummary(layer) };
1093
- }
1094
- case "fill_grid_rect": {
1095
- const { applied, state } = dispatchGuarded({
1096
- type: "fillGridRect",
1097
- id: request.id,
1098
- col0: request.col0,
1099
- row0: request.row0,
1100
- col1: request.col1,
1101
- row1: request.row1,
1102
- value: request.value,
1103
- });
1104
- if (!applied)
1105
- return { ok: false, error: `fill_grid_rect rejected: no effect (${request.id})` };
1106
- const layer = (state.document.grids ?? []).find((entry) => entry.id === request.id);
1107
- return { ok: true, result: gridLayerSummary(layer) };
1108
- }
1109
- case "flood_fill_grid": {
1110
- const { applied, state } = dispatchGuarded({
1111
- type: "floodFillGrid",
1112
- id: request.id,
1113
- col: request.col,
1114
- row: request.row,
1115
- value: request.value,
1116
- });
1117
- if (!applied)
1118
- return { ok: false, error: `flood_fill_grid rejected: no effect (${request.id})` };
1119
- const layer = (state.document.grids ?? []).find((entry) => entry.id === request.id);
1120
- return { ok: true, result: gridLayerSummary(layer) };
1121
- }
1122
- case "resize_grid_layer": {
1123
- const { applied, state } = dispatchGuarded({ type: "resizeGridLayer", id: request.id, cols: request.cols, rows: request.rows });
1124
- if (!applied)
1125
- return { ok: false, error: `resize_grid_layer rejected: no effect (${request.id})` };
1126
- const layer = (state.document.grids ?? []).find((entry) => entry.id === request.id);
1127
- return { ok: true, result: gridLayerSummary(layer) };
1128
- }
1129
- case "import_grid": {
1130
- const layer = request.format === "csv"
1131
- ? importCsvGrid(request.text, {
1132
- id: request.id,
1133
- kind: request.kind,
1134
- ...(request.empty === undefined ? {} : { empty: request.empty }),
1135
- ...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
1136
- ...(request.origin === undefined ? {} : { origin: request.origin }),
1137
- ...(request.palette === undefined ? {} : { palette: request.palette }),
1138
- })
1139
- : importAsciiGrid(request.text, {
1140
- id: request.id,
1141
- kind: request.kind,
1142
- ...(request.empty === undefined ? {} : { empty: request.empty }),
1143
- ...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
1144
- ...(request.origin === undefined ? {} : { origin: request.origin }),
1145
- ...(request.glyphMap === undefined ? {} : { glyphMap: request.glyphMap }),
1146
- ...(request.palette === undefined ? {} : { palette: request.palette }),
1147
- });
1148
- const { applied } = dispatchGuarded({ type: "addGridLayer", layer });
1149
- if (!applied)
1150
- return { ok: false, error: "import_grid rejected: no effect" };
1151
- return { ok: true, result: gridLayerSummary(layer) };
1152
- }
166
+ const response = EDITOR_RPC_HANDLERS[request.method](ctx, request);
167
+ if (!response.ok) {
168
+ emitEditorConsole("error", "rpc", `${request.method}: ${response.error ?? "failed"}`);
1153
169
  }
170
+ return response;
1154
171
  }
1155
172
  catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ emitEditorConsole("error", "rpc", `${request.method} threw: ${message}`);
1156
175
  return {
1157
176
  ok: false,
1158
- error: error instanceof Error ? error.message : String(error),
177
+ error: message,
1159
178
  };
1160
179
  }
1161
180
  },