@jgengine/editor 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -0
  3. package/dist/AssetBrowser.d.ts +33 -1
  4. package/dist/AssetBrowser.js +64 -2
  5. package/dist/AssetDropZone.d.ts +9 -0
  6. package/dist/AssetDropZone.js +65 -0
  7. package/dist/CatalogsPanel.d.ts +4 -2
  8. package/dist/CatalogsPanel.js +162 -9
  9. package/dist/CityPreview.d.ts +11 -0
  10. package/dist/CityPreview.js +45 -0
  11. package/dist/CollectionsPanel.js +5 -5
  12. package/dist/DebugDraw.d.ts +3 -1
  13. package/dist/DebugDraw.js +37 -19
  14. package/dist/EditorApp.d.ts +15 -1
  15. package/dist/EditorApp.js +147 -35
  16. package/dist/EditorChrome.d.ts +27 -6
  17. package/dist/EditorChrome.js +314 -130
  18. package/dist/InspectorPanel.d.ts +17 -7
  19. package/dist/InspectorPanel.js +173 -27
  20. package/dist/LightingPanel.d.ts +11 -0
  21. package/dist/LightingPanel.js +99 -0
  22. package/dist/MaterialsWorkspacePanel.d.ts +12 -0
  23. package/dist/MaterialsWorkspacePanel.js +86 -0
  24. package/dist/ParentPickerMenu.d.ts +13 -0
  25. package/dist/ParentPickerMenu.js +33 -0
  26. package/dist/PerfProbe.js +33 -0
  27. package/dist/PrefabsPanel.js +5 -5
  28. package/dist/RuntimePlayBridge.d.ts +5 -3
  29. package/dist/RuntimePlayBridge.js +6 -13
  30. package/dist/ScatterPreview.d.ts +3 -1
  31. package/dist/ScatterPreview.js +10 -7
  32. package/dist/SchemaInspector.js +21 -8
  33. package/dist/ScriptingPanel.d.ts +15 -0
  34. package/dist/ScriptingPanel.js +46 -0
  35. package/dist/SelectionGizmo.js +114 -30
  36. package/dist/StandaloneEditor.d.ts +16 -9
  37. package/dist/StandaloneEditor.js +19 -20
  38. package/dist/TerrainPanel.d.ts +3 -2
  39. package/dist/TerrainPanel.js +16 -5
  40. package/dist/TerrainSculpt.js +98 -59
  41. package/dist/agent/AgentPanel.d.ts +5 -2
  42. package/dist/agent/AgentPanel.js +11 -6
  43. package/dist/agent/toolBridge.js +5 -0
  44. package/dist/assetImport.d.ts +36 -0
  45. package/dist/assetImport.js +66 -0
  46. package/dist/authoredComponentMeta.d.ts +25 -0
  47. package/dist/authoredComponentMeta.js +65 -0
  48. package/dist/gizmoPivot.d.ts +4 -0
  49. package/dist/gizmoPivot.js +34 -0
  50. package/dist/handlers/catalogs.d.ts +3 -0
  51. package/dist/handlers/catalogs.js +137 -0
  52. package/dist/handlers/collections.d.ts +3 -0
  53. package/dist/handlers/collections.js +57 -0
  54. package/dist/handlers/context.d.ts +36 -0
  55. package/dist/handlers/context.js +1 -0
  56. package/dist/handlers/document.d.ts +3 -0
  57. package/dist/handlers/document.js +350 -0
  58. package/dist/handlers/grids.d.ts +3 -0
  59. package/dist/handlers/grids.js +142 -0
  60. package/dist/handlers/hierarchy.d.ts +3 -0
  61. package/dist/handlers/hierarchy.js +55 -0
  62. package/dist/handlers/index.d.ts +8 -0
  63. package/dist/handlers/index.js +25 -0
  64. package/dist/handlers/minimap.d.ts +9 -0
  65. package/dist/handlers/minimap.js +32 -0
  66. package/dist/handlers/runtime.d.ts +3 -0
  67. package/dist/handlers/runtime.js +191 -0
  68. package/dist/handlers/streets.d.ts +3 -0
  69. package/dist/handlers/streets.js +159 -0
  70. package/dist/handlers/terrain.d.ts +3 -0
  71. package/dist/handlers/terrain.js +204 -0
  72. package/dist/index.d.ts +3 -2
  73. package/dist/index.js +3 -2
  74. package/dist/listDocumentTriggers.d.ts +42 -0
  75. package/dist/listDocumentTriggers.js +49 -0
  76. package/dist/materialAssignments.d.ts +63 -0
  77. package/dist/materialAssignments.js +84 -0
  78. package/dist/mcp/fieldSpec.d.ts +32 -0
  79. package/dist/mcp/fieldSpec.js +174 -0
  80. package/dist/mcp/rpcRequest.d.ts +8 -5
  81. package/dist/mcp/rpcRequest.js +72 -93
  82. package/dist/mcp/tools.js +105 -912
  83. package/dist/networkSnapshot.d.ts +82 -0
  84. package/dist/networkSnapshot.js +73 -0
  85. package/dist/parentCandidates.d.ts +12 -0
  86. package/dist/parentCandidates.js +25 -0
  87. package/dist/session.d.ts +91 -4
  88. package/dist/session.js +36 -1017
  89. package/dist/shell/AnimationPanel.d.ts +17 -0
  90. package/dist/shell/AnimationPanel.js +134 -0
  91. package/dist/shell/AssetThumbnail.d.ts +12 -0
  92. package/dist/shell/AssetThumbnail.js +23 -0
  93. package/dist/shell/BottomDock.d.ts +25 -0
  94. package/dist/shell/BottomDock.js +40 -0
  95. package/dist/shell/CommandPalette.d.ts +12 -0
  96. package/dist/shell/CommandPalette.js +56 -0
  97. package/dist/shell/ConsolePanel.d.ts +8 -0
  98. package/dist/shell/ConsolePanel.js +45 -0
  99. package/dist/shell/ContentBrowser.d.ts +21 -0
  100. package/dist/shell/ContentBrowser.js +130 -0
  101. package/dist/shell/HierarchyPanel.d.ts +26 -0
  102. package/dist/shell/HierarchyPanel.js +387 -0
  103. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  104. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  105. package/dist/shell/PlayModeBar.d.ts +12 -0
  106. package/dist/shell/PlayModeBar.js +27 -0
  107. package/dist/shell/ProfilerPanel.d.ts +10 -0
  108. package/dist/shell/ProfilerPanel.js +41 -0
  109. package/dist/shell/SceneToolbar.d.ts +43 -0
  110. package/dist/shell/SceneToolbar.js +122 -0
  111. package/dist/shell/StatusBar.d.ts +18 -0
  112. package/dist/shell/StatusBar.js +41 -0
  113. package/dist/shell/TopAppBar.d.ts +35 -0
  114. package/dist/shell/TopAppBar.js +64 -0
  115. package/dist/shell/ViewportOverlays.d.ts +23 -0
  116. package/dist/shell/ViewportOverlays.js +136 -0
  117. package/dist/shell/WorkspaceRail.d.ts +20 -0
  118. package/dist/shell/WorkspaceRail.js +31 -0
  119. package/dist/shell/cameraTelemetry.d.ts +27 -0
  120. package/dist/shell/cameraTelemetry.js +24 -0
  121. package/dist/shell/commandRegistry.d.ts +58 -0
  122. package/dist/shell/commandRegistry.js +133 -0
  123. package/dist/shell/consoleSink.d.ts +11 -0
  124. package/dist/shell/consoleSink.js +17 -0
  125. package/dist/shell/consoleStore.d.ts +27 -0
  126. package/dist/shell/consoleStore.js +43 -0
  127. package/dist/shell/fields.d.ts +39 -0
  128. package/dist/shell/fields.js +104 -0
  129. package/dist/shell/formatSavedRelative.d.ts +2 -0
  130. package/dist/shell/formatSavedRelative.js +14 -0
  131. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  132. package/dist/shell/glbThumbnailCache.js +241 -0
  133. package/dist/shell/hierarchyA11y.d.ts +21 -0
  134. package/dist/shell/hierarchyA11y.js +35 -0
  135. package/dist/shell/icons.d.ts +13 -0
  136. package/dist/shell/icons.js +85 -0
  137. package/dist/shell/layoutStore.d.ts +68 -0
  138. package/dist/shell/layoutStore.js +156 -0
  139. package/dist/shell/pathFlythrough.d.ts +37 -0
  140. package/dist/shell/pathFlythrough.js +54 -0
  141. package/dist/shell/perfHistory.d.ts +70 -0
  142. package/dist/shell/perfHistory.js +130 -0
  143. package/dist/shell/renameObject.d.ts +3 -0
  144. package/dist/shell/renameObject.js +25 -0
  145. package/dist/shell/theme.d.ts +36 -0
  146. package/dist/shell/theme.js +37 -0
  147. package/dist/shell/ui.d.ts +84 -0
  148. package/dist/shell/ui.js +90 -0
  149. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  150. package/dist/shell/useGlbThumbnail.js +19 -0
  151. package/dist/uiStore.d.ts +27 -0
  152. package/dist/uiStore.js +10 -0
  153. package/dist/viewportContextMenu.d.ts +4 -2
  154. package/dist/viewportContextMenu.js +5 -1
  155. package/package.json +4 -4
  156. package/skills/jgengine/SKILL.md +8 -4
  157. package/skills/jgengine/api.md +182 -81
  158. package/skills/jgengine/capabilities.md +104 -2
  159. package/skills/jgengine/recipes/minimal-game.md +88 -0
  160. package/skills/jgengine-editor/SKILL.md +18 -3
  161. package/skills/jgengine-editor/api.md +403 -20
  162. package/skills/jgengine-editor/capabilities.md +46 -0
  163. package/skills/jgengine-editor/reference.md +87 -0
  164. package/skills/level-design/SKILL.md +54 -0
  165. package/skills/level-design/agents/openai.yaml +4 -0
  166. package/skills/level-design/references/application-playbook.md +192 -0
  167. package/skills/level-design/references/field-guide.md +337 -0
  168. package/skills/level-design/references/live-editor-loop.md +179 -0
  169. package/dist/OutlinerPanel.d.ts +0 -14
  170. package/dist/OutlinerPanel.js +0 -84
  171. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -5,7 +5,8 @@ import * as THREE from "three";
