@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.
- package/CHANGELOG.md +52 -0
- package/README.md +12 -0
- package/dist/AssetBrowser.d.ts +33 -1
- package/dist/AssetBrowser.js +64 -2
- package/dist/AssetDropZone.d.ts +9 -0
- package/dist/AssetDropZone.js +65 -0
- package/dist/CatalogsPanel.d.ts +4 -2
- package/dist/CatalogsPanel.js +162 -9
- package/dist/CityPreview.d.ts +11 -0
- package/dist/CityPreview.js +45 -0
- package/dist/CollectionsPanel.js +5 -5
- package/dist/DebugDraw.d.ts +3 -1
- package/dist/DebugDraw.js +37 -19
- package/dist/EditorApp.d.ts +15 -1
- package/dist/EditorApp.js +147 -35
- package/dist/EditorChrome.d.ts +27 -6
- package/dist/EditorChrome.js +314 -130
- package/dist/InspectorPanel.d.ts +17 -7
- package/dist/InspectorPanel.js +173 -27
- package/dist/LightingPanel.d.ts +11 -0
- package/dist/LightingPanel.js +99 -0
- package/dist/MaterialsWorkspacePanel.d.ts +12 -0
- package/dist/MaterialsWorkspacePanel.js +86 -0
- package/dist/ParentPickerMenu.d.ts +13 -0
- package/dist/ParentPickerMenu.js +33 -0
- package/dist/PerfProbe.js +33 -0
- package/dist/PrefabsPanel.js +5 -5
- package/dist/RuntimePlayBridge.d.ts +5 -3
- package/dist/RuntimePlayBridge.js +6 -13
- package/dist/ScatterPreview.d.ts +3 -1
- package/dist/ScatterPreview.js +10 -7
- package/dist/SchemaInspector.js +21 -8
- package/dist/ScriptingPanel.d.ts +15 -0
- package/dist/ScriptingPanel.js +46 -0
- package/dist/SelectionGizmo.js +114 -30
- package/dist/StandaloneEditor.d.ts +16 -9
- package/dist/StandaloneEditor.js +19 -20
- package/dist/TerrainPanel.d.ts +3 -2
- package/dist/TerrainPanel.js +16 -5
- package/dist/TerrainSculpt.js +98 -59
- package/dist/agent/AgentPanel.d.ts +5 -2
- package/dist/agent/AgentPanel.js +11 -6
- package/dist/agent/toolBridge.js +5 -0
- package/dist/assetImport.d.ts +36 -0
- package/dist/assetImport.js +66 -0
- package/dist/authoredComponentMeta.d.ts +25 -0
- package/dist/authoredComponentMeta.js +65 -0
- package/dist/gizmoPivot.d.ts +4 -0
- package/dist/gizmoPivot.js +34 -0
- package/dist/handlers/catalogs.d.ts +3 -0
- package/dist/handlers/catalogs.js +137 -0
- package/dist/handlers/collections.d.ts +3 -0
- package/dist/handlers/collections.js +57 -0
- package/dist/handlers/context.d.ts +36 -0
- package/dist/handlers/context.js +1 -0
- package/dist/handlers/document.d.ts +3 -0
- package/dist/handlers/document.js +350 -0
- package/dist/handlers/grids.d.ts +3 -0
- package/dist/handlers/grids.js +142 -0
- package/dist/handlers/hierarchy.d.ts +3 -0
- package/dist/handlers/hierarchy.js +55 -0
- package/dist/handlers/index.d.ts +8 -0
- package/dist/handlers/index.js +25 -0
- package/dist/handlers/minimap.d.ts +9 -0
- package/dist/handlers/minimap.js +32 -0
- package/dist/handlers/runtime.d.ts +3 -0
- package/dist/handlers/runtime.js +191 -0
- package/dist/handlers/streets.d.ts +3 -0
- package/dist/handlers/streets.js +159 -0
- package/dist/handlers/terrain.d.ts +3 -0
- package/dist/handlers/terrain.js +204 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/listDocumentTriggers.d.ts +42 -0
- package/dist/listDocumentTriggers.js +49 -0
- package/dist/materialAssignments.d.ts +63 -0
- package/dist/materialAssignments.js +84 -0
- package/dist/mcp/fieldSpec.d.ts +32 -0
- package/dist/mcp/fieldSpec.js +174 -0
- package/dist/mcp/rpcRequest.d.ts +8 -5
- package/dist/mcp/rpcRequest.js +72 -93
- package/dist/mcp/tools.js +105 -912
- package/dist/networkSnapshot.d.ts +82 -0
- package/dist/networkSnapshot.js +73 -0
- package/dist/parentCandidates.d.ts +12 -0
- package/dist/parentCandidates.js +25 -0
- package/dist/session.d.ts +91 -4
- package/dist/session.js +36 -1017
- package/dist/shell/AnimationPanel.d.ts +17 -0
- package/dist/shell/AnimationPanel.js +134 -0
- package/dist/shell/AssetThumbnail.d.ts +12 -0
- package/dist/shell/AssetThumbnail.js +23 -0
- package/dist/shell/BottomDock.d.ts +25 -0
- package/dist/shell/BottomDock.js +40 -0
- package/dist/shell/CommandPalette.d.ts +12 -0
- package/dist/shell/CommandPalette.js +56 -0
- package/dist/shell/ConsolePanel.d.ts +8 -0
- package/dist/shell/ConsolePanel.js +45 -0
- package/dist/shell/ContentBrowser.d.ts +21 -0
- package/dist/shell/ContentBrowser.js +130 -0
- package/dist/shell/HierarchyPanel.d.ts +26 -0
- package/dist/shell/HierarchyPanel.js +387 -0
- package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
- package/dist/shell/NetworkWorkspacePanel.js +49 -0
- package/dist/shell/PlayModeBar.d.ts +12 -0
- package/dist/shell/PlayModeBar.js +27 -0
- package/dist/shell/ProfilerPanel.d.ts +10 -0
- package/dist/shell/ProfilerPanel.js +41 -0
- package/dist/shell/SceneToolbar.d.ts +43 -0
- package/dist/shell/SceneToolbar.js +122 -0
- package/dist/shell/StatusBar.d.ts +18 -0
- package/dist/shell/StatusBar.js +41 -0
- package/dist/shell/TopAppBar.d.ts +35 -0
- package/dist/shell/TopAppBar.js +64 -0
- package/dist/shell/ViewportOverlays.d.ts +23 -0
- package/dist/shell/ViewportOverlays.js +136 -0
- package/dist/shell/WorkspaceRail.d.ts +20 -0
- package/dist/shell/WorkspaceRail.js +31 -0
- package/dist/shell/cameraTelemetry.d.ts +27 -0
- package/dist/shell/cameraTelemetry.js +24 -0
- package/dist/shell/commandRegistry.d.ts +58 -0
- package/dist/shell/commandRegistry.js +133 -0
- package/dist/shell/consoleSink.d.ts +11 -0
- package/dist/shell/consoleSink.js +17 -0
- package/dist/shell/consoleStore.d.ts +27 -0
- package/dist/shell/consoleStore.js +43 -0
- package/dist/shell/fields.d.ts +39 -0
- package/dist/shell/fields.js +104 -0
- package/dist/shell/formatSavedRelative.d.ts +2 -0
- package/dist/shell/formatSavedRelative.js +14 -0
- package/dist/shell/glbThumbnailCache.d.ts +45 -0
- package/dist/shell/glbThumbnailCache.js +241 -0
- package/dist/shell/hierarchyA11y.d.ts +21 -0
- package/dist/shell/hierarchyA11y.js +35 -0
- package/dist/shell/icons.d.ts +13 -0
- package/dist/shell/icons.js +85 -0
- package/dist/shell/layoutStore.d.ts +68 -0
- package/dist/shell/layoutStore.js +156 -0
- package/dist/shell/pathFlythrough.d.ts +37 -0
- package/dist/shell/pathFlythrough.js +54 -0
- package/dist/shell/perfHistory.d.ts +70 -0
- package/dist/shell/perfHistory.js +130 -0
- package/dist/shell/renameObject.d.ts +3 -0
- package/dist/shell/renameObject.js +25 -0
- package/dist/shell/theme.d.ts +36 -0
- package/dist/shell/theme.js +37 -0
- package/dist/shell/ui.d.ts +84 -0
- package/dist/shell/ui.js +90 -0
- package/dist/shell/useGlbThumbnail.d.ts +8 -0
- package/dist/shell/useGlbThumbnail.js +19 -0
- package/dist/uiStore.d.ts +27 -0
- package/dist/uiStore.js +10 -0
- package/dist/viewportContextMenu.d.ts +4 -2
- package/dist/viewportContextMenu.js +5 -1
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-editor/SKILL.md +18 -3
- package/skills/jgengine-editor/api.md +403 -20
- package/skills/jgengine-editor/capabilities.md +46 -0
- package/skills/jgengine-editor/reference.md +87 -0
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/OutlinerPanel.d.ts +0 -14
- package/dist/OutlinerPanel.js +0 -84
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createGridLayer, eyedropGridCell, gridCellCount, importAsciiGrid, importCsvGrid, } from "@jgengine/core/editor/index";
|
|
2
|
+
/** Compact grid-layer summary returned by the grid RPC verbs — never the full sparse cell map. */
|
|
3
|
+
function gridLayerSummary(layer) {
|
|
4
|
+
return {
|
|
5
|
+
id: layer.id,
|
|
6
|
+
kind: layer.kind,
|
|
7
|
+
...(layer.label === undefined ? {} : { label: layer.label }),
|
|
8
|
+
cols: layer.cols,
|
|
9
|
+
rows: layer.rows,
|
|
10
|
+
cellSize: layer.cellSize,
|
|
11
|
+
cellCount: gridCellCount(layer),
|
|
12
|
+
visible: layer.visible ?? true,
|
|
13
|
+
paletteIds: (layer.palette ?? []).map((entry) => entry.id),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Runs `fn` with the grid layer in `state`, or short-circuits with an honest not-found response. */
|
|
17
|
+
function withGridLayer(state, id, fn) {
|
|
18
|
+
const layer = (state.document.grids ?? []).find((entry) => entry.id === id);
|
|
19
|
+
if (layer === undefined)
|
|
20
|
+
return { ok: false, error: `grid layer not found: ${id}` };
|
|
21
|
+
return fn(layer);
|
|
22
|
+
}
|
|
23
|
+
/** Grid/tile layer verbs — create, edit, paint, fill, and import. */
|
|
24
|
+
export const gridHandlers = {
|
|
25
|
+
list_grids: (ctx) => {
|
|
26
|
+
const grids = ctx.session.getState().document.grids ?? [];
|
|
27
|
+
return { ok: true, result: { grids: grids.map(gridLayerSummary) } };
|
|
28
|
+
},
|
|
29
|
+
get_grid_cell: (ctx, request) => withGridLayer(ctx.session.getState(), request.id, (layer) => ({
|
|
30
|
+
ok: true,
|
|
31
|
+
result: { id: request.id, col: request.col, row: request.row, value: eyedropGridCell(layer, request.col, request.row) },
|
|
32
|
+
})),
|
|
33
|
+
add_grid_layer: (ctx, request) => {
|
|
34
|
+
const layer = createGridLayer({
|
|
35
|
+
id: request.id,
|
|
36
|
+
kind: request.kind,
|
|
37
|
+
cols: request.cols,
|
|
38
|
+
rows: request.rows,
|
|
39
|
+
...(request.label === undefined ? {} : { label: request.label }),
|
|
40
|
+
...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
|
|
41
|
+
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
42
|
+
...(request.axes === undefined ? {} : { axes: request.axes }),
|
|
43
|
+
...(request.empty === undefined ? {} : { empty: request.empty }),
|
|
44
|
+
...(request.palette === undefined ? {} : { palette: request.palette }),
|
|
45
|
+
});
|
|
46
|
+
const { applied } = ctx.dispatchGuarded({ type: "addGridLayer", layer });
|
|
47
|
+
if (!applied)
|
|
48
|
+
return { ok: false, error: "add_grid_layer rejected: no effect" };
|
|
49
|
+
return { ok: true, result: gridLayerSummary(layer) };
|
|
50
|
+
},
|
|
51
|
+
remove_grid_layer: (ctx, request) => {
|
|
52
|
+
const { applied } = ctx.dispatchGuarded({ type: "removeGridLayer", id: request.id });
|
|
53
|
+
if (!applied)
|
|
54
|
+
return { ok: false, error: `grid layer not found: ${request.id}` };
|
|
55
|
+
return { ok: true, result: { removed: request.id } };
|
|
56
|
+
},
|
|
57
|
+
set_grid_layer: (ctx, request) => {
|
|
58
|
+
const patch = {};
|
|
59
|
+
if (request.label !== undefined)
|
|
60
|
+
patch.label = request.label;
|
|
61
|
+
if (request.kind !== undefined)
|
|
62
|
+
patch.kind = request.kind;
|
|
63
|
+
if (request.visible !== undefined)
|
|
64
|
+
patch.visible = request.visible;
|
|
65
|
+
if (request.empty !== undefined)
|
|
66
|
+
patch.empty = request.empty;
|
|
67
|
+
if (request.cellSize !== undefined)
|
|
68
|
+
patch.cellSize = request.cellSize;
|
|
69
|
+
if (request.origin !== undefined)
|
|
70
|
+
patch.origin = request.origin;
|
|
71
|
+
if (request.axes !== undefined)
|
|
72
|
+
patch.axes = request.axes;
|
|
73
|
+
if (request.palette !== undefined)
|
|
74
|
+
patch.palette = request.palette;
|
|
75
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "setGridLayer", id: request.id, patch });
|
|
76
|
+
if (!applied)
|
|
77
|
+
return { ok: false, error: `set_grid_layer rejected: no effect (${request.id})` };
|
|
78
|
+
return withGridLayer(state, request.id, (layer) => ({ ok: true, result: gridLayerSummary(layer) }));
|
|
79
|
+
},
|
|
80
|
+
paint_grid_cells: (ctx, request) => {
|
|
81
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "paintGridCells", id: request.id, cells: request.cells });
|
|
82
|
+
if (!applied)
|
|
83
|
+
return { ok: false, error: `paint_grid_cells rejected: no effect (${request.id})` };
|
|
84
|
+
return withGridLayer(state, request.id, (layer) => ({ ok: true, result: gridLayerSummary(layer) }));
|
|
85
|
+
},
|
|
86
|
+
fill_grid_rect: (ctx, request) => {
|
|
87
|
+
const { applied, state } = ctx.dispatchGuarded({
|
|
88
|
+
type: "fillGridRect",
|
|
89
|
+
id: request.id,
|
|
90
|
+
col0: request.col0,
|
|
91
|
+
row0: request.row0,
|
|
92
|
+
col1: request.col1,
|
|
93
|
+
row1: request.row1,
|
|
94
|
+
value: request.value,
|
|
95
|
+
});
|
|
96
|
+
if (!applied)
|
|
97
|
+
return { ok: false, error: `fill_grid_rect rejected: no effect (${request.id})` };
|
|
98
|
+
return withGridLayer(state, request.id, (layer) => ({ ok: true, result: gridLayerSummary(layer) }));
|
|
99
|
+
},
|
|
100
|
+
flood_fill_grid: (ctx, request) => {
|
|
101
|
+
const { applied, state } = ctx.dispatchGuarded({
|
|
102
|
+
type: "floodFillGrid",
|
|
103
|
+
id: request.id,
|
|
104
|
+
col: request.col,
|
|
105
|
+
row: request.row,
|
|
106
|
+
value: request.value,
|
|
107
|
+
});
|
|
108
|
+
if (!applied)
|
|
109
|
+
return { ok: false, error: `flood_fill_grid rejected: no effect (${request.id})` };
|
|
110
|
+
return withGridLayer(state, request.id, (layer) => ({ ok: true, result: gridLayerSummary(layer) }));
|
|
111
|
+
},
|
|
112
|
+
resize_grid_layer: (ctx, request) => {
|
|
113
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "resizeGridLayer", id: request.id, cols: request.cols, rows: request.rows });
|
|
114
|
+
if (!applied)
|
|
115
|
+
return { ok: false, error: `resize_grid_layer rejected: no effect (${request.id})` };
|
|
116
|
+
return withGridLayer(state, request.id, (layer) => ({ ok: true, result: gridLayerSummary(layer) }));
|
|
117
|
+
},
|
|
118
|
+
import_grid: (ctx, request) => {
|
|
119
|
+
const layer = request.format === "csv"
|
|
120
|
+
? importCsvGrid(request.text, {
|
|
121
|
+
id: request.id,
|
|
122
|
+
kind: request.kind,
|
|
123
|
+
...(request.empty === undefined ? {} : { empty: request.empty }),
|
|
124
|
+
...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
|
|
125
|
+
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
126
|
+
...(request.palette === undefined ? {} : { palette: request.palette }),
|
|
127
|
+
})
|
|
128
|
+
: importAsciiGrid(request.text, {
|
|
129
|
+
id: request.id,
|
|
130
|
+
kind: request.kind,
|
|
131
|
+
...(request.empty === undefined ? {} : { empty: request.empty }),
|
|
132
|
+
...(request.cellSize === undefined ? {} : { cellSize: request.cellSize }),
|
|
133
|
+
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
134
|
+
...(request.glyphMap === undefined ? {} : { glyphMap: request.glyphMap }),
|
|
135
|
+
...(request.palette === undefined ? {} : { palette: request.palette }),
|
|
136
|
+
});
|
|
137
|
+
const { applied } = ctx.dispatchGuarded({ type: "addGridLayer", layer });
|
|
138
|
+
if (!applied)
|
|
139
|
+
return { ok: false, error: "import_grid rejected: no effect" };
|
|
140
|
+
return { ok: true, result: gridLayerSummary(layer) };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { HandlerTable } from "./context.js";
|
|
2
|
+
/** Parent/child hierarchy and prefab library verbs. */
|
|
3
|
+
export declare const hierarchyHandlers: Pick<HandlerTable, "set_parent" | "hierarchy" | "list_prefabs" | "create_prefab" | "insert_prefab" | "detach_prefab_instance" | "delete_prefab">;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { editorChildren, editorParentOf, editorRoots, findEditorPrefab, summarizeEditorSession, } from "@jgengine/core/editor/index";
|
|
2
|
+
/** Parent/child hierarchy and prefab library verbs. */
|
|
3
|
+
export const hierarchyHandlers = {
|
|
4
|
+
set_parent: (ctx, request) => {
|
|
5
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "setParent", ids: request.ids, parentId: request.parentId });
|
|
6
|
+
if (!applied)
|
|
7
|
+
return { ok: false, error: "set_parent rejected: cyclic parent or empty selection" };
|
|
8
|
+
const doc = state.document;
|
|
9
|
+
return {
|
|
10
|
+
ok: true,
|
|
11
|
+
result: { roots: editorRoots(doc), parents: request.ids.map((id) => ({ id, parentId: editorParentOf(doc, id) ?? null })) },
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
hierarchy: (ctx) => {
|
|
15
|
+
const doc = ctx.session.getState().document;
|
|
16
|
+
const roots = editorRoots(doc);
|
|
17
|
+
return { ok: true, result: { roots, tree: roots.map((id) => ({ id, children: editorChildren(doc, id) })) } };
|
|
18
|
+
},
|
|
19
|
+
list_prefabs: (ctx) => ({ ok: true, result: { prefabs: ctx.session.getState().document.prefabs } }),
|
|
20
|
+
create_prefab: (ctx, request) => {
|
|
21
|
+
if (request.ids.length === 0)
|
|
22
|
+
return { ok: false, error: "create_prefab needs at least one id" };
|
|
23
|
+
ctx.session.dispatch({ type: "createPrefab", id: request.id, name: request.name, ids: request.ids });
|
|
24
|
+
const prefab = findEditorPrefab(ctx.session.getState().document, request.id);
|
|
25
|
+
return prefab === undefined ? { ok: false, error: `prefab not created: ${request.id}` } : { ok: true, result: prefab };
|
|
26
|
+
},
|
|
27
|
+
insert_prefab: (ctx, request) => {
|
|
28
|
+
const prefab = findEditorPrefab(ctx.session.getState().document, request.prefabId);
|
|
29
|
+
if (prefab === undefined)
|
|
30
|
+
return { ok: false, error: `prefab not found: ${request.prefabId}` };
|
|
31
|
+
const at = ctx.api.getFocusTarget() ?? { x: 0, y: 0, z: 0 };
|
|
32
|
+
ctx.session.dispatch({
|
|
33
|
+
type: "insertPrefab",
|
|
34
|
+
prefabId: request.prefabId,
|
|
35
|
+
at: { x: request.x ?? at.x, y: request.y ?? at.y, z: request.z ?? at.z },
|
|
36
|
+
});
|
|
37
|
+
return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
|
|
38
|
+
},
|
|
39
|
+
detach_prefab_instance: (ctx, request) => {
|
|
40
|
+
const doc = ctx.session.getState().document;
|
|
41
|
+
const carries = (item) => item.meta?.prefabInstanceId === request.instanceId;
|
|
42
|
+
const hasInstance = doc.markers.some(carries) || doc.volumes.some(carries) || doc.paths.some(carries) || doc.annotations.some(carries);
|
|
43
|
+
if (!hasInstance)
|
|
44
|
+
return { ok: false, error: `prefab instance not found: ${request.instanceId}` };
|
|
45
|
+
ctx.session.dispatch({ type: "detachPrefabInstance", instanceId: request.instanceId });
|
|
46
|
+
return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
|
|
47
|
+
},
|
|
48
|
+
delete_prefab: (ctx, request) => {
|
|
49
|
+
if (findEditorPrefab(ctx.session.getState().document, request.prefabId) === undefined) {
|
|
50
|
+
return { ok: false, error: `prefab not found: ${request.prefabId}` };
|
|
51
|
+
}
|
|
52
|
+
ctx.session.dispatch({ type: "deletePrefab", prefabId: request.prefabId });
|
|
53
|
+
return { ok: true, result: { prefabs: ctx.session.getState().document.prefabs } };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HandlerTable } from "./context.js";
|
|
2
|
+
/**
|
|
3
|
+
* The complete method → handler dispatch table. Spreading the per-domain tables into a value typed
|
|
4
|
+
* `HandlerTable` is the compile-time completeness check: if any union method lacks a handler (or a
|
|
5
|
+
* domain misspells one), this assignment fails to type-check.
|
|
6
|
+
*/
|
|
7
|
+
export declare const EDITOR_RPC_HANDLERS: HandlerTable;
|
|
8
|
+
export type { HandlerContext } from "./context.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { catalogHandlers } from "./catalogs.js";
|
|
2
|
+
import { collectionHandlers } from "./collections.js";
|
|
3
|
+
import { documentHandlers } from "./document.js";
|
|
4
|
+
import { gridHandlers } from "./grids.js";
|
|
5
|
+
import { hierarchyHandlers } from "./hierarchy.js";
|
|
6
|
+
import { minimapHandlers } from "./minimap.js";
|
|
7
|
+
import { streetsHandlers } from "./streets.js";
|
|
8
|
+
import { runtimeHandlers } from "./runtime.js";
|
|
9
|
+
import { terrainHandlers } from "./terrain.js";
|
|
10
|
+
/**
|
|
11
|
+
* The complete method → handler dispatch table. Spreading the per-domain tables into a value typed
|
|
12
|
+
* `HandlerTable` is the compile-time completeness check: if any union method lacks a handler (or a
|
|
13
|
+
* domain misspells one), this assignment fails to type-check.
|
|
14
|
+
*/
|
|
15
|
+
export const EDITOR_RPC_HANDLERS = {
|
|
16
|
+
...documentHandlers,
|
|
17
|
+
...catalogHandlers,
|
|
18
|
+
...terrainHandlers,
|
|
19
|
+
...minimapHandlers,
|
|
20
|
+
...streetsHandlers,
|
|
21
|
+
...hierarchyHandlers,
|
|
22
|
+
...collectionHandlers,
|
|
23
|
+
...gridHandlers,
|
|
24
|
+
...runtimeHandlers,
|
|
25
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HandlerTable } from "./context.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bake the authored terrain into a stored minimap PNG (#1036). Composes the live viewport's base
|
|
4
|
+
* ground field with the document's sculpt snapshot, rasterizes it top-down via the pure core bake,
|
|
5
|
+
* and stores `{ background, bounds }` on `document.minimap` as an undoable edit — runtime then feeds
|
|
6
|
+
* those straight into the `Minimap`/`WorldMap` props with no re-raster. Needs the mounted viewport's
|
|
7
|
+
* composed sampler, so it is a live-editor-only action, not a headless CLI verb.
|
|
8
|
+
*/
|
|
9
|
+
export declare const minimapHandlers: Pick<HandlerTable, "bake_minimap">;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { bakeMinimapFromDocument } from "@jgengine/core/editor/index";
|
|
2
|
+
import { editableTerrainFromSnapshot } from "@jgengine/core/world/terraform";
|
|
3
|
+
/**
|
|
4
|
+
* Bake the authored terrain into a stored minimap PNG (#1036). Composes the live viewport's base
|
|
5
|
+
* ground field with the document's sculpt snapshot, rasterizes it top-down via the pure core bake,
|
|
6
|
+
* and stores `{ background, bounds }` on `document.minimap` as an undoable edit — runtime then feeds
|
|
7
|
+
* those straight into the `Minimap`/`WorldMap` props with no re-raster. Needs the mounted viewport's
|
|
8
|
+
* composed sampler, so it is a live-editor-only action, not a headless CLI verb.
|
|
9
|
+
*/
|
|
10
|
+
export const minimapHandlers = {
|
|
11
|
+
bake_minimap: (ctx, request) => {
|
|
12
|
+
const base = ctx.getTerrainSampler();
|
|
13
|
+
if (base === null)
|
|
14
|
+
return { ok: false, error: "bake_minimap needs the live editor viewport" };
|
|
15
|
+
const doc = ctx.session.getState().document;
|
|
16
|
+
// Compose base terrain + the document's sculpt delta; a doc with no sculpt bakes the base field.
|
|
17
|
+
const composed = doc.terrain === undefined ? base : editableTerrainFromSnapshot(doc.terrain, base);
|
|
18
|
+
const waterLevel = request.waterLevel ?? base.waterLevel;
|
|
19
|
+
const options = {
|
|
20
|
+
sampleNormal: (x, z) => composed.sampleNormal(x, z),
|
|
21
|
+
...(request.padding === undefined ? {} : { padding: request.padding }),
|
|
22
|
+
...(request.resolution === undefined ? {} : { resolution: request.resolution }),
|
|
23
|
+
...(waterLevel === undefined ? {} : { waterLevel }),
|
|
24
|
+
};
|
|
25
|
+
const { background, mapBounds } = bakeMinimapFromDocument(doc, (x, z) => composed.sampleHeight(x, z), options);
|
|
26
|
+
ctx.dispatchGuarded({ type: "setMinimapBake", minimap: { background, bounds: mapBounds } });
|
|
27
|
+
const b64 = background.slice(background.indexOf(",") + 1);
|
|
28
|
+
const padCount = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
|
29
|
+
const bytes = Math.max(0, Math.floor((b64.length * 3) / 4) - padCount);
|
|
30
|
+
return { ok: true, result: { bounds: mapBounds, bytes } };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { HandlerTable } from "./context.js";
|
|
2
|
+
/** Live-sync document patches, the ephemeral runtime reverse channel, and play-mode poke verbs. */
|
|
3
|
+
export declare const runtimeHandlers: Pick<HandlerTable, "push_document_patch" | "pull_document_patches" | "document_revision" | "push_runtime_delta" | "pull_runtime_deltas" | "runtime_snapshot" | "runtime_summary" | "runtime_get" | "runtime_set" | "runtime_pause" | "runtime_resume" | "runtime_step" | "set_runtime_override" | "clear_runtime_override" | "write_back_override">;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { decodeEditorDocument, getRuntimeInspectorValue, planRuntimeInspectorSet, runtimeEntityMetaWriteBackCommand, runtimeEntityWriteBackCommand, summarizeEditorSession, summarizeRuntimeInspector, } from "@jgengine/core/editor/index";
|
|
2
|
+
/** Live-sync document patches, the ephemeral runtime reverse channel, and play-mode poke verbs. */
|
|
3
|
+
export const runtimeHandlers = {
|
|
4
|
+
push_document_patch: (ctx, request) => {
|
|
5
|
+
const force = request.force === true;
|
|
6
|
+
const rawPatch = request.patch;
|
|
7
|
+
if (rawPatch === null || typeof rawPatch !== "object") {
|
|
8
|
+
return { ok: false, error: "push_document_patch requires a patch object" };
|
|
9
|
+
}
|
|
10
|
+
const patch = rawPatch;
|
|
11
|
+
if (patch.type !== "snapshot" && patch.type !== "commands") {
|
|
12
|
+
return { ok: false, error: `unknown document patch type: ${String(patch.type)} (snapshot | commands)` };
|
|
13
|
+
}
|
|
14
|
+
if (typeof patch.baseRevision !== "number") {
|
|
15
|
+
return { ok: false, error: "push_document_patch requires a numeric baseRevision" };
|
|
16
|
+
}
|
|
17
|
+
if (!force && patch.baseRevision !== ctx.liveSync.getRevision()) {
|
|
18
|
+
return { ok: false, error: `baseRevision mismatch: patch=${patch.baseRevision} current=${ctx.liveSync.getRevision()}` };
|
|
19
|
+
}
|
|
20
|
+
if (patch.type === "snapshot") {
|
|
21
|
+
// A snapshot replaces the whole document, so it must clear the same decode/migrate boundary a
|
|
22
|
+
// disk or `import_document` document does — otherwise a fuzzed snapshot would reach
|
|
23
|
+
// `replaceDocument` uncoerced.
|
|
24
|
+
const decoded = decodeEditorDocument(patch.document);
|
|
25
|
+
if (!decoded.ok) {
|
|
26
|
+
return { ok: false, error: `invalid snapshot document: ${decoded.errors.map((e) => `${e.path} ${e.message}`).join("; ")}` };
|
|
27
|
+
}
|
|
28
|
+
ctx.session.dispatch({ type: "replaceDocument", document: decoded.document });
|
|
29
|
+
return { ok: true, result: { revision: ctx.liveSync.getRevision(), ...summarizeEditorSession(ctx.session.getState()) } };
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(patch.commands))
|
|
32
|
+
return { ok: false, error: "commands patch requires a commands array" };
|
|
33
|
+
if (patch.commands.length === 0)
|
|
34
|
+
return { ok: false, error: "commands patch is empty" };
|
|
35
|
+
for (const command of patch.commands) {
|
|
36
|
+
const { applied } = ctx.dispatchGuarded(command);
|
|
37
|
+
if (!applied && command.type !== "select" && command.type !== "clearSelection") {
|
|
38
|
+
return { ok: false, error: `${command.type} rejected while applying patch` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, result: { revision: ctx.liveSync.getRevision(), ...summarizeEditorSession(ctx.session.getState()) } };
|
|
42
|
+
},
|
|
43
|
+
pull_document_patches: (ctx, request) => ({
|
|
44
|
+
ok: true,
|
|
45
|
+
result: { revision: ctx.liveSync.getRevision(), patches: ctx.liveSync.pullPatches(request.sinceRevision ?? 0) },
|
|
46
|
+
}),
|
|
47
|
+
document_revision: (ctx, request) => ({
|
|
48
|
+
ok: true,
|
|
49
|
+
result: {
|
|
50
|
+
revision: ctx.liveSync.getRevision(),
|
|
51
|
+
...(request.includeDocument === true ? { document: ctx.liveSync.getDocument() } : {}),
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
push_runtime_delta: (ctx, request) => {
|
|
55
|
+
const delta = ctx.liveSync.pushRuntimeDelta({
|
|
56
|
+
at: request.at ?? Date.now(),
|
|
57
|
+
...(request.entities === undefined ? {} : { entities: request.entities }),
|
|
58
|
+
...(request.removeIds === undefined ? {} : { removeIds: request.removeIds }),
|
|
59
|
+
...(request.tunables === undefined ? {} : { tunables: request.tunables }),
|
|
60
|
+
});
|
|
61
|
+
return { ok: true, result: delta };
|
|
62
|
+
},
|
|
63
|
+
pull_runtime_deltas: (ctx, request) => ({
|
|
64
|
+
ok: true,
|
|
65
|
+
result: {
|
|
66
|
+
seq: ctx.liveSync.getRuntimeState().seq,
|
|
67
|
+
deltas: ctx.liveSync.pullRuntimeDeltas(request.sinceSeq ?? 0),
|
|
68
|
+
...(request.includeSnapshot === true ? { snapshot: ctx.liveSync.getRuntimeState() } : {}),
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
runtime_snapshot: (ctx) => ({ ok: true, result: ctx.liveSync.getRuntimeState() }),
|
|
72
|
+
runtime_summary: (ctx) => ({
|
|
73
|
+
ok: true,
|
|
74
|
+
result: summarizeRuntimeInspector(ctx.liveSync.getRuntimeState(), ctx.liveSync.getRuntimeOverrides(), ctx.api.getPlayControl()),
|
|
75
|
+
}),
|
|
76
|
+
runtime_get: (ctx, request) => {
|
|
77
|
+
if (request.id.length === 0)
|
|
78
|
+
return { ok: false, error: "runtime_get requires id" };
|
|
79
|
+
const got = getRuntimeInspectorValue(ctx.liveSync.getRuntimeState(), ctx.liveSync.getRuntimeOverrides(), request.id, request.path);
|
|
80
|
+
if (got.kind === "missing")
|
|
81
|
+
return { ok: false, error: `runtime entity/tunable not found: ${request.id}` };
|
|
82
|
+
return { ok: true, result: got };
|
|
83
|
+
},
|
|
84
|
+
runtime_set: (ctx, request) => {
|
|
85
|
+
if (request.id.length === 0)
|
|
86
|
+
return { ok: false, error: "runtime_set requires id" };
|
|
87
|
+
const plan = planRuntimeInspectorSet(ctx.session.getState().document, {
|
|
88
|
+
id: request.id,
|
|
89
|
+
...(request.path === undefined ? {} : { path: request.path }),
|
|
90
|
+
...(request.value === undefined ? {} : { value: request.value }),
|
|
91
|
+
...(request.position === undefined ? {} : { position: request.position }),
|
|
92
|
+
...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
|
|
93
|
+
...(request.values === undefined ? {} : { values: request.values }),
|
|
94
|
+
...(request.writeBack === undefined ? {} : { writeBack: request.writeBack }),
|
|
95
|
+
});
|
|
96
|
+
if (plan.error !== undefined)
|
|
97
|
+
return { ok: false, error: plan.error };
|
|
98
|
+
if (plan.tunable !== undefined) {
|
|
99
|
+
ctx.liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { [plan.tunable.key]: plan.tunable.value } });
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
result: {
|
|
103
|
+
kind: "tunable",
|
|
104
|
+
key: plan.tunable.key,
|
|
105
|
+
value: plan.tunable.value,
|
|
106
|
+
writeBack: false,
|
|
107
|
+
...summarizeRuntimeInspector(ctx.liveSync.getRuntimeState(), ctx.liveSync.getRuntimeOverrides(), ctx.api.getPlayControl()),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (plan.entity === undefined)
|
|
112
|
+
return { ok: false, error: "runtime_set produced no entity" };
|
|
113
|
+
// Apply the document write-back first so a rejected command leaves the document — and the reverse
|
|
114
|
+
// channel — untouched. Any commands that did land are undone before bailing, keeping the edit atomic.
|
|
115
|
+
let wroteBack = false;
|
|
116
|
+
let appliedCount = 0;
|
|
117
|
+
let lastState = ctx.session.getState();
|
|
118
|
+
for (const command of plan.writeBackCommands) {
|
|
119
|
+
const { applied, state } = ctx.dispatchGuarded(command);
|
|
120
|
+
if (!applied) {
|
|
121
|
+
for (let i = 0; i < appliedCount; i += 1)
|
|
122
|
+
ctx.session.dispatch({ type: "undo" });
|
|
123
|
+
return { ok: false, error: `runtime_set write-back rejected: ${command.type}` };
|
|
124
|
+
}
|
|
125
|
+
appliedCount += 1;
|
|
126
|
+
lastState = state;
|
|
127
|
+
wroteBack = true;
|
|
128
|
+
}
|
|
129
|
+
// Only publish the ephemeral override + delta once the document write-back (if any) has committed.
|
|
130
|
+
ctx.liveSync.setRuntimeOverride(plan.entity);
|
|
131
|
+
ctx.liveSync.pushRuntimeDelta({ at: Date.now(), entities: [plan.entity] });
|
|
132
|
+
if (wroteBack)
|
|
133
|
+
ctx.liveSync.clearRuntimeOverride(plan.entity.id);
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
result: {
|
|
137
|
+
kind: "entity",
|
|
138
|
+
entity: plan.entity,
|
|
139
|
+
writeBack: wroteBack,
|
|
140
|
+
revision: ctx.liveSync.getRevision(),
|
|
141
|
+
...summarizeEditorSession(lastState),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
runtime_pause: (ctx) => {
|
|
146
|
+
const play = ctx.api.setPlayControl({ paused: true, pendingSteps: 0 });
|
|
147
|
+
ctx.liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true } });
|
|
148
|
+
return { ok: true, result: { play } };
|
|
149
|
+
},
|
|
150
|
+
runtime_resume: (ctx) => {
|
|
151
|
+
const play = ctx.api.setPlayControl({ paused: false, pendingSteps: 0 });
|
|
152
|
+
ctx.liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: false } });
|
|
153
|
+
return { ok: true, result: { play } };
|
|
154
|
+
},
|
|
155
|
+
runtime_step: (ctx, request) => {
|
|
156
|
+
const frames = request.frames === undefined ? 1 : Math.max(1, Math.floor(request.frames));
|
|
157
|
+
// Thread the applied control back so the published delta agrees with the incremented step count.
|
|
158
|
+
const play = ctx.api.setPlayControl({ paused: true, pendingSteps: ctx.api.getPlayControl().pendingSteps + frames });
|
|
159
|
+
ctx.liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true, pendingSteps: play.pendingSteps } });
|
|
160
|
+
return { ok: true, result: { play } };
|
|
161
|
+
},
|
|
162
|
+
set_runtime_override: (ctx, request) => {
|
|
163
|
+
ctx.liveSync.setRuntimeOverride(request.entity);
|
|
164
|
+
return { ok: true, result: { overrides: ctx.liveSync.getRuntimeOverrides() } };
|
|
165
|
+
},
|
|
166
|
+
clear_runtime_override: (ctx, request) => {
|
|
167
|
+
ctx.liveSync.clearRuntimeOverride(request.id);
|
|
168
|
+
return { ok: true, result: { overrides: ctx.liveSync.getRuntimeOverrides() } };
|
|
169
|
+
},
|
|
170
|
+
write_back_override: (ctx, request) => {
|
|
171
|
+
const entity = ctx.liveSync.getRuntimeOverrides()[request.id];
|
|
172
|
+
if (entity === undefined)
|
|
173
|
+
return { ok: false, error: `no runtime override for "${request.id}"` };
|
|
174
|
+
const transform = runtimeEntityWriteBackCommand(ctx.session.getState().document, entity);
|
|
175
|
+
const meta = runtimeEntityMetaWriteBackCommand(ctx.session.getState().document, entity);
|
|
176
|
+
if (transform === null && meta === null) {
|
|
177
|
+
return { ok: false, error: `override "${request.id}" has nothing to write back into the document` };
|
|
178
|
+
}
|
|
179
|
+
let lastState = ctx.session.getState();
|
|
180
|
+
for (const command of [transform, meta]) {
|
|
181
|
+
if (command === null)
|
|
182
|
+
continue;
|
|
183
|
+
const { applied, state } = ctx.dispatchGuarded(command);
|
|
184
|
+
if (!applied)
|
|
185
|
+
return { ok: false, error: `write_back_override rejected for "${request.id}"` };
|
|
186
|
+
lastState = state;
|
|
187
|
+
}
|
|
188
|
+
ctx.liveSync.clearRuntimeOverride(request.id);
|
|
189
|
+
return { ok: true, result: { revision: ctx.liveSync.getRevision(), ...summarizeEditorSession(lastState) } };
|
|
190
|
+
},
|
|
191
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { generateStreets } from "@jgengine/core/world/streetGenerator";
|
|
2
|
+
/** Fully-defaulted slider preset for an open street net (resolves to `net` in `streetNetworkMode`). */
|
|
3
|
+
const NET_DEFAULTS = {
|
|
4
|
+
gridness: 0.9,
|
|
5
|
+
loopiness: 0.2,
|
|
6
|
+
connectivity: 0.35,
|
|
7
|
+
branching: 0.3,
|
|
8
|
+
deadEnds: 0.5,
|
|
9
|
+
segmentLength: 48,
|
|
10
|
+
aspect: 1,
|
|
11
|
+
winding: 0.2,
|
|
12
|
+
minCurveRadius: 30,
|
|
13
|
+
minTurnAngle: 0,
|
|
14
|
+
maxTurnAngle: 120,
|
|
15
|
+
width: 7,
|
|
16
|
+
boulevards: 0.3,
|
|
17
|
+
};
|
|
18
|
+
/** Fully-defaulted slider preset for a closed race circuit (resolves to `circuit` in `streetNetworkMode`). */
|
|
19
|
+
const CIRCUIT_DEFAULTS = {
|
|
20
|
+
gridness: 0.1,
|
|
21
|
+
loopiness: 1,
|
|
22
|
+
connectivity: 0,
|
|
23
|
+
branching: 0,
|
|
24
|
+
deadEnds: 0,
|
|
25
|
+
segmentLength: 60,
|
|
26
|
+
aspect: 1,
|
|
27
|
+
winding: 0.5,
|
|
28
|
+
minCurveRadius: 24,
|
|
29
|
+
minTurnAngle: 0,
|
|
30
|
+
maxTurnAngle: 70,
|
|
31
|
+
width: 12,
|
|
32
|
+
boulevards: 0,
|
|
33
|
+
};
|
|
34
|
+
/** The numeric slider keys a caller may overlay onto the mode preset via `params`. */
|
|
35
|
+
const RULE_KEYS = [
|
|
36
|
+
"gridness",
|
|
37
|
+
"loopiness",
|
|
38
|
+
"connectivity",
|
|
39
|
+
"branching",
|
|
40
|
+
"deadEnds",
|
|
41
|
+
"segmentLength",
|
|
42
|
+
"aspect",
|
|
43
|
+
"winding",
|
|
44
|
+
"minCurveRadius",
|
|
45
|
+
"minTurnAngle",
|
|
46
|
+
"maxTurnAngle",
|
|
47
|
+
"width",
|
|
48
|
+
"boulevards",
|
|
49
|
+
];
|
|
50
|
+
/** The tag every baked path carries in `meta.generator`, so a re-run can find and replace its own output. */
|
|
51
|
+
const GENERATOR_TAG = "streetGenerator";
|
|
52
|
+
/** Stable id prefix for a seed's baked paths — the idempotency key for replace-on-re-run. */
|
|
53
|
+
function genPrefix(seed) {
|
|
54
|
+
return `gen-${seed}-`;
|
|
55
|
+
}
|
|
56
|
+
/** True when a path was baked by this generator for `seed` (matches id prefix OR the meta tag). */
|
|
57
|
+
function isGeneratedForSeed(path, seed) {
|
|
58
|
+
if (path.id.startsWith(genPrefix(seed)))
|
|
59
|
+
return true;
|
|
60
|
+
const meta = path.meta;
|
|
61
|
+
return meta !== undefined && meta.generator === GENERATOR_TAG && meta.seed === seed;
|
|
62
|
+
}
|
|
63
|
+
/** Half-extents (hx, hz) and world origin a volume fills, so the generator grows inside its footprint. */
|
|
64
|
+
function volumeFootprint(volume) {
|
|
65
|
+
if (volume.shape === "box" && volume.halfExtents !== undefined) {
|
|
66
|
+
return { hx: Math.max(1, volume.halfExtents.x), hz: Math.max(1, volume.halfExtents.z), origin: volume.center };
|
|
67
|
+
}
|
|
68
|
+
// sphere / cylinder (or a box missing halfExtents): a radius is symmetric in x/z.
|
|
69
|
+
const r = Math.max(1, volume.radius ?? 0);
|
|
70
|
+
return { hx: r, hz: r, origin: volume.center };
|
|
71
|
+
}
|
|
72
|
+
/** Document, selection, camera, mode, asset placement, and status verbs. */
|
|
73
|
+
export const streetsHandlers = {
|
|
74
|
+
generate_streets: (ctx, request) => {
|
|
75
|
+
const doc = ctx.session.getState().document;
|
|
76
|
+
const seed = request.seed ?? "path";
|
|
77
|
+
const requestedMode = request.mode === "circuit" ? "circuit" : "net";
|
|
78
|
+
// Resolve the footprint + world origin from a named volume or from explicit bounds.
|
|
79
|
+
let hx;
|
|
80
|
+
let hz;
|
|
81
|
+
let origin;
|
|
82
|
+
if (request.volumeId !== undefined) {
|
|
83
|
+
const volume = doc.volumes.find((v) => v.id === request.volumeId);
|
|
84
|
+
if (volume === undefined)
|
|
85
|
+
return { ok: false, error: `volume not found: ${request.volumeId}` };
|
|
86
|
+
({ hx, hz, origin } = volumeFootprint(volume));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const center = request.center ?? { x: 0, y: 0, z: 0 };
|
|
90
|
+
if (request.halfX === undefined || request.halfZ === undefined) {
|
|
91
|
+
return { ok: false, error: "generate_streets requires volumeId, or center + halfX + halfZ" };
|
|
92
|
+
}
|
|
93
|
+
hx = Math.max(1, request.halfX);
|
|
94
|
+
hz = Math.max(1, request.halfZ);
|
|
95
|
+
origin = center;
|
|
96
|
+
}
|
|
97
|
+
// Build rules: mode preset, overlaid with any numeric slider overrides from `params`.
|
|
98
|
+
const preset = requestedMode === "circuit" ? CIRCUIT_DEFAULTS : NET_DEFAULTS;
|
|
99
|
+
const rules = { seed, ...preset };
|
|
100
|
+
const params = request.params;
|
|
101
|
+
if (params !== undefined) {
|
|
102
|
+
for (const key of RULE_KEYS) {
|
|
103
|
+
const value = params[key];
|
|
104
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
105
|
+
rules[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const network = generateStreets(rules, hx, hz);
|
|
110
|
+
// The generator resolves the ACTUAL topology from the rules; use it for the default path kind so a
|
|
111
|
+
// circuit bakes `route`s and a net bakes `road`s even when the caller's sliders cross the boundary.
|
|
112
|
+
const kind = request.kind ?? (network.mode === "circuit" ? "route" : "road");
|
|
113
|
+
const baseY = origin.y;
|
|
114
|
+
const toWorld = (street) => street.points.map(([lx, lz]) => ({ x: origin.x + lx, y: baseY, z: origin.z + lz }));
|
|
115
|
+
// Through-streets → one path each (a circuit's ring is a single closed loop street: first ≈ last).
|
|
116
|
+
const newPaths = [];
|
|
117
|
+
network.streets.forEach((street, index) => {
|
|
118
|
+
const points = toWorld(street);
|
|
119
|
+
if (points.length < 2)
|
|
120
|
+
return;
|
|
121
|
+
newPaths.push({
|
|
122
|
+
id: `${genPrefix(seed)}${index}`,
|
|
123
|
+
kind,
|
|
124
|
+
points,
|
|
125
|
+
width: street.width,
|
|
126
|
+
label: `${kind} ${index}`,
|
|
127
|
+
meta: {
|
|
128
|
+
generator: GENERATOR_TAG,
|
|
129
|
+
seed,
|
|
130
|
+
mode: network.mode,
|
|
131
|
+
level: street.level,
|
|
132
|
+
loop: street.loop,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// Idempotent replace: drop this seed's prior baked paths, keep everything else, append the fresh set —
|
|
137
|
+
// all as ONE undoable structural edit via replaceDocument.
|
|
138
|
+
const kept = doc.paths.filter((p) => !isGeneratedForSeed(p, seed));
|
|
139
|
+
const nextDoc = { ...doc, paths: [...kept, ...newPaths] };
|
|
140
|
+
const { applied } = ctx.dispatchGuarded({ type: "replaceDocument", document: nextDoc });
|
|
141
|
+
if (!applied)
|
|
142
|
+
return { ok: false, error: "generate_streets: document replace had no effect" };
|
|
143
|
+
// replaceDocument clears selection; re-select the freshly baked paths for the caller.
|
|
144
|
+
ctx.session.dispatch({ type: "select", ids: newPaths.map((p) => p.id) });
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
result: {
|
|
148
|
+
seed,
|
|
149
|
+
requestedMode,
|
|
150
|
+
mode: network.mode,
|
|
151
|
+
kind,
|
|
152
|
+
pathCount: newPaths.length,
|
|
153
|
+
removed: doc.paths.length - kept.length,
|
|
154
|
+
ids: newPaths.map((p) => p.id),
|
|
155
|
+
footprint: { hx, hz, origin },
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { HandlerTable } from "./context.js";
|
|
2
|
+
/** Sculpt heightfield, material painting, terrain layers, and foliage/scatter verbs. */
|
|
3
|
+
export declare const terrainHandlers: Pick<HandlerTable, "create_terrain" | "sculpt_terrain" | "terrain_summary" | "paint_terrain" | "fill_terrain" | "auto_paint" | "terrain_materials" | "terrain_layers" | "set_terrain_layers" | "blend_terrain" | "convert_scatter" | "add_foliage" | "scatter_summary">;
|