@jgengine/editor 0.11.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 (169) hide show
  1. package/CHANGELOG.md +26 -0
  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 +147 -35
  16. package/dist/EditorChrome.d.ts +25 -6
  17. package/dist/EditorChrome.js +310 -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 +23 -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/terrain.d.ts +3 -0
  69. package/dist/handlers/terrain.js +204 -0
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.js +3 -2
  72. package/dist/listDocumentTriggers.d.ts +42 -0
  73. package/dist/listDocumentTriggers.js +49 -0
  74. package/dist/materialAssignments.d.ts +63 -0
  75. package/dist/materialAssignments.js +84 -0
  76. package/dist/mcp/fieldSpec.d.ts +32 -0
  77. package/dist/mcp/fieldSpec.js +173 -0
  78. package/dist/mcp/rpcRequest.d.ts +8 -5
  79. package/dist/mcp/rpcRequest.js +72 -93
  80. package/dist/mcp/tools.js +104 -912
  81. package/dist/networkSnapshot.d.ts +82 -0
  82. package/dist/networkSnapshot.js +73 -0
  83. package/dist/parentCandidates.d.ts +12 -0
  84. package/dist/parentCandidates.js +25 -0
  85. package/dist/session.d.ts +77 -4
  86. package/dist/session.js +36 -1017
  87. package/dist/shell/AnimationPanel.d.ts +17 -0
  88. package/dist/shell/AnimationPanel.js +134 -0
  89. package/dist/shell/AssetThumbnail.d.ts +12 -0
  90. package/dist/shell/AssetThumbnail.js +23 -0
  91. package/dist/shell/BottomDock.d.ts +25 -0
  92. package/dist/shell/BottomDock.js +40 -0
  93. package/dist/shell/CommandPalette.d.ts +12 -0
  94. package/dist/shell/CommandPalette.js +56 -0
  95. package/dist/shell/ConsolePanel.d.ts +8 -0
  96. package/dist/shell/ConsolePanel.js +45 -0
  97. package/dist/shell/ContentBrowser.d.ts +21 -0
  98. package/dist/shell/ContentBrowser.js +130 -0
  99. package/dist/shell/HierarchyPanel.d.ts +26 -0
  100. package/dist/shell/HierarchyPanel.js +387 -0
  101. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  102. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  103. package/dist/shell/PlayModeBar.d.ts +12 -0
  104. package/dist/shell/PlayModeBar.js +27 -0
  105. package/dist/shell/ProfilerPanel.d.ts +10 -0
  106. package/dist/shell/ProfilerPanel.js +41 -0
  107. package/dist/shell/SceneToolbar.d.ts +43 -0
  108. package/dist/shell/SceneToolbar.js +122 -0
  109. package/dist/shell/StatusBar.d.ts +18 -0
  110. package/dist/shell/StatusBar.js +41 -0
  111. package/dist/shell/TopAppBar.d.ts +33 -0
  112. package/dist/shell/TopAppBar.js +64 -0
  113. package/dist/shell/ViewportOverlays.d.ts +23 -0
  114. package/dist/shell/ViewportOverlays.js +136 -0
  115. package/dist/shell/WorkspaceRail.d.ts +20 -0
  116. package/dist/shell/WorkspaceRail.js +31 -0
  117. package/dist/shell/cameraTelemetry.d.ts +27 -0
  118. package/dist/shell/cameraTelemetry.js +24 -0
  119. package/dist/shell/commandRegistry.d.ts +58 -0
  120. package/dist/shell/commandRegistry.js +133 -0
  121. package/dist/shell/consoleSink.d.ts +11 -0
  122. package/dist/shell/consoleSink.js +17 -0
  123. package/dist/shell/consoleStore.d.ts +27 -0
  124. package/dist/shell/consoleStore.js +43 -0
  125. package/dist/shell/fields.d.ts +39 -0
  126. package/dist/shell/fields.js +104 -0
  127. package/dist/shell/formatSavedRelative.d.ts +2 -0
  128. package/dist/shell/formatSavedRelative.js +14 -0
  129. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  130. package/dist/shell/glbThumbnailCache.js +241 -0
  131. package/dist/shell/hierarchyA11y.d.ts +21 -0
  132. package/dist/shell/hierarchyA11y.js +35 -0
  133. package/dist/shell/icons.d.ts +13 -0
  134. package/dist/shell/icons.js +85 -0
  135. package/dist/shell/layoutStore.d.ts +68 -0
  136. package/dist/shell/layoutStore.js +156 -0
  137. package/dist/shell/pathFlythrough.d.ts +37 -0
  138. package/dist/shell/pathFlythrough.js +54 -0
  139. package/dist/shell/perfHistory.d.ts +70 -0
  140. package/dist/shell/perfHistory.js +130 -0
  141. package/dist/shell/renameObject.d.ts +3 -0
  142. package/dist/shell/renameObject.js +25 -0
  143. package/dist/shell/theme.d.ts +36 -0
  144. package/dist/shell/theme.js +37 -0
  145. package/dist/shell/ui.d.ts +84 -0
  146. package/dist/shell/ui.js +90 -0
  147. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  148. package/dist/shell/useGlbThumbnail.js +19 -0
  149. package/dist/uiStore.d.ts +27 -0
  150. package/dist/uiStore.js +10 -0
  151. package/dist/viewportContextMenu.d.ts +4 -2
  152. package/dist/viewportContextMenu.js +5 -1
  153. package/package.json +4 -4
  154. package/skills/jgengine/SKILL.md +7 -4
  155. package/skills/jgengine/api.md +172 -79
  156. package/skills/jgengine/capabilities.md +104 -2
  157. package/skills/jgengine/recipes/minimal-game.md +88 -0
  158. package/skills/jgengine-editor/SKILL.md +18 -3
  159. package/skills/jgengine-editor/api.md +397 -18
  160. package/skills/jgengine-editor/capabilities.md +46 -0
  161. package/skills/jgengine-editor/reference.md +87 -0
  162. package/skills/level-design/SKILL.md +54 -0
  163. package/skills/level-design/agents/openai.yaml +4 -0
  164. package/skills/level-design/references/application-playbook.md +192 -0
  165. package/skills/level-design/references/field-guide.md +337 -0
  166. package/skills/level-design/references/live-editor-loop.md +179 -0
  167. package/dist/OutlinerPanel.d.ts +0 -14
  168. package/dist/OutlinerPanel.js +0 -84
  169. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -2,12 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useThree } from "@react-three/fiber";