5
5
  import { findEditorMarker, findEditorNote, findEditorPath, findEditorVolume, } from "@jgengine/core/editor/index";
6
6
  import { TransformGizmo, } from "@jgengine/shell/structures/TransformGizmo";
7
7
  import { isPointerClick } from "./viewportContextMenu.js";
8
- import { newPlacementId } from "./uiStore.js";
8
+ import { resolvePivotPosition } from "./gizmoPivot.js";
9
+ import { newPlacementId, } from "./uiStore.js";
9
10
  import { useStoreSelector } from "./useStoreSelector.js";
10
11
  const CLICK_SLOP_PX = 5;
11
12
  const SCREEN_PICK_RADIUS_PX = 22;
@@ -15,10 +16,26 @@ const NOTE_LIFT = 1;
15
16
  const SELECT_TAGS = ["jgEditorId", "jgEntityId", "jgObjectId"];
16
17
  const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
17
18
  function selectGizmoUi(state) {
18
- return { gizmoMode: state.gizmoMode, snapMode: state.snapMode, gridSize: state.gridSize, pathPoint: state.pathPoint };
19
+ return {
20
+ gizmoMode: state.gizmoMode,
21
+ gizmoSpace: state.gizmoSpace,
22
+ gizmoPivot: state.gizmoPivot,
23
+ snapMode: state.snapMode,
24
+ gridSize: state.gridSize,
25
+ rotationSnapDeg: state.rotationSnapDeg,
26
+ scaleSnap: state.scaleSnap,
27
+ pathPoint: state.pathPoint,
28
+ };
19
29
  }
