@jgengine/editor 0.10.0 → 0.12.0

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