3
3
  import { memo, useEffect, useMemo, useRef, useState } from "react";
4
4
  import * as THREE from "three";
5
- import { createTerrainPaletteSampler } from "@jgengine/core/world/terrain";
5
+ import { createTerrainPaletteSampler, raycastHeightField, } from "@jgengine/core/world/terrain";
6
6
  import { beginSurfaceStroke, beginTerraformStroke, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
7
7
  import { useGameContext } from "@jgengine/react/provider";
8
- import { normalizeHeightBlend, TerraformBrushCursor } from "@jgengine/shell/terrain";
8
+ import { displaceHeightfieldGeometry, normalizeHeightBlend, TerraformBrushCursor, } from "@jgengine/shell/terrain";
9
9
  import { editorPerfMarks } from "./perfMarks.js";
10
- import { TERRAIN_MATERIAL_COLORS } from "./uiStore.js";
10
+ import { TERRAIN_MATERIAL_COLORS, } from "./uiStore.js";
11
+ import { useStoreSelector } from "./useStoreSelector.js";
11
12
  const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
12
13
  const LOW = new THREE.Color("#3f5d34");
13
14
  const HIGH = new THREE.Color("#8f9c66");
@@ -64,9 +65,18 @@ function editFromSettings(settings, center, clickHeight) {
64
65
  ...(heightLimit === undefined ? {} : { heightLimit }),
65
66
  };
66
67
  }
67
- const PAINT_TINT = new THREE.Color();
68
68
  const BLEND_A = new THREE.Color();
69
69
  const BLEND_B = new THREE.Color();
70
+ /** Parsed once — `toneAt` runs per vertex, so hex→Color parsing must not. */
71
+ const SURFACE_COLOR_CACHE = new Map();
72
+ function surfaceColor(hex) {
73
+ let color = SURFACE_COLOR_CACHE.get(hex);
74
+ if (color === undefined) {
75
+ color = new THREE.Color(hex);
76
+ SURFACE_COLOR_CACHE.set(hex, color);
77
+ }
78
+ return color;
79
+ }
70
80
  /** World-unit spacing between topographic relief bands drawn on the sculpt preview. */
71
81
  const CONTOUR_INTERVAL = 1.5;
72
82
  /**
@@ -92,7 +102,7 @@ function applyRelief(out, height, strength) {
92
102
  * default — so `materialRegions`/base `colors`/`material` (worn paths, rock outcrops, biome tint)
93
103
  * show through in the editor the same way `TerrainGround` renders them at runtime.
94
104
  */
95
- function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
105
+ function toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors) {
96
106
  const layers = terrain.layers;
97
107
  if (layers.length > 0) {
98
108
  const weights = terrain.weightsAt(x, z);
@@ -105,10 +115,10 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
105
115
  const w = weights[l] * (layers[l].opacity ?? 1);
106
116
  if (w <= 0)
107
117
  continue;
108
- BLEND_B.set(layerColor(layers[l]));
109
- r += BLEND_B.r * w;
110
- g += BLEND_B.g * w;
111
- b += BLEND_B.b * w;
118
+ const color = layerColors[l];
119
+ r += color.r * w;
120
+ g += color.g * w;
121
+ b += color.b * w;
112
122
  total += w;
113
123
  }
114
124
  if (total > 0) {
@@ -120,7 +130,7 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
120
130
  const surface = terrain.surfaceAt(x, z);
121
131
  const painted = surface === null ? undefined : TERRAIN_MATERIAL_COLORS[surface];
122
132
  if (painted !== undefined) {
123
- out.copy(PAINT_TINT.set(painted));
133
+ out.copy(surfaceColor(painted));
124
134
  return;
125
135
  }
126
136
  if (paletteAt !== null) {
@@ -133,42 +143,35 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
133
143
  out.copy(BLEND_A.copy(LOW).lerp(HIGH, t));
134
144
  }
135
145
  /**
136
- * Re-samples vertex Y and vertex color from the live terrain (in place — no reallocation). When a
137
- * dirty `region` is given, only vertices inside it re-sample — the whole-mesh resample per stamp is
138
- * the sculpt hot path — while normals recompute across the mesh for seamless shading. Painted/blended
139
- * layers win the vertex color; unpainted ground falls back to the runtime terrain palette (or the
140
- * flat height gradient with no descriptor). Records rebuild time.
146
+ * Re-samples vertex Y, vertex color, and vertex normals from the live terrain via the shared
147
+ * {@link displaceHeightfieldGeometry} primitive. A dirty `region` keeps a brush stamp O(brush
148
+ * footprint) — no whole-mesh normal or bounding pass per stamp. Painted/blended layers win the
149
+ * vertex color; unpainted ground falls back to the runtime terrain palette (or the flat height
150
+ * gradient with no descriptor). Records rebuild time.
141
151
  */
142
152
  function displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength) {
143
153
  const started = performance.now();
144
- const position = geometry.attributes.position;
145
- const cx = (bounds.minX + bounds.maxX) / 2;
146
- const cz = (bounds.minZ + bounds.maxZ) / 2;
147
- let colors = geometry.attributes.color;
148
- const full = region === null || colors === undefined || colors.count !== position.count;
149
- if (colors === undefined || colors.count !== position.count) {
150
- colors = new THREE.BufferAttribute(new Float32Array(position.count * 3), 3);
151
- geometry.setAttribute("color", colors);
152
- }
153
- const tone = new THREE.Color();
154
- for (let index = 0; index < position.count; index += 1) {
155
- const x = position.getX(index) + cx;
156
- const z = position.getZ(index) + cz;
157
- if (!full && (x < region.minX || x > region.maxX || z < region.minZ || z > region.maxZ))
158
- continue;
159
- const height = terrain.sampleHeight(x, z);
160
- position.setY(index, height);
161
- toneAt(terrain, x, z, height, tone, paletteAt, heightRange);
162
- applyRelief(tone, height, contourStrength);
163
- colors.setXYZ(index, tone.r, tone.g, tone.b);
164
- }
165
- position.needsUpdate = true;
166
- colors.needsUpdate = true;
167
- geometry.computeVertexNormals();
168
- geometry.computeBoundingSphere();
154
+ const layerColors = terrain.layers.map((layer) => surfaceColor(layerColor(layer)));
155
+ displaceHeightfieldGeometry(geometry, terrain.sampleHeight, {
156
+ bounds,
157
+ region,
158
+ color: (x, z, height, out) => {
159
+ toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors);
160
+ applyRelief(out, height, contourStrength);
161
+ },
162
+ });
169
163
  editorPerfMarks.record("rebuild", performance.now() - started);
170
164
  }