20
30
  function gizmoUiEqual(a, b) {
21
- return a.gizmoMode === b.gizmoMode && a.snapMode === b.snapMode && a.gridSize === b.gridSize && a.pathPoint === b.pathPoint;
31
+ return (a.gizmoMode === b.gizmoMode &&
32
+ a.gizmoSpace === b.gizmoSpace &&
33
+ a.gizmoPivot === b.gizmoPivot &&
34
+ a.snapMode === b.snapMode &&
35
+ a.gridSize === b.gridSize &&
36
+ a.rotationSnapDeg === b.rotationSnapDeg &&
37
+ a.scaleSnap === b.scaleSnap &&
38
+ a.pathPoint === b.pathPoint);
22
39
  }
23
40
  /** Reads only the gizmo-relevant UI slice (mode, snap, path-point anchor) — skips re-render on
24
41
  * unrelated UI churn (sculpt/paint drags, tool switches, placement) that the old blanket
@@ -41,6 +58,24 @@ function pathCentroid(points) {
41
58
  const n = Math.max(1, points.length);
42
59
  return { x: x / n, y: y / n, z: z / n };
43
60
  }
61
+ function objectPosition(session, id) {
62
+ const { document } = session.getState();
63
+ const marker = findEditorMarker(document, id);
64
+ if (marker !== undefined) {
65
+ return { position: marker.position, lift: MARKER_LIFT, rotationY: marker.rotationY ?? 0, kind: "marker" };
66
+ }
67
+ const volume = findEditorVolume(document, id);
68
+ if (volume !== undefined)
69
+ return { position: volume.center, lift: 0, rotationY: 0, kind: "volume" };
70
+ const path = findEditorPath(document, id);
71
+ if (path !== undefined && path.points.length > 0) {
72
+ return { position: pathCentroid(path.points), lift: 0.8, rotationY: 0, kind: "path" };
73
+ }
74
+ const note = findEditorNote(document, id);
75
+ if (note !== undefined)
76
+ return { position: note.position, lift: NOTE_LIFT, rotationY: 0, kind: "note" };
77
+ return null;
78
+ }
44
79
  function isEditorOverlayNode(node) {
45
80
  let current = node;
46
81
  while (current !== null) {
@@ -146,21 +181,24 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
146
181
  const state = session().getState();
147
182
  const visibility = api.getVisibility();
148
183
  const candidates = [];
149
- const consider = (id, kind, point, lift) => {
150
- if (visibility[kind] === false)
184
+ const consider = (id, kind, point, lift, hidden) => {
185
+ if (visibility[kind] === false || hidden === true)
151
186
  return;
152
187
  const distance = screenDistance(point, lift, clickX, clickY, rect);
153
188
  if (distance !== null && distance < SCREEN_PICK_RADIUS_PX)
154
189
  candidates.push({ id, distance });
155
190
  };
156
- for (const marker of state.document.markers)
157
- consider(marker.id, marker.kind, marker.position, MARKER_LIFT);
158
- for (const volume of state.document.volumes)
159
- consider(volume.id, volume.kind, volume.center, 0);
160
- for (const note of state.document.annotations)
161
- consider(note.id, "note", note.position, NOTE_LIFT);
191
+ for (const marker of state.document.markers) {
192
+ consider(marker.id, marker.kind, marker.position, MARKER_LIFT, marker.hidden);
193
+ }
194
+ for (const volume of state.document.volumes) {
195
+ consider(volume.id, volume.kind, volume.center, 0, volume.hidden);
196
+ }
197
+ for (const note of state.document.annotations) {
198
+ consider(note.id, "note", note.position, NOTE_LIFT, note.hidden);
199
+ }
162
200
  for (const path of state.document.paths) {
163
- if (visibility[path.kind] === false)
201
+ if (visibility[path.kind] === false || path.hidden === true)
164
202
  continue;
165
203
  let best = null;
166
204
  for (const point of path.points) {
@@ -211,6 +249,44 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
211
249
  }
212
250
  return null;
213
251
  };
252
+ // Throttled hover pick for pre-selection affordance — reuses the same document pick path as click.
253
+ let hoverRaf = 0;
254
+ let lastHoverClientX = 0;
255
+ let lastHoverClientY = 0;
256
+ const flushHover = () => {
257
+ hoverRaf = 0;
258
+ if (ui.getState().tool === "terrain" || ui.getState().placement !== null) {
259
+ if (ui.getState().hoverId !== null)
260
+ ui.patch({ hoverId: null });
261
+ return;
262
+ }
263
+ const rect = canvas.getBoundingClientRect();
264
+ const clickX = lastHoverClientX - rect.left;
265
+ const clickY = lastHoverClientY - rect.top;
266
+ if (clickX < 0 || clickY < 0 || clickX > rect.width || clickY > rect.height) {
267
+ if (ui.getState().hoverId !== null)
268
+ ui.patch({ hoverId: null });
269
+ return;
270
+ }
271
+ const id = pickDocumentId(clickX, clickY, rect) ?? pickTaggedId(clickX, clickY, rect);
272
+ if (ui.getState().hoverId !== id)
273
+ ui.patch({ hoverId: id });
274
+ };
275
+ const onMove = (event) => {
276
+ lastHoverClientX = event.clientX;
277
+ lastHoverClientY = event.clientY;
278
+ if (hoverRaf !== 0)
279
+ return;
280
+ hoverRaf = requestAnimationFrame(flushHover);
281
+ };
282
+ const onLeave = () => {
283
+ if (hoverRaf !== 0) {
284
+ cancelAnimationFrame(hoverRaf);
285
+ hoverRaf = 0;
286
+ }
287
+ if (ui.getState().hoverId !== null)
288
+ ui.patch({ hoverId: null });
289
+ };
214
290
  const onDown = (event) => {
215
291
  if (ui.getState().tool === "terrain")
216
292
  return;
@@ -317,16 +393,22 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
317
393
  };
318
394
  canvas.addEventListener("pointerdown", onDown);
319
395
  canvas.addEventListener("pointerup", onUp);
396
+ canvas.addEventListener("pointermove", onMove);
397
+ canvas.addEventListener("pointerleave", onLeave);
320
398
  canvas.addEventListener("contextmenu", onContextMenu);
321
399
  return () => {
400
+ if (hoverRaf !== 0)
401
+ cancelAnimationFrame(hoverRaf);
322
402
  canvas.removeEventListener("pointerdown", onDown);
323
403
  canvas.removeEventListener("pointerup", onUp);
404
+ canvas.removeEventListener("pointermove", onMove);
405
+ canvas.removeEventListener("pointerleave", onLeave);
324
406
  canvas.removeEventListener("contextmenu", onContextMenu);
325
407
  };
326
408
  }, [gl, camera, scene, api, ui]);
327
409
  return null;
328
410
  });
329
- function resolveTarget(session, selectedId, pathPoint) {
411
+ function resolveTarget(session, selection, pathPoint, pivot) {
330
412
  const { document } = session.getState();
331
413
  if (pathPoint !== null) {
332
414
  const path = findEditorPath(document, pathPoint.pathId);
@@ -334,23 +416,25 @@ function resolveTarget(session, selectedId, pathPoint) {
334
416
  if (point !== undefined)
335
417
  return { position: point, lift: 0.8, rotationY: 0, kind: "pathPoint" };
336
418
  }
337
- if (selectedId === undefined)
419
+ if (selection.length === 0)
338
420
  return null;
339
- const marker = findEditorMarker(document, selectedId);
340
- if (marker !== undefined) {
341
- return { position: marker.position, lift: MARKER_LIFT, rotationY: marker.rotationY ?? 0, kind: "marker" };
342
- }
343
- const volume = findEditorVolume(document, selectedId);
344
- if (volume !== undefined)
345
- return { position: volume.center, lift: 0, rotationY: 0, kind: "volume" };
346
- const path = findEditorPath(document, selectedId);
347
- if (path !== undefined && path.points.length > 0) {
348
- return { position: pathCentroid(path.points), lift: 0.8, rotationY: 0, kind: "path" };
421
+ const primaryId = selection[0];
422
+ const primary = objectPosition(session, primaryId);
423
+ if (primary === null)
424
+ return null;
425
+ if (selection.length === 1)
426
+ return primary;
427
+ const positions = [];
428
+ for (const id of selection) {
429
+ const resolved = objectPosition(session, id);
430
+ if (resolved !== null)
431
+ positions.push(resolved.position);
349
432
  }
350
- const note = findEditorNote(document, selectedId);
351
- if (note !== undefined)
352
- return { position: note.position, lift: NOTE_LIFT, rotationY: 0, kind: "note" };
353
- return null;
433
+ const pivotPos = resolvePivotPosition(positions, pivot, primary.position);
434
+ if (pivotPos === null)
435
+ return primary;
436
+ // Multi-select always translates as a group from the pivot; rotation/scale stay primary-kind limited.
437
+ return { position: pivotPos, lift: primary.lift, rotationY: primary.rotationY, kind: primary.kind };
354
438
  }
355
439
  function effectiveMode(mode, kind) {
356
440
  if (kind === "marker")
@@ -368,7 +452,7 @@ export const SelectionGizmo = memo(function SelectionGizmo({ session, ui, ground
368
452
  const uiState = useGizmoUiState(ui);
369
453
  const state = session.getState();
370
454
  const selectedId = state.selection[0];
371
- const target = useMemo(() => resolveTarget(session, selectedId, uiState.pathPoint), [state, selectedId, uiState.pathPoint, session]);
455
+ const target = useMemo(() => resolveTarget(session, state.selection, uiState.pathPoint, uiState.gizmoPivot), [state, uiState.pathPoint, uiState.gizmoPivot, session]);
372
456
  const mode = target === null ? "translate" : effectiveMode(uiState.gizmoMode, target.kind);
373
457
  if (target === null)
374
458
  return null;
@@ -451,5 +535,5 @@ export const SelectionGizmo = memo(function SelectionGizmo({ session, ui, ground
451
535
  session.dispatch({ type: "setTransform", id: selectedId, position: dropped });
452
536
  };
453
537
  const snapMode = uiState.snapMode === "grid" ? "grid" : uiState.snapMode === "ground" ? "ground" : "free";
454
- return (_jsx(TransformGizmo, { position: target.position, rotationY: target.rotationY, mode: mode, snapMode: snapMode, gridSize: uiState.gridSize, lift: target.lift, groundSnap: mode === "translate" && target.kind !== "path" ? groundSnap : undefined, onRelease: onRelease }));
538
+ return (_jsx(TransformGizmo, { position: target.position, rotationY: target.rotationY, mode: mode, snapMode: snapMode, gridSize: uiState.gridSize, space: uiState.gizmoSpace, rotationSnap: uiState.rotationSnapDeg === null ? null : (uiState.rotationSnapDeg * Math.PI) / 180, scaleSnap: uiState.scaleSnap, lift: target.lift, groundSnap: mode === "translate" && target.kind !== "path" ? groundSnap : undefined, onRelease: onRelease }));
455
539
  });
@@ -1,21 +1,23 @@
1
1
  import type { EditorLayersInput } from "@jgengine/core/editor/index";
2
2
  import { type EnvironmentWorldFeature } from "@jgengine/core/world/features";
3
3
  import type { PlayableGame } from "@jgengine/shell/registry";
4
+ import { type AssetImporter, type StandaloneAsset } from "./assetImport.js";
4
5
  import { type EditorSaveFn } from "./EditorApp.js";
5
- /** One user-supplied model the standalone editor can place: a stable id and a resolvable URL. */
6
- export interface StandaloneAsset {
7
- id: string;
8
- url: string;
9
- label?: string;
10
- }
6
+ export type { AssetImporter, StandaloneAsset } from "./assetImport.js";
7
+ export { importAssetToHost, loadDroppedAssets, mergeStandaloneAssets } from "./assetImport.js";
11
8
  /** Options for the blank, gameless world the standalone editor authors over. */
