@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
@@ -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
  }
@@ -0,0 +1,17 @@
1
+ import { type GuideRegion } from "@jgengine/core/world/terrainGuides";
2
+ import type { TerrainReadoutStore } from "./terrainReadoutStore.js";
3
+ /**
4
+ * Surface-following terrain-readability overlay: draws iso-elevation contour lines and an optional
5
+ * terrain-draped reference grid from the live ground field, and tracks the cursor's elevation. All
6
+ * geometry comes from headless `@jgengine/core/world/terrainGuides` math; this component only turns it
7
+ * into three.js lines and publishes the measured feedback to the readout store.
8
+ */
9
+ export declare function TerrainReadout({ groundHeightAt, region, showContours, showSurfaceGrid, version, readout, }: {
10
+ groundHeightAt: (x: number, z: number) => number;
11
+ region: GuideRegion;
12
+ showContours: boolean;
13
+ showSurfaceGrid: boolean;
14
+ /** Bumped by the host when the terrain changes, so the overlay rebuilds off the fresh field. */
15
+ version: number;
16
+ readout: TerrainReadoutStore;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,163 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import * as THREE from "three";
5
+ import { extractContours, sampleElevation, summarizeElevation, surfaceGridLines, surfaceRing, chooseContourInterval, } from "@jgengine/core/world/terrainGuides";
6
+ /** How often (seconds) the cursor readout re-samples — a bounded per-frame cost, not every frame. */
7
+ const CURSOR_SAMPLE_INTERVAL = 1 / 20;
8
+ /** Cap on marching-squares samples per axis — the overlay's explicit performance budget. */
9
+ const READOUT_MAX_SAMPLES = 200;
10
+ /** Small lift keeping guide lines off the surface so they never z-fight the terrain mesh. */
11
+ const DEPTH_BIAS = 0.08;
12
+ const MAJOR_CONTOUR_COLOR = "#f2c14e";
13
+ const MINOR_CONTOUR_COLOR = "#8aa0b8";
14
+ const MAJOR_GRID_COLOR = "#5b6b7d";
15
+ const MINOR_GRID_COLOR = "#39434f";
16
+ const CURSOR_RING_COLOR = "#ffd479";
17
+ /** Expands a flat `[x,y,z,...]` polyline into consecutive `LineSegments` vertex pairs. */
18
+ function polylineToSegments(points, into) {
19
+ const vertexCount = Math.floor(points.length / 3);
20
+ for (let i = 0; i < vertexCount - 1; i += 1) {
21
+ const a = i * 3;
22
+ const b = (i + 1) * 3;
23
+ into.push(points[a], points[a + 1], points[a + 2]);
24
+ into.push(points[b], points[b + 1], points[b + 2]);
25
+ }
26
+ }
27
+ function lineSegments(positions, color, opacity) {
28
+ if (positions.length === 0)
29
+ return null;
30
+ const geometry = new THREE.BufferGeometry();
31
+ geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(positions), 3));
32
+ const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthTest: true });
33
+ return new THREE.LineSegments(geometry, material);
34
+ }
35
+ /**
36
+ * Surface-following terrain-readability overlay: draws iso-elevation contour lines and an optional
37
+ * terrain-draped reference grid from the live ground field, and tracks the cursor's elevation. All
38
+ * geometry comes from headless `@jgengine/core/world/terrainGuides` math; this component only turns it
39
+ * into three.js lines and publishes the measured feedback to the readout store.
40
+ */
41
+ export function TerrainReadout({ groundHeightAt, region, showContours, showSurfaceGrid, version, readout, }) {
42
+ const regionKey = `${region.minX},${region.minZ},${region.maxX},${region.maxZ}`;
43
+ const guides = useMemo(() => {
44
+ const summary = summarizeElevation(groundHeightAt, region, 96, READOUT_MAX_SAMPLES);
45
+ const interval = chooseContourInterval(summary.range);
46
+ const objects = [];
47
+ if (showContours && interval > 0) {
48
+ const contours = extractContours(groundHeightAt, {
49
+ region,
50
+ interval,
51
+ resolution: 128,
52
+ maxSamples: READOUT_MAX_SAMPLES,
53
+ });
54
+ const major = [];
55
+ const minor = [];
56
+ for (const line of contours) {
57
+ const target = line.major ? major : minor;
58
+ for (let i = 0; i < line.segments.length; i += 2) {
59
+ target.push(line.segments[i], line.level + DEPTH_BIAS, line.segments[i + 1]);
60
+ }
61
+ }
62
+ const majorObj = lineSegments(major, MAJOR_CONTOUR_COLOR, 0.9);
63
+ const minorObj = lineSegments(minor, MINOR_CONTOUR_COLOR, 0.5);
64
+ if (majorObj !== null)
65
+ objects.push(majorObj);
66
+ if (minorObj !== null)
67
+ objects.push(minorObj);
68
+ }
69
+ if (showSurfaceGrid) {
70
+ const spacing = interval > 0 ? Math.max(region.maxX - region.minX, region.maxZ - region.minZ) / 16 : 8;
71
+ const lines = surfaceGridLines(groundHeightAt, {
72
+ region,
73
+ spacing,
74
+ drape: { spacing: Math.max(1, spacing / 8), offset: DEPTH_BIAS },
75
+ });
76
+ const major = [];
77
+ const minor = [];
78
+ for (const line of lines)
79
+ polylineToSegments(line.points, line.major ? major : minor);
80
+ const majorObj = lineSegments(major, MAJOR_GRID_COLOR, 0.7);
81
+ const minorObj = lineSegments(minor, MINOR_GRID_COLOR, 0.4);
82
+ if (majorObj !== null)
83
+ objects.push(majorObj);
84
+ if (minorObj !== null)
85
+ objects.push(minorObj);
86
+ }
87
+ return { summary, interval, objects };
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [groundHeightAt, regionKey, showContours, showSurfaceGrid, version]);
90
+ useEffect(() => {
91
+ readout.setSummary(guides.summary, guides.interval);
92
+ }, [guides, readout]);
93
+ useEffect(() => () => {
94
+ for (const object of guides.objects) {
95
+ if (object instanceof THREE.LineSegments) {
96
+ object.geometry.dispose();
97
+ object.material.dispose();
98
+ }
99
+ }
100
+ }, [guides]);
101
+ // Cursor ring — a surface-following placement guide draped under the pointer.
102
+ const ring = useMemo(() => {
103
+ const geometry = new THREE.BufferGeometry();
104
+ geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(48 * 2 * 3), 3));
105
+ const material = new THREE.LineBasicMaterial({ color: CURSOR_RING_COLOR, transparent: true, opacity: 0.85 });
106
+ const segments = new THREE.LineSegments(geometry, material);
107
+ segments.visible = false;
108
+ return segments;
109
+ }, []);
110
+ useEffect(() => () => {
111
+ ring.geometry.dispose();
112
+ ring.material.dispose();
113
+ }, [ring]);
114
+ const { camera, pointer, raycaster } = useThree();
115
+ const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
116
+ const hitRef = useRef(new THREE.Vector3());
117
+ const accum = useRef(0);
118
+ useEffect(() => {
119
+ planeRef.current.constant = -(guides.summary.mean ?? 0);
120
+ }, [guides.summary]);
121
+ useFrame((_, delta) => {
122
+ accum.current += delta;
123
+ if (accum.current < CURSOR_SAMPLE_INTERVAL)
124
+ return;
125
+ accum.current = 0;
126
+ raycaster.setFromCamera(pointer, camera);
127
+ const hit = raycaster.ray.intersectPlane(planeRef.current, hitRef.current);
128
+ if (hit === null ||
129
+ hit.x < region.minX ||
130
+ hit.x > region.maxX ||
131
+ hit.z < region.minZ ||
132
+ hit.z > region.maxZ) {
133
+ readout.setCursor(null);
134
+ if (ring.visible)
135
+ ring.visible = false;
136
+ return;
137
+ }
138
+ const elevation = sampleElevation(groundHeightAt, hit.x, hit.z);
139
+ readout.setCursor(elevation);
140
+ const radius = Math.max(1.5, (region.maxX - region.minX) / 48);
141
+ const ringPoints = surfaceRing(groundHeightAt, [hit.x, hit.z], radius, 48, { offset: DEPTH_BIAS });
142
+ const attribute = ring.geometry.getAttribute("position");
143
+ const array = attribute.array;
144
+ let w = 0;
145
+ const vertexCount = Math.floor(ringPoints.length / 3);
146
+ for (let i = 0; i < vertexCount - 1 && w + 6 <= array.length; i += 1) {
147
+ const a = i * 3;
148
+ const b = (i + 1) * 3;
149
+ array[w++] = ringPoints[a];
150
+ array[w++] = ringPoints[a + 1];
151
+ array[w++] = ringPoints[a + 2];
152
+ array[w++] = ringPoints[b];
153
+ array[w++] = ringPoints[b + 1];
154
+ array[w++] = ringPoints[b + 2];
155
+ }
156
+ while (w < array.length)
157
+ array[w++] = 0;
158
+ attribute.needsUpdate = true;
159
+ ring.geometry.setDrawRange(0, Math.max(0, (vertexCount - 1) * 2));
160
+ ring.visible = true;
161
+ });
162
+ return (_jsxs("group", { children: [guides.objects.map((object, index) => (_jsx("primitive", { object: object }, index))), _jsx("primitive", { object: ring })] }));
163
+ }