171
- function SculptMesh({ terrain, bounds, segments, revision, regionRef, meshRef, paletteAt, heightRange, contourStrength, }) {
165
+ function unionRect(a, b) {
166
+ return {
167
+ minX: Math.min(a.minX, b.minX),
168
+ minZ: Math.min(a.minZ, b.minZ),
169
+ maxX: Math.max(a.maxX, b.maxX),
170
+ maxZ: Math.max(a.maxZ, b.maxZ),
171
+ };
172
+ }
173
+ /** Memoized so cursor-hover state updates in the parent never reconcile the mesh subtree. */
174
+ const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revision, pendingRef, paletteAt, heightRange, contourStrength, }) {
172
175
  const geometry = useMemo(() => {
173
176
  const width = Math.max(1, bounds.maxX - bounds.minX);
174
177
  const depth = Math.max(1, bounds.maxZ - bounds.minZ);
@@ -179,10 +182,33 @@ function SculptMesh({ terrain, bounds, segments, revision, regionRef, meshRef, p
179
182
  }, [boundsKey(bounds, segments)]);
180
183
  useEffect(() => () => geometry.dispose(), [geometry]);
181
184
  useEffect(() => {
182
- displace(geometry, terrain, bounds, regionRef.current, paletteAt, heightRange, contourStrength);
185
+ // Consume the queued work; a re-run triggered by anything other than a stamp (palette,
186
+ // contour strength, fresh geometry) sees no pending entry and rebuilds the whole mesh.
187
+ const pending = pendingRef.current;
188
+ pendingRef.current = null;
189
+ const region = pending !== null && pending.kind === "region" ? pending.region : null;
190
+ displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength);
183
191
  // eslint-disable-next-line react-hooks/exhaustive-deps
184
192
  }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
185
- return (_jsx("mesh", { ref: meshRef, geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
193
+ return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
194
+ });
195
+ function selectSculptUi(state) {
196
+ return {
197
+ tool: state.tool,
198
+ terrainMode: state.terrainMode,
199
+ sculptRadius: state.sculpt.radius,
200
+ sculptBrush: state.sculpt.brush,
201
+ sculptInvert: state.sculpt.invert,
202
+ paintRadius: state.paint.radius,
203
+ };
204
+ }
205
+ function sculptUiEqual(a, b) {
206
+ return (a.tool === b.tool &&
207
+ a.terrainMode === b.terrainMode &&
208
+ a.sculptRadius === b.sculptRadius &&
209
+ a.sculptBrush === b.sculptBrush &&
210
+ a.sculptInvert === b.sculptInvert &&
211
+ a.paintRadius === b.paintRadius);
186
212
  }
187
213
  /**
188
214
  * Terrain-sculpt viewport layer: renders the editable heightfield and, while the terrain tool is
@@ -199,26 +225,35 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
199
225
  const terrainDescriptor = useMemo(() => terrainDescriptorOf(world), [world]);
200
226
  const paletteAt = useMemo(() => (terrainDescriptor === undefined ? null : createTerrainPaletteSampler(terrainDescriptor)), [terrainDescriptor]);
201
227
  const heightRange = useMemo(() => (terrainDescriptor === undefined ? [0, 1] : heightRangeOf(terrainDescriptor)), [terrainDescriptor]);
202
- const [, setTick] = useState(0);
203
- useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
204
- useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
205
- const snapshot = session.getState().document.terrain ?? null;
228
+ // Selector subscriptions: only the terrain snapshot and the sculpt-relevant UI slice re-render
229
+ // this layer — selection changes, marker edits, and unrelated UI churn no longer touch it.
230
+ const snapshot = useStoreSelector(session, (state) => state.document.terrain ?? null);
231
+ const uiSlice = useStoreSelector(ui, selectSculptUi, sculptUiEqual);
206
232
  const base = ctx.world.ground;
207
- const uiState = ui.getState();
208
- const toolActive = uiState.tool === "terrain";
233
+ const toolActive = uiSlice.tool === "terrain";
209
234
  const editableRef = useRef(null);
210
235
  const boundsRef = useRef(null);
211
- const meshRef = useRef(null);
212
236
  const syncedRef = useRef(null);
213
237
  const gridKeyRef = useRef(null);
214
- const regionRef = useRef(null);
238
+ const pendingRef = useRef({ kind: "full" });
215
239
  const [revision, setRevision] = useState(0);
216
240
  const [cursor, setCursor] = useState(null);
217
241
  const segments = snapshot === null ? 0 : segmentsFor(snapshot);
218
242
  const gridKey = snapshot === null ? null : `${boundsKey(snapshot.bounds, segments)}:${snapshot.cellSize}`;
219
- // region === null forces a whole-mesh rebuild; a stamp passes only its dirty footprint.
243
+ // region === null queues a whole-mesh rebuild; a stamp queues only its dirty footprint. Queued
244
+ // regions union (two stamps before an effect flush both get displaced), and a queued full
245
+ // rebuild is never downgraded by a later stamp.
220
246
  const refresh = (region = null) => {
221
- regionRef.current = region;
247
+ const pending = pendingRef.current;
248
+ if (region === null || pending?.kind === "full") {
249
+ pendingRef.current = { kind: "full" };
250
+ }
251
+ else if (pending === null) {
252
+ pendingRef.current = { kind: "region", region };
253
+ }
254
+ else {
255
+ pendingRef.current = { kind: "region", region: unionRect(pending.region, region) };
256
+ }
222
257
  setRevision((value) => value + 1);
223
258
  };
224
259
  // Rebuild the live terrain when the grid changes; otherwise re-sync offsets on undo/redo/import.
@@ -257,15 +292,19 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
257
292
  let last = null;
258
293
  const pick = (clientX, clientY) => {
259
294
  const started = performance.now();
260
- const mesh = meshRef.current;
261
295
  const rect = canvas.getBoundingClientRect();
262
296
  ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
263
297
  raycaster.setFromCamera(ndc, camera);
264
298
  let result = null;
265
- if (mesh !== null) {
266
- const hit = raycaster.intersectObject(mesh, false)[0];
267
- if (hit !== undefined)
268
- result = { x: hit.point.x, z: hit.point.z };
299
+ // Heightfield raymarch against the live terrain — O(steps along the ray) instead of a
300
+ // brute-force triangle raycast against the whole preview mesh on every pointer move.
301
+ const terrain = editableRef.current;
302
+ const terrainBounds = boundsRef.current;
303
+ if (terrain !== null && terrainBounds !== null) {
304
+ const { origin, direction } = raycaster.ray;
305
+ const hit = raycastHeightField(terrain.sampleHeight, [origin.x, origin.y, origin.z], [direction.x, direction.y, direction.z], { bounds: terrainBounds, step: Math.max(0.25, (snapshot?.cellSize ?? 1) / 2) });
306
+ if (hit !== null)
307
+ result = { x: hit.x, z: hit.z };
269
308
  }
270
309
  if (result === null && raycaster.ray.intersectPlane(GROUND_PLANE, planeHit) !== null) {
271
310
  result = { x: planeHit.x, z: planeHit.z };
@@ -411,5 +450,5 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
411
450
  }, [toolActive, snapshot, gl, camera, controls, session, ui]);
412
451
  if (snapshot === null || editableRef.current === null || boundsRef.current === null)
413
452
  return null;
414
- return (_jsxs(_Fragment, { children: [_jsx(SculptMesh, { terrain: editableRef.current, bounds: boundsRef.current, segments: segments, revision: revision, regionRef: regionRef, meshRef: meshRef, paletteAt: paletteAt, heightRange: heightRange, contourStrength: toolActive ? 1 : 0.45 }), toolActive && cursor !== null ? (_jsx(TerraformBrushCursor, { center: [cursor.x, cursor.z], y: cursor.y + 0.1, radius: uiState.terrainMode === "paint" ? uiState.paint.radius : uiState.sculpt.radius, mode: uiState.terrainMode === "paint" ? "paint" : effectiveBrush(uiState.sculpt.brush, uiState.sculpt.invert) })) : null] }));
453
+ return (_jsxs(_Fragment, { children: [_jsx(SculptMesh, { terrain: editableRef.current, bounds: boundsRef.current, segments: segments, revision: revision, pendingRef: pendingRef, paletteAt: paletteAt, heightRange: heightRange, contourStrength: toolActive ? 1 : 0.45 }), toolActive && cursor !== null ? (_jsx(TerraformBrushCursor, { center: [cursor.x, cursor.z], y: cursor.y + 0.1, radius: uiSlice.terrainMode === "paint" ? uiSlice.paintRadius : uiSlice.sculptRadius, mode: uiSlice.terrainMode === "paint" ? "paint" : effectiveBrush(uiSlice.sculptBrush, uiSlice.sculptInvert) })) : null] }));
415
454
  });
@@ -5,9 +5,12 @@ import { createDefaultAgentEndpoint } from "./endpoint.js";
5
5
  * Tool calls route through `EditorHostApi.handle`; patches appear in the transcript and can be undone.
6
6
  * @internal — mounted by `EditorChrome`.
7
7
  */
8
- export declare function AgentPanel({ api, onClose, }: {
8
+ export declare function AgentPanel({ api, onClose, embedded, }: {
9
9
  api: EditorHostApi;
10
- onClose: () => void;
10
+ /** Close affordance for the side-dock variant; omit when the host owns dismissal. */
11
+ onClose?: () => void;
12
+ /** Fill the parent (bottom-dock tab) instead of rendering the resizable right aside. */
13
+ embedded?: boolean;
11
14
  }): import("react").JSX.Element;
12
15
  /** Re-export for hosts that want a default endpoint without the panel. */
13
16
  export { createDefaultAgentEndpoint };
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { BTN, INPUT, MICRO } from "../chromeStyles.js";
3
+ import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL, PANEL_BG } from "../shell/theme.js";
4
+ const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
5
+ const INPUT = `px-2 py-1 ${INPUT_CLS}`;
6
+ const MICRO = MICRO_LABEL;
4
7
  import { createDefaultAgentEndpoint, createHttpAgentEndpoint, createLocalAgentEndpoint, EDITOR_AGENT_KEY_ENV, EDITOR_AGENT_URL_ENV, resolveAgentEndpointConfig, } from "./endpoint.js";
5
8
  import { packAgentContext } from "./context.js";
6
9
  import { runAgentTurn, undoAgentPatch, } from "./turn.js";
@@ -28,7 +31,7 @@ function buildEndpoint(url, apiKey) {
28
31
  * Tool calls route through `EditorHostApi.handle`; patches appear in the transcript and can be undone.
29
32
  * @internal — mounted by `EditorChrome`.
30
33
  */
31
- export function AgentPanel({ api, onClose, }) {
34
+ export function AgentPanel({ api, onClose, embedded = false, }) {
32
35
  const stored = useMemo(() => readStoredConfig(), []);
33
36
  const [url, setUrl] = useState(stored.url);
34
37
  const [apiKey, setApiKey] = useState(stored.apiKey);
@@ -110,21 +113,23 @@ export function AgentPanel({ api, onClose, }) {
110
113
  setContextTick((v) => v + 1);
111
114
  }, [api, patches]);
112
115
  const envHint = resolveAgentEndpointConfig();
113
- return (_jsxs("aside", { className: "pointer-events-auto flex w-80 min-w-64 max-w-[48vw] resize-x flex-col overflow-hidden border-l border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.08] px-2 py-2", children: [_jsx("div", { className: MICRO, children: "Agent" }), _jsx("span", { className: `rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${endpoint.id === "http"
116
+ return (_jsxs("aside", { className: embedded
117
+ ? "pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden"
118
+ : `pointer-events-auto flex w-80 min-w-64 max-w-[48vw] resize-x flex-col overflow-hidden border-l ${BORDER} ${PANEL_BG}/95 backdrop-blur-md`, children: [_jsxs("div", { className: `flex items-center gap-2 border-b ${BORDER} px-2 py-2`, children: [_jsx("div", { className: MICRO, children: "Agent" }), _jsx("span", { className: `rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${endpoint.id === "http"
114
119
  ? "bg-violet-500/20 text-violet-200 ring-1 ring-inset ring-violet-400/30"
115
- : "bg-white/[0.06] text-neutral-400 ring-1 ring-inset ring-white/10"}`, title: endpoint.id === "http" ? "Remote endpoint" : "Local offline agent", children: endpoint.id }), _jsx("button", { type: "button", className: `${BTN} ml-auto text-[10px]`, onClick: () => setShowConfig((v) => !v), children: "Config" }), _jsx("button", { type: "button", className: "rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: onClose, "aria-label": "Close agent panel", children: "\u00D7" })] }), showConfig ? (_jsxs("div", { className: "space-y-2 border-b border-white/[0.08] p-2", children: [_jsx("div", { className: `${MICRO} text-neutral-500`, children: "Endpoint" }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, placeholder: `${EDITOR_AGENT_URL_ENV} or https://…`, value: url, onChange: (event) => setUrl(event.target.value), onBlur: persistConfig }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, type: "password", placeholder: `${EDITOR_AGENT_KEY_ENV} / ANTHROPIC_API_KEY`, value: apiKey, onChange: (event) => setApiKey(event.target.value), onBlur: persistConfig }), _jsxs("p", { className: "text-[10px] leading-snug text-neutral-500", children: ["Env: ", EDITOR_AGENT_URL_ENV, envHint.url !== undefined ? " ✓" : " (unset)", " \u00B7 ", EDITOR_AGENT_KEY_ENV, envHint.apiKey !== undefined ? " ✓" : " (unset)", ". Empty URL uses the local command agent (/help). Remote: POST JSON ", "{messages,context,tools}", " \u2192 ", "{message?,toolCalls?}", "."] }), _jsx("button", { type: "button", className: BTN, onClick: () => {
120
+ : "bg-white/[0.06] text-neutral-400 ring-1 ring-inset ring-white/10"}`, title: endpoint.id === "http" ? "Remote endpoint" : "Local offline agent", children: endpoint.id }), _jsx("button", { type: "button", className: `${BTN} ml-auto text-[10px]`, onClick: () => setShowConfig((v) => !v), children: "Config" }), onClose !== undefined ? (_jsx("button", { type: "button", className: `rounded-[5px] px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200 ${FOCUS_RING}`, onClick: onClose, "aria-label": "Close agent panel", children: "\u00D7" })) : null] }), showConfig ? (_jsxs("div", { className: `space-y-2 border-b ${BORDER} p-2`, children: [_jsx("div", { className: MICRO, children: "Endpoint" }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, placeholder: `${EDITOR_AGENT_URL_ENV} or https://…`, value: url, onChange: (event) => setUrl(event.target.value), onBlur: persistConfig }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, type: "password", placeholder: `${EDITOR_AGENT_KEY_ENV} / ANTHROPIC_API_KEY`, value: apiKey, onChange: (event) => setApiKey(event.target.value), onBlur: persistConfig }), _jsxs("p", { className: "text-[10px] leading-snug text-neutral-500", children: ["Env: ", EDITOR_AGENT_URL_ENV, envHint.url !== undefined ? " ✓" : " (unset)", " \u00B7 ", EDITOR_AGENT_KEY_ENV, envHint.apiKey !== undefined ? " ✓" : " (unset)", ". Empty URL uses the local command agent (/help). Remote: POST JSON ", "{messages,context,tools}", " \u2192 ", "{message?,toolCalls?}", "."] }), _jsx("button", { type: "button", className: BTN, onClick: () => {
116
121
  setUrl("");
117
122
  setApiKey("");
118
123
  if (typeof localStorage !== "undefined") {
119
124
  localStorage.removeItem(STORAGE_URL);
120
125
  localStorage.removeItem(STORAGE_KEY);
121
126
  }
122
- }, children: "Use local agent" })] })) : null, _jsxs("div", { className: "border-b border-white/[0.08] px-2 py-1.5 text-[10px] text-neutral-500", children: [_jsx("span", { className: "text-neutral-400", children: context.mode }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: ["sel ", context.selection.length === 0 ? "—" : context.selection.slice(0, 3).join(", "), context.selection.length > 3 ? ` +${context.selection.length - 3}` : ""] }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: [context.summary.markers, "m ", context.summary.volumes, "v ", context.summary.paths, "p"] })] }), _jsxs("div", { ref: listRef, className: "min-h-0 flex-1 space-y-2 overflow-auto p-2", children: [transcript.length === 0 ? (_jsxs("div", { className: "rounded-lg border border-dashed border-white/10 bg-black/20 p-3 text-[11px] leading-relaxed text-neutral-500", children: ["Chat drives the live scene through the same RPC verbs as the MCP bridge. Edits share undo with the toolbar. Local: try ", _jsx("code", { className: "text-cyan-300", children: "/status" }), ",", " ", _jsx("code", { className: "text-cyan-300", children: "/help" }), ", or", " ", _jsx("code", { className: "text-cyan-300", children: "move id x y z" }), "."] })) : null, transcript.map((entry) => (_jsx(TranscriptRow, { entry: entry, patches: patches, onUndo: onUndoPatch }, entry.id))), busy ? _jsx("div", { className: "text-[10px] text-violet-300", children: "Thinking\u2026" }) : null] }), patches.some((p) => !p.undone) ? (_jsxs("div", { className: "max-h-28 overflow-auto border-t border-white/[0.08] p-2", children: [_jsx("div", { className: `${MICRO} mb-1`, children: "Agent patches" }), _jsx("ul", { className: "space-y-1", children: patches
127
+ }, children: "Use local agent" })] })) : null, _jsxs("div", { className: `border-b ${BORDER} px-2 py-1.5 text-[10px] text-neutral-500`, children: [_jsx("span", { className: "text-neutral-400", children: context.mode }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: ["sel ", context.selection.length === 0 ? "—" : context.selection.slice(0, 3).join(", "), context.selection.length > 3 ? ` +${context.selection.length - 3}` : ""] }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: [context.summary.markers, "m ", context.summary.volumes, "v ", context.summary.paths, "p"] })] }), _jsxs("div", { ref: listRef, className: "min-h-0 flex-1 space-y-2 overflow-auto p-2", children: [transcript.length === 0 ? (_jsxs("div", { className: `rounded-[6px] border border-dashed ${BORDER} bg-black/20 p-3 text-[11px] leading-relaxed text-neutral-500`, children: ["Chat drives the live scene through the same RPC verbs as the MCP bridge. Edits share undo with the toolbar. Local: try ", _jsx("code", { className: "text-cyan-300", children: "/status" }), ",", " ", _jsx("code", { className: "text-cyan-300", children: "/help" }), ", or", " ", _jsx("code", { className: "text-cyan-300", children: "move id x y z" }), "."] })) : null, transcript.map((entry) => (_jsx(TranscriptRow, { entry: entry, patches: patches, onUndo: onUndoPatch }, entry.id))), busy ? _jsx("div", { className: "text-[10px] text-violet-300", children: "Thinking\u2026" }) : null] }), patches.some((p) => !p.undone) ? (_jsxs("div", { className: `max-h-28 overflow-auto border-t ${BORDER} p-2`, children: [_jsx("div", { className: `${MICRO} mb-1`, children: "Agent patches" }), _jsx("ul", { className: "space-y-1", children: patches
123
128
  .filter((p) => !p.undone)
124
129
  .map((patch, index, live) => {
125
130
  const isTop = index === live.length - 1;
126
131
  return (_jsxs("li", { className: "flex items-center gap-1 text-[10px] text-neutral-300", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-cyan-200/90", children: patch.summary }), _jsx("button", { type: "button", className: `${BTN} shrink-0 px-1.5 py-0.5 text-[10px] disabled:opacity-40`, disabled: !isTop, title: isTop ? "Undo this edit" : "Undo newer patches first", onClick: () => onUndoPatch(patch.id), children: "Undo" })] }, patch.id));
127
- }) })] })) : null, _jsxs("div", { className: "flex gap-1 border-t border-white/[0.08] p-2", children: [_jsx("input", { className: `min-w-0 flex-1 text-[12px] ${INPUT}`, placeholder: endpoint.id === "local" ? "/help · /status · move id x y z" : "Ask the agent to edit the scene…", value: input, disabled: busy, onChange: (event) => setInput(event.target.value), onKeyDown: (event) => {
132
+ }) })] })) : null, _jsxs("div", { className: `flex gap-1 border-t ${BORDER} p-2`, children: [_jsx("input", { className: `min-w-0 flex-1 text-[12px] ${INPUT}`, placeholder: endpoint.id === "local" ? "/help · /status · move id x y z" : "Ask the agent to edit the scene…", value: input, disabled: busy, onChange: (event) => setInput(event.target.value), onKeyDown: (event) => {
128
133
  if (event.key === "Enter" && !event.shiftKey) {
129
134
  event.preventDefault();
130
135
  void send();
@@ -1,4 +1,5 @@
1
1
  import { decodeEditorBridgeRequest } from "../mcp/rpcRequest.js";
2
+ import { emitEditorConsole } from "../shell/consoleSink.js";
2
3
  /** Methods that never write the document (no undo step). */
3
4
  const READ_ONLY_METHODS = new Set([
4
5
  "editor_status",
@@ -49,6 +50,7 @@ function summarizeCall(name, args, response, mutated) {
49
50
  export function routeToolCall(api, call) {
50
51
  const built = toolCallToRequest(call);
51
52
  if (!built.ok) {
53
+ emitEditorConsole("error", "agent", `${call.name} rejected: ${built.error}`);
52
54
  return {
53
55
  id: call.id,
54
56
  name: call.name,
@@ -58,14 +60,17 @@ export function routeToolCall(api, call) {
58
60
  patchSummary: `${call.name} rejected: ${built.error}`,
59
61
  };
60
62
  }
63
+ emitEditorConsole("info", "agent", `tool ${call.name}`);
61
64
  const session = api.getSession();
62
65
  const before = session.exportJson(false);
63
66
  let response;
64
67
  try {
68
+ // RPC failures are logged once by the host handle sink; agent layer only records the call.
65
69
  response = api.handle(built.request);
66
70
  }
67
71
  catch (error) {
68
72
  const message = error instanceof Error ? error.message : String(error);
73
+ emitEditorConsole("error", "agent", `${call.name} threw: ${message}`);
69
74
  return {
70
75
  id: call.id,
71
76
  name: call.name,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared model-import helpers used by the standalone editor strip and the in-game Content Browser.
3
+ * Host-agnostic and free of React/shell so unit tests can exercise the importer without a DOM or three.
4
+ */
5
+ /** One user-supplied model the editor can place: a stable id and a resolvable URL. */
6
+ export interface StandaloneAsset {
7
+ id: string;
8
+ url: string;
9
+ label?: string;
10
+ }
11
+ /**
12
+ * Persists a dropped model file through the editor host so it survives reload as a durable catalog asset,
13
+ * returning the host-assigned id/url (which the manifest scan re-lists on reload). Resolves `null` when no
14
+ * host is listening — a plain browser mount with no dev server — so the caller can fall back to a blob URL.
15
+ */
16
+ export type AssetImporter = (file: File) => Promise<StandaloneAsset | null>;
17
+ /**
18
+ * Uploads a model to the editor host plugin's import endpoint and returns the durable asset it persisted.
19
+ * Returns `null` on any failure (no host, non-model, network error) so imports degrade to ephemeral blobs.
20
+ * The default {@link AssetImporter} `StandaloneEditor` and the in-game Content Browser use.
21
+ * @internal
22
+ */
23
+ export declare function importAssetToHost(file: File, route?: string): Promise<StandaloneAsset | null>;
24
+ /**
25
+ * Turns dropped files into placeable assets: each model is persisted through `importer` when a host answers,
26
+ * else falls back to an ephemeral `blob:` URL (lost on reload). Non-model files are skipped. Pure and
27
+ * host-agnostic so the import wiring is testable without a DOM.
28
+ * @internal
29
+ */
30
+ export declare function loadDroppedAssets(files: readonly File[], importer: AssetImporter): Promise<StandaloneAsset[]>;
31
+ /**
32
+ * Appends freshly dropped assets, replacing any prior entry that shares an id (re-imports stay a
33
+ * single catalog entry). Shared by the standalone strip and the in-game Content Browser importer.
34
+ * @internal
35
+ */
36
+ export declare function mergeStandaloneAssets(current: readonly StandaloneAsset[], next: readonly StandaloneAsset[]): StandaloneAsset[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared model-import helpers used by the standalone editor strip and the in-game Content Browser.
3
+ * Host-agnostic and free of React/shell so unit tests can exercise the importer without a DOM or three.
4
+ */
5
+ const MODEL_FILE = /\.(glb|gltf)$/i;
6
+ /** Route the `@jgengine/node` editor host plugin persists uploaded models on; must match its `IMPORT_ROUTE`. */
7
+ const IMPORT_ASSET_ROUTE = "/__jgengine/import-asset";
8
+ function assetId(name) {
9
+ return name.replace(MODEL_FILE, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "asset";
10
+ }
11
+ /**
12
+ * Uploads a model to the editor host plugin's import endpoint and returns the durable asset it persisted.
13
+ * Returns `null` on any failure (no host, non-model, network error) so imports degrade to ephemeral blobs.
14
+ * The default {@link AssetImporter} `StandaloneEditor` and the in-game Content Browser use.
15
+ * @internal
16
+ */
17
+ export async function importAssetToHost(file, route = IMPORT_ASSET_ROUTE) {
18
+ try {
19
+ const response = await fetch(route, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/octet-stream",
23
+ "x-jg-filename": encodeURIComponent(file.name),
24
+ },
25
+ body: file,
26
+ });
27
+ if (!response.ok)
28
+ return null;
29
+ const asset = (await response.json());
30
+ if (typeof asset.id !== "string" || typeof asset.url !== "string")
31
+ return null;
32
+ return { id: asset.id, url: asset.url, label: asset.label ?? file.name };
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Turns dropped files into placeable assets: each model is persisted through `importer` when a host answers,
40
+ * else falls back to an ephemeral `blob:` URL (lost on reload). Non-model files are skipped. Pure and
41
+ * host-agnostic so the import wiring is testable without a DOM.
42
+ * @internal
43
+ */
44
+ export async function loadDroppedAssets(files, importer) {
45
+ const out = [];
46
+ for (const file of files) {
47
+ if (!MODEL_FILE.test(file.name))
48
+ continue;
49
+ const persisted = await importer(file);
50
+ out.push(persisted ?? { id: assetId(file.name), url: URL.createObjectURL(file), label: file.name });
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Appends freshly dropped assets, replacing any prior entry that shares an id (re-imports stay a
56
+ * single catalog entry). Shared by the standalone strip and the in-game Content Browser importer.
57
+ * @internal
58
+ */
59
+ export function mergeStandaloneAssets(current, next) {
60
+ if (next.length === 0)
61
+ return [...current];
62
+ const byId = new Map(current.map((asset) => [asset.id, asset]));
63
+ for (const asset of next)
64
+ byId.set(asset.id, asset);
65
+ return Array.from(byId.values());
66
+ }
@@ -0,0 +1,25 @@
1
+ import { type TriggerSourceKind } from "@jgengine/core/scene/authoredTriggers";
2
+ /**
3
+ * True when an object's meta carries an authored behavior trigger (flat on/action or a multi-list).
4
+ * Empty `on` / no list means the trigger component is not installed.
5
+ * @internal
6
+ */
7
+ export declare function hasAuthoredTrigger(meta: Record<string, unknown> | undefined): boolean;
8
+ /** True when the game has registered at least one trigger action for this target kind. @internal */
9
+ export declare function canAuthorTrigger(target: TriggerSourceKind): boolean;
10
+ /**
11
+ * Meta patch that installs a default trigger (first registered action, enter event) without
12
+ * inventing action ids. Returns null when no actions are registered.
13
+ * @internal
14
+ */
15
+ export declare function defaultTriggerInstallPatch(target: TriggerSourceKind, meta: Record<string, unknown> | undefined): Record<string, unknown> | null;
16
+ /**
17
+ * Meta patch that clears the trigger vocabulary keys. Action-specific params are left in place so
18
+ * re-adding the same action keeps prior tuning; the component is considered removed once `on`/`action`/list are gone.
19
+ * @internal
20
+ */
21
+ export declare function clearTriggerInstallPatch(): Record<string, unknown>;
22
+ /** True when a material assignment is present on the object. @internal */
23
+ export declare function hasMaterialAssignment(meta: Record<string, unknown> | undefined): boolean;
24
+ /** Meta patch that removes material assignment. @internal */
25
+ export declare function clearMaterialAssignmentPatch(): Record<string, unknown>;
@@ -0,0 +1,65 @@
1
+ import { listTriggerActions, readFlatTrigger, TRIGGER_ACTION_KEY, TRIGGER_LIST_KEY, TRIGGER_ON_KEY, TRIGGER_RADIUS_KEY, } from "@jgengine/core/scene/authoredTriggers";
2
+ import { defaultParamMeta } from "@jgengine/core/scene/sceneKinds";
3
+ /**
4
+ * True when an object's meta carries an authored behavior trigger (flat on/action or a multi-list).
5
+ * Empty `on` / no list means the trigger component is not installed.
6
+ * @internal
7
+ */
8
+ export function hasAuthoredTrigger(meta) {
9
+ if (meta === undefined)
10
+ return false;
11
+ const list = meta[TRIGGER_LIST_KEY];
12
+ if (Array.isArray(list) && list.length > 0)
13
+ return true;
14
+ const flat = readFlatTrigger(meta);
15
+ return flat.on !== "" && flat.action.length > 0;
16
+ }
17
+ /** True when the game has registered at least one trigger action for this target kind. @internal */
18
+ export function canAuthorTrigger(target) {
19
+ return listTriggerActions(target).length > 0;
20
+ }
21
+ /**
22
+ * Meta patch that installs a default trigger (first registered action, enter event) without
23
+ * inventing action ids. Returns null when no actions are registered.
24
+ * @internal
25
+ */
26
+ export function defaultTriggerInstallPatch(target, meta) {
27
+ const actions = listTriggerActions(target);
28
+ const first = actions[0];
29
+ if (first === undefined)
30
+ return null;
31
+ const events = first.events;
32
+ const on = events !== undefined && events.length > 0 ? events[0] : "enter";
33
+ const patch = {
34
+ [TRIGGER_ON_KEY]: on,
35
+ [TRIGGER_ACTION_KEY]: first.id,
36
+ [TRIGGER_LIST_KEY]: [],
37
+ };
38
+ const defaults = defaultParamMeta(first.schema);
39
+ for (const [key, value] of Object.entries(defaults)) {
40
+ if (meta?.[key] === undefined)
41
+ patch[key] = value;
42
+ }
43
+ return patch;
44
+ }
45
+ /**
46
+ * Meta patch that clears the trigger vocabulary keys. Action-specific params are left in place so
47
+ * re-adding the same action keeps prior tuning; the component is considered removed once `on`/`action`/list are gone.
48
+ * @internal
49
+ */
50
+ export function clearTriggerInstallPatch() {
51
+ return {
52
+ [TRIGGER_ON_KEY]: "",
53
+ [TRIGGER_ACTION_KEY]: "",
54
+ [TRIGGER_LIST_KEY]: [],
55
+ [TRIGGER_RADIUS_KEY]: undefined,
56
+ };
57
+ }
58
+ /** True when a material assignment is present on the object. @internal */
59
+ export function hasMaterialAssignment(meta) {
60
+ return typeof meta?.["materialId"] === "string" && meta["materialId"].length > 0;
61
+ }
62
+ /** Meta patch that removes material assignment. @internal */
63
+ export function clearMaterialAssignmentPatch() {
64
+ return { materialId: undefined };
65
+ }
@@ -0,0 +1,4 @@
1
+ import type { EditorVec3 } from "@jgengine/core/editor/index";
2
+ import type { GizmoPivot } from "./uiStore.js";
3
+ /** Aggregate a list of positions into a pivot according to the toolbar pivot mode. @internal */
4
+ export declare function resolvePivotPosition(positions: readonly EditorVec3[], pivot: GizmoPivot, primary: EditorVec3 | null): EditorVec3 | null;
@@ -0,0 +1,34 @@
1
+ function medianOf(values) {
2
+ if (values.length === 0)
3
+ return 0;
4
+ const sorted = [...values].sort((a, b) => a - b);
5
+ const mid = Math.floor(sorted.length / 2);
6
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
7
+ }
8
+ function meanOf(positions) {
9
+ let x = 0;
10
+ let y = 0;
11
+ let z = 0;
12
+ for (const point of positions) {
13
+ x += point.x;
14
+ y += point.y;
15
+ z += point.z;
16
+ }
17
+ const n = Math.max(1, positions.length);
18
+ return { x: x / n, y: y / n, z: z / n };
19
+ }
20
+ /** Aggregate a list of positions into a pivot according to the toolbar pivot mode. @internal */
21
+ export function resolvePivotPosition(positions, pivot, primary) {
22
+ if (positions.length === 0)
23
+ return primary;
24
+ if (positions.length === 1 || pivot === "origin")
25
+ return primary ?? positions[0];
26
+ if (pivot === "median") {
27
+ return {
28
+ x: medianOf(positions.map((p) => p.x)),
29
+ y: medianOf(positions.map((p) => p.y)),
30
+ z: medianOf(positions.map((p) => p.z)),
31
+ };
32
+ }
33
+ return meanOf(positions);
34
+ }
@@ -0,0 +1,3 @@
1
+ import type { HandlerTable } from "./context.js";
2
+ /** Gameplay data catalog verbs (list / get / merge-patch / add / remove rows). */
3
+ export declare const catalogHandlers: Pick<HandlerTable, "list_catalogs" | "get_catalog_entry" | "set_catalog_entry" | "add_catalog_entry" | "remove_catalog_entry" | "add_catalog" | "remove_catalog" | "set_catalog_schema">;