12
9
  export interface BlankPlayableOptions {
13
10
  name?: string;
14
11
  assets?: readonly StandaloneAsset[];
15
12
  world?: EnvironmentWorldFeature;
16
13
  }
17
- /** The default flat-ground world the standalone editor opens on when the host supplies none. */
18
- export declare function blankWorld(seed?: string): EnvironmentWorldFeature;
14
+ /**
15
+ * The default flat-ground authoring canvas the standalone editor opens on when the host supplies
16
+ * none. Deliberately thin — bare sculptable ground under a clear default sky. Dressing (sky look,
17
+ * foliage scatter, props) is what the author adds here, written into the scene document; it is
18
+ * never pre-baked into the canvas. The `seed` parameter is legacy and unused.
19
+ */
20
+ export declare function blankWorld(_seed?: string): EnvironmentWorldFeature;
19
21
  /** Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over. */
20
22
  export declare function createBlankPlayable(options?: BlankPlayableOptions): PlayableGame;
21
23
  /** A save fn that hands the scene JSON back to the browser as a downloaded file — the exit path when no dev server is listening. */
@@ -32,6 +34,11 @@ export interface StandaloneEditorProps {
32
34
  world?: EnvironmentWorldFeature;
33
35
  /** Where Save writes. Default: the installed dev-server endpoint, else a browser download. */
34
36
  save?: EditorSaveFn;
37
+ /**
38
+ * How a dropped model is made durable. Default: upload to the `@jgengine/node` editor host plugin so it
39
+ * persists to disk and survives reload; when no host answers the import degrades to an ephemeral blob URL.
40
+ */
41
+ importAsset?: AssetImporter;
35
42
  /** Hide the built-in open-scene / add-assets strip (host supplies its own loading UI). */
36
43
  hidePickers?: boolean;
37
44
  }
@@ -41,4 +48,4 @@ export interface StandaloneEditorProps {
41
48
  * desktop app, or any React host). Ships a slim strip to open a world file and pull in an asset
42
49
  * folder; both are also settable up front through props.
43
50
  */
44
- export declare function StandaloneEditor({ sceneId, scene, assets, world, save, hidePickers, }: StandaloneEditorProps): import("react").JSX.Element;
51
+ export declare function StandaloneEditor({ sceneId, scene, assets, world, save, importAsset, hidePickers, }: StandaloneEditorProps): import("react").JSX.Element;
@@ -2,15 +2,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useRef, useState } from "react";
3
3
  import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
4
4
  import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
5
- import { environment, grass, sky, terrain, } from "@jgengine/core/world/features";
5
+ import { environment, sky, terrain, } from "@jgengine/core/world/features";
6
6
  import { defineGame } from "@jgengine/shell/defineGame";
7
+ import { importAssetToHost, loadDroppedAssets, mergeStandaloneAssets, } from "./assetImport.js";
7
8
  import { EditorApp } from "./EditorApp.js";
8
- /** The default flat-ground world the standalone editor opens on when the host supplies none. */
9
- export function blankWorld(seed = "standalone") {
9
+ export { importAssetToHost, loadDroppedAssets, mergeStandaloneAssets } from "./assetImport.js";
10
+ /**
11
+ * The default flat-ground authoring canvas the standalone editor opens on when the host supplies
12
+ * none. Deliberately thin — bare sculptable ground under a clear default sky. Dressing (sky look,
13
+ * foliage scatter, props) is what the author adds here, written into the scene document; it is
14
+ * never pre-baked into the canvas. The `seed` parameter is legacy and unused.
15
+ */
16
+ export function blankWorld(_seed = "standalone") {
10
17
  return environment({
11
18
  terrain: terrain({ bounds: { w: 128, d: 128 }, height: 0, material: "grass" }),
12
- sky: sky({ preset: "day" }),
13
- vegetation: grass({ area: { w: 100, d: 100 }, density: 1.5, colors: ["#3f7d2d", "#6bbf4a"], seed }),
19
+ // Fog pushed past the world edge: the engine's default (near 70 / far 260) soaks most of a
20
+ // 128 m authoring canvas in haze — the editor should open on a clear world.
21
+ sky: sky({ preset: "day", fog: { near: 220, far: 700 } }),
14
22
  });
15
23
  }
16
24
  /** Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over. */
@@ -44,17 +52,13 @@ export function downloadSaver(filename = "editor.scene.json") {
44
52
  }
45
53
  };
46
54
  }
47
- const MODEL_FILE = /\.(glb|gltf)$/i;
48
- function assetId(name) {
49
- return name.replace(MODEL_FILE, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "asset";
50
- }
51
55
  /**
52
56
  * The scene editor, mounted over a blank gameless world instead of a game — the same `EditorApp`
53
57
  * every jgengine game ships, usable standalone on the user's own project (CLI `jgengine editor`,
54
58
  * desktop app, or any React host). Ships a slim strip to open a world file and pull in an asset
55
59
  * folder; both are also settable up front through props.
56
60
  */
57
- export function StandaloneEditor({ sceneId = "standalone", scene, assets, world, save, hidePickers = false, }) {
61
+ export function StandaloneEditor({ sceneId = "standalone", scene, assets, world, save, importAsset = importAssetToHost, hidePickers = false, }) {
58
62
  const [loadedScene, setLoadedScene] = useState(scene);
59
63
  const [loadedAssets, setLoadedAssets] = useState(assets ?? []);
60
64
  const [rev, setRev] = useState(0);
@@ -67,18 +71,13 @@ export function StandaloneEditor({ sceneId = "standalone", scene, assets, world,
67
71
  setLoadedScene(JSON.parse(text));
68
72
  setRev((value) => value + 1);
69
73
  }, []);
70
- const addAssets = useCallback((files) => {
71
- const next = [];
72
- for (const file of Array.from(files)) {
73
- if (!MODEL_FILE.test(file.name))
74
- continue;
75
- next.push({ id: assetId(file.name), url: URL.createObjectURL(file), label: file.name });
76
- }
74
+ const addAssets = useCallback(async (files) => {
75
+ const next = await loadDroppedAssets(Array.from(files), importAsset);
77
76
  if (next.length === 0)
78
77
  return;
79
- setLoadedAssets((current) => [...current, ...next]);
78
+ setLoadedAssets((current) => mergeStandaloneAssets(current, next));
80
79
  setRev((value) => value + 1);
81
- }, []);
80
+ }, [importAsset]);
82
81
  return (_jsxs("div", { className: "relative h-full w-full", children: [_jsx(EditorApp, { gameId: sceneId, playable: playable, layers: loadedScene, save: saveFn }, rev), hidePickers ? null : (_jsxs("div", { className: "pointer-events-none absolute bottom-3 left-1/2 z-[70] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/70 px-2 py-1.5 text-[11px] text-neutral-200 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("button", { type: "button", className: "pointer-events-auto rounded-full px-3 py-1 transition-colors hover:bg-white/10", onClick: () => sceneInput.current?.click(), children: "\uD83D\uDCC2 Open scene" }), _jsx("span", { className: "h-4 w-px bg-white/15" }), _jsx("button", { type: "button", className: "pointer-events-auto rounded-full px-3 py-1 transition-colors hover:bg-white/10", onClick: () => assetInput.current?.click(), children: "\uD83E\uDDCA Add assets" }), loadedAssets.length > 0 ? (_jsx("span", { className: "rounded-full bg-white/10 px-2 py-0.5 text-[10px] text-neutral-300", children: loadedAssets.length })) : null, _jsx("input", { ref: sceneInput, type: "file", accept: ".json,application/json", className: "hidden", onChange: (event) => {
83
82
  const file = event.target.files?.[0];
84
83
  if (file !== undefined)
@@ -86,7 +85,7 @@ export function StandaloneEditor({ sceneId = "standalone", scene, assets, world,
86
85
  event.target.value = "";
87
86
  } }), _jsx("input", { ref: assetInput, type: "file", accept: ".glb,.gltf,model/gltf-binary", multiple: true, className: "hidden", onChange: (event) => {
88
87
  if (event.target.files !== null)
89
- addAssets(event.target.files);
88
+ void addAssets(event.target.files);
90
89
  event.target.value = "";
91
90
  } })] }))] }));
92
91
  }
@@ -1,7 +1,8 @@
1
- import type { EditorSession } from "./session.js";
1
+ import type { EditorHostApi, EditorSession } from "./session.js";
2
2
  import { type EditorUiStore } from "./uiStore.js";
3
3
  /** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
4
- export declare function TerrainPanel({ session, ui }: {
4
+ export declare function TerrainPanel({ session, ui, api }: {
5
5
  session: EditorSession;
6
6
  ui: EditorUiStore;
7
+ api: EditorHostApi;
7
8
  }): import("react").JSX.Element;
@@ -3,8 +3,11 @@ import { useEffect, useState } from "react";
3
3
  import { editorDocumentBounds } from "@jgengine/core/editor/index";
4
4
  import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
5
5
  import { TERRAIN_MATERIALS } from "./uiStore.js";
6
- import { INPUT } from "./chromeStyles.js";
7
6
  import { SliderRow } from "./chromeFields.js";
7
+ import { BORDER, CONTROL, CONTROL_ACTIVE, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
8
+ const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
9
+ const BTN_SM = `${CONTROL} px-2 py-0.5 text-[11px]`;
10
+ const BTN_ACTIVE = `${CONTROL_ACTIVE} rounded-[5px] px-1.5 py-1 text-[11px] ${FOCUS_RING}`;
8
11
  const TERRAIN_BRUSHES = [
9
12
  { kind: "raise", label: "Raise", hint: "Push terrain up" },
10
13
  { kind: "lower", label: "Lower", hint: "Dig terrain down" },
@@ -30,7 +33,7 @@ function defaultTerrainBounds(document) {
30
33
  }
31
34
  function SculptControls({ ui }) {
32
35
  const sculpt = ui.getState().sculpt;
33
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-3 gap-1", children: TERRAIN_BRUSHES.map((brush) => (_jsx("button", { type: "button", title: brush.hint, className: `rounded-md px-1.5 py-1 text-[11px] transition-colors ${sculpt.brush === brush.kind ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "bg-white/[0.04] text-neutral-300 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => ui.patchSculpt({ brush: brush.kind }), children: brush.label }, brush.kind))) }), sculpt.brush === "ramp" ? _jsx("div", { className: "text-[10px] text-neutral-500", children: "Drag from the low end to the high end to grade a slope." }) : null, _jsx(SliderRow, { label: "radius", value: sculpt.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchSculpt({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsx(SliderRow, { label: "strength", value: sculpt.strength, min: 0.05, max: 5, step: 0.05, onChange: (value) => ui.patchSculpt({ strength: value }) }), _jsx(SliderRow, { label: "spacing", value: sculpt.spacing, min: 0.25, max: 12, step: 0.25, onChange: (value) => ui.patchSculpt({ spacing: value }), format: (v) => `${v.toFixed(2)}m` }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "falloff" }), _jsx("div", { className: "ml-auto flex gap-0.5 rounded-md bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["smooth", "linear", "none"].map((mode) => (_jsx("button", { type: "button", className: `rounded px-1.5 py-0.5 text-[10px] capitalize transition-colors ${sculpt.falloff === mode ? "bg-amber-500/80 text-white" : "text-neutral-400 hover:text-neutral-200"}`, onClick: () => ui.patchSculpt({ falloff: mode }), children: mode }, mode))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "shape" }), _jsx("button", { type: "button", className: "ml-auto rounded-md bg-white/[0.04] px-2 py-0.5 text-[11px] capitalize ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => ui.patchSculpt({ shape: sculpt.shape === "circle" ? "square" : "circle" }), children: sculpt.shape }), _jsxs("label", { className: "flex items-center gap-1 text-[10px] text-neutral-400", children: [_jsx("input", { type: "checkbox", className: "accent-amber-400", checked: sculpt.invert, onChange: (event) => ui.patchSculpt({ invert: event.target.checked }) }), "invert"] })] }), sculpt.brush === "flatten" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "flatten to" }), _jsx("input", { type: "number", step: 0.5, placeholder: "sample", className: `w-24 ${INPUT}`, value: sculpt.flattenHeight ?? "", onChange: (event) => ui.patchSculpt({ flattenHeight: event.target.value === "" ? null : Number(event.target.value) }) })] })) : null, sculpt.brush === "noise" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "seed" }), _jsx("input", { type: "number", step: 1, className: `w-24 ${INPUT}`, value: sculpt.noiseSeed, onChange: (event) => ui.patchSculpt({ noiseSeed: Math.round(Number(event.target.value)) || 0 }) })] })) : null] }));
36
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-3 gap-1", children: TERRAIN_BRUSHES.map((brush) => (_jsx("button", { type: "button", title: brush.hint, className: sculpt.brush === brush.kind ? BTN_ACTIVE : `${BTN} px-1.5`, onClick: () => ui.patchSculpt({ brush: brush.kind }), children: brush.label }, brush.kind))) }), sculpt.brush === "ramp" ? _jsx("div", { className: "text-[10px] text-neutral-500", children: "Drag from the low end to the high end to grade a slope." }) : null, _jsx(SliderRow, { label: "radius", value: sculpt.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchSculpt({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsx(SliderRow, { label: "strength", value: sculpt.strength, min: 0.05, max: 5, step: 0.05, onChange: (value) => ui.patchSculpt({ strength: value }) }), _jsx(SliderRow, { label: "spacing", value: sculpt.spacing, min: 0.25, max: 12, step: 0.25, onChange: (value) => ui.patchSculpt({ spacing: value }), format: (v) => `${v.toFixed(2)}m` }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: MICRO_LABEL, children: "falloff" }), _jsx("div", { className: `ml-auto flex gap-0.5 rounded-[5px] border ${BORDER} bg-black/40 p-0.5`, children: ["smooth", "linear", "none"].map((mode) => (_jsx("button", { type: "button", className: `rounded-[4px] px-1.5 py-0.5 text-[10px] capitalize transition-colors ${FOCUS_RING} ${sculpt.falloff === mode ? CONTROL_ACTIVE : "text-neutral-400 hover:text-neutral-200"}`, onClick: () => ui.patchSculpt({ falloff: mode }), children: mode }, mode))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "shape" }), _jsx("button", { type: "button", className: `ml-auto capitalize ${BTN_SM}`, onClick: () => ui.patchSculpt({ shape: sculpt.shape === "circle" ? "square" : "circle" }), children: sculpt.shape }), _jsxs("label", { className: "flex items-center gap-1 text-[10px] text-neutral-400", children: [_jsx("input", { type: "checkbox", className: "accent-amber-400", checked: sculpt.invert, onChange: (event) => ui.patchSculpt({ invert: event.target.checked }) }), "invert"] })] }), sculpt.brush === "flatten" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "flatten to" }), _jsx("input", { type: "number", step: 0.5, placeholder: "sample", className: `h-7 w-24 px-1.5 ${INPUT_CLS}`, value: sculpt.flattenHeight ?? "", onChange: (event) => ui.patchSculpt({ flattenHeight: event.target.value === "" ? null : Number(event.target.value) }) })] })) : null, sculpt.brush === "noise" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "seed" }), _jsx("input", { type: "number", step: 1, className: `h-7 w-24 px-1.5 ${INPUT_CLS}`, value: sculpt.noiseSeed, onChange: (event) => ui.patchSculpt({ noiseSeed: Math.round(Number(event.target.value)) || 0 }) })] })) : null] }));
34
37
  }
35
38
  function PaintControls({ session, ui }) {
36
39
  const paint = ui.getState().paint;
@@ -42,11 +45,12 @@ function PaintControls({ session, ui }) {
42
45
  if (delta.indices.length > 0)
43
46
  session.dispatch({ type: "paintTerrain", delta });
44
47
  };
45
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-2 gap-1", children: TERRAIN_MATERIALS.map((material) => (_jsxs("button", { type: "button", className: `flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] transition-colors ${paint.material === material.id ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "bg-white/[0.04] text-neutral-300 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => ui.patchPaint({ material: material.id }), children: [_jsx("span", { className: "h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/40", style: { backgroundColor: material.color } }), material.label] }, material.id))) }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Click/drag to paint \u00B7 Alt-click to sample." }), _jsx(SliderRow, { label: "radius", value: paint.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchPaint({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "shape" }), _jsx("button", { type: "button", className: "ml-auto rounded-md bg-white/[0.04] px-2 py-0.5 text-[11px] capitalize ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => ui.patchPaint({ shape: paint.shape === "circle" ? "square" : "circle" }), children: paint.shape })] }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(paint.material)), children: "Fill all" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(null)), children: "Clear paint" })] }), _jsxs("div", { className: "space-y-1 rounded-lg border border-white/[0.06] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Auto rules" }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[10px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", title: "Paint the selected material onto slopes steeper than ~30\u00B0", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minSlope: 0.6 })), children: "On steep slopes" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[10px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", title: "Paint the selected material above height 15", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minHeight: 15 })), children: "On high ground" })] })] })] }));
48
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-2 gap-1", children: TERRAIN_MATERIALS.map((material) => (_jsxs("button", { type: "button", className: `flex items-center gap-1.5 ${paint.material === material.id ? BTN_ACTIVE : `${BTN} px-1.5`}`, onClick: () => ui.patchPaint({ material: material.id }), children: [_jsx("span", { className: "h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/40", style: { backgroundColor: material.color } }), material.label] }, material.id))) }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Click/drag to paint \u00B7 Alt-click to sample." }), _jsx(SliderRow, { label: "radius", value: paint.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchPaint({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "shape" }), _jsx("button", { type: "button", className: `ml-auto capitalize ${BTN_SM}`, onClick: () => ui.patchPaint({ shape: paint.shape === "circle" ? "square" : "circle" }), children: paint.shape })] }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: BTN, onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(paint.material)), children: "Fill all" }), _jsx("button", { type: "button", className: BTN, onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(null)), children: "Clear paint" })] }), _jsxs("div", { className: `space-y-1 rounded-[6px] border ${BORDER} p-2`, children: [_jsx("div", { className: MICRO_LABEL, children: "Auto rules" }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: `${BTN} text-[10px]`, title: "Paint the selected material onto slopes steeper than ~30\u00B0", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minSlope: 0.6 })), children: "On steep slopes" }), _jsx("button", { type: "button", className: `${BTN} text-[10px]`, title: "Paint the selected material above height 15", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minHeight: 15 })), children: "On high ground" })] })] })] }));
46
49
  }
47
50
  /** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
48
- export function TerrainPanel({ session, ui }) {
51
+ export function TerrainPanel({ session, ui, api }) {
49
52
  const [, setTick] = useState(0);
53
+ const [bakeStatus, setBakeStatus] = useState(null);
50
54
  useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
51
55
  useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
52
56
  const uiState = ui.getState();
@@ -55,5 +59,12 @@ export function TerrainPanel({ session, ui }) {
55
59
  const createTerrain = () => {
56
60
  session.dispatch({ type: "setTerrain", terrain: createTerrainSnapshot({ bounds: defaultTerrainBounds(document), cellSize: 2 }) });
57
61
  };
58
- return (_jsxs("div", { className: "pointer-events-auto absolute right-3 top-3 z-40 max-h-[calc(100%-1.5rem)] w-64 space-y-2.5 overflow-auto rounded-xl border border-amber-400/20 bg-[#0d0f13]/95 p-3 shadow-2xl shadow-black/60 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em] text-amber-300", children: "Terrain" }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-0.5 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => ui.setTool("select"), title: "Back to select tool", children: "\u00D7" })] }), !hasTerrain ? (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-[11px] leading-relaxed text-neutral-400", children: "No terrain yet. Create an editable heightfield over the scene, then sculpt its shape and paint material layers on it." }), _jsx("button", { type: "button", className: "w-full rounded-md bg-gradient-to-b from-amber-500 to-amber-600 px-2.5 py-1.5 font-semibold text-white shadow-md shadow-amber-950/50 transition-colors hover:from-amber-400 hover:to-amber-500", onClick: createTerrain, children: "Create terrain" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["sculpt", "paint"].map((mode) => (_jsx("button", { type: "button", className: `flex-1 rounded-md px-2 py-1 text-[11px] capitalize transition-colors ${uiState.terrainMode === mode ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.setTerrainMode(mode), children: mode }, mode))) }), uiState.terrainMode === "paint" ? _jsx(PaintControls, { session: session, ui: ui }) : _jsx(SculptControls, { ui: ui }), _jsxs("div", { className: "flex items-center justify-between gap-2 border-t border-white/[0.06] pt-2", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 disabled:opacity-40", onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: "rounded-md bg-rose-500/15 px-2 py-1 text-[11px] text-rose-200 ring-1 ring-inset ring-rose-400/25 transition-colors hover:bg-rose-500/25", onClick: () => session.dispatch({ type: "clearTerrain" }), children: "Clear terrain" })] })] }))] }));
62
+ const bakeMinimap = () => {
63
+ const response = api.handle({ method: "bake_minimap" });
64
+ if (response.ok)
65
+ setBakeStatus({ tone: "ok", text: "Minimap baked onto the scene." });
66
+ else
67
+ setBakeStatus({ tone: "error", text: response.error ?? "Bake failed." });
68
+ };
69
+ return (_jsxs("div", { className: `pointer-events-auto absolute right-3 top-3 z-40 max-h-[calc(100%-1.5rem)] w-64 space-y-2.5 overflow-auto rounded-xl border ${BORDER} bg-[#111318]/96 p-3 shadow-2xl shadow-black/60 backdrop-blur-md`, children: [_jsxs("div", { className: "flex items-center", children: [_jsx("div", { className: MICRO_LABEL, children: "Terrain" }), _jsx("button", { type: "button", className: `ml-auto rounded-[5px] px-2 py-0.5 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200 ${FOCUS_RING}`, onClick: () => ui.setTool("select"), title: "Back to select tool", children: "\u00D7" })] }), _jsxs("div", { className: `space-y-1 border-b ${BORDER} pb-2`, children: [_jsx("button", { type: "button", className: `w-full ${BTN}`, onClick: bakeMinimap, title: "Rasterize the authored terrain into a stored minimap PNG the game reads at runtime", children: "Bake minimap" }), bakeStatus !== null ? (_jsx("div", { className: `text-[10px] leading-snug ${bakeStatus.tone === "error" ? "text-rose-300" : "text-emerald-300"}`, children: bakeStatus.text })) : null] }), !hasTerrain ? (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-[11px] leading-relaxed text-neutral-400", children: "No terrain yet. Create an editable heightfield over the scene, then sculpt its shape and paint material layers on it." }), _jsx("button", { type: "button", className: `w-full ${CONTROL_ACTIVE} rounded-[5px] px-2.5 py-1.5 text-[12px] font-semibold ${FOCUS_RING}`, onClick: createTerrain, children: "Create terrain" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: `flex gap-0.5 rounded-[6px] border ${BORDER} bg-black/40 p-0.5`, children: ["sculpt", "paint"].map((mode) => (_jsx("button", { type: "button", className: `flex-1 rounded-[5px] px-2 py-1 text-[11px] capitalize transition-colors ${FOCUS_RING} ${uiState.terrainMode === mode ? CONTROL_ACTIVE : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.setTerrainMode(mode), children: mode }, mode))) }), uiState.terrainMode === "paint" ? _jsx(PaintControls, { session: session, ui: ui }) : _jsx(SculptControls, { ui: ui }), _jsxs("div", { className: `flex items-center justify-between gap-2 border-t ${BORDER} pt-2`, children: [_jsx("button", { type: "button", className: BTN, onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: `rounded-[5px] border border-rose-400/25 bg-rose-500/15 px-2 py-1 text-[11px] text-rose-200 transition-colors hover:bg-rose-500/25 ${FOCUS_RING}`, onClick: () => session.dispatch({ type: "clearTerrain" }), children: "Clear terrain" })] })] }))] }));
59
70
  }