@jgengine/editor 0.19.0 → 0.21.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 (71) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +10 -28
  4. package/dist/CreatorApp.d.ts +12 -0
  5. package/dist/CreatorApp.js +107 -0
  6. package/dist/DebugDraw.js +3 -1
  7. package/dist/EditorApp.d.ts +14 -2
  8. package/dist/EditorApp.js +29 -14
  9. package/dist/EditorChrome.d.ts +9 -1
  10. package/dist/EditorChrome.js +54 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/MaterialAssetEditor.d.ts +13 -0
  14. package/dist/MaterialAssetEditor.js +109 -0
  15. package/dist/MaterialPreview.d.ts +16 -0
  16. package/dist/MaterialPreview.js +41 -0
  17. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  18. package/dist/MaterialsWorkspacePanel.js +78 -4
  19. package/dist/RuntimePlayBridge.js +1 -6
  20. package/dist/SelectionGizmo.js +23 -14
  21. package/dist/SimulationInspector.d.ts +9 -0
  22. package/dist/SimulationInspector.js +47 -0
  23. package/dist/TerrainSculpt.js +3 -1
  24. package/dist/agent/toolBridge.js +2 -0
  25. package/dist/authoringPlacement.d.ts +21 -0
  26. package/dist/authoringPlacement.js +34 -0
  27. package/dist/handlers/context.d.ts +1 -0
  28. package/dist/handlers/document.d.ts +1 -1
  29. package/dist/handlers/document.js +29 -1
  30. package/dist/handlers/hierarchy.d.ts +1 -1
  31. package/dist/handlers/hierarchy.js +4 -0
  32. package/dist/handlers/runtime.js +20 -17
  33. package/dist/handlers/terrain.js +52 -12
  34. package/dist/index.d.ts +2 -1
  35. package/dist/index.js +1 -0
  36. package/dist/materialControls.d.ts +32 -0
  37. package/dist/materialControls.js +79 -0
  38. package/dist/mcp/fieldSpec.d.ts +1 -1
  39. package/dist/mcp/fieldSpec.js +11 -2
  40. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  41. package/dist/mcp/importOptionalGameModule.js +13 -0
  42. package/dist/mcp/loadGameCatalogs.js +5 -12
  43. package/dist/mcp/rpcRequest.d.ts +3 -3
  44. package/dist/mcp/rpcRequest.js +10 -4
  45. package/dist/mcp/tools.js +10 -1
  46. package/dist/modelAnimationAuthoring.d.ts +27 -7
  47. package/dist/modelAnimationAuthoring.js +69 -72
  48. package/dist/session.d.ts +62 -0
  49. package/dist/session.js +56 -7
  50. package/dist/shell/AnimationGraphPanel.js +18 -10
  51. package/dist/shell/PlayModeBar.d.ts +2 -1
  52. package/dist/shell/PlayModeBar.js +2 -2
  53. package/dist/shell/SceneToolbar.d.ts +2 -1
  54. package/dist/shell/SceneToolbar.js +5 -4
  55. package/dist/shell/TopAppBar.d.ts +2 -1
  56. package/dist/shell/TopAppBar.js +2 -2
  57. package/dist/shell/animGraphPreview.d.ts +2 -0
  58. package/dist/shell/animGraphPreview.js +5 -2
  59. package/dist/shell/clipPreview.d.ts +1 -0
  60. package/dist/useDocumentSave.d.ts +20 -0
  61. package/dist/useDocumentSave.js +33 -0
  62. package/package.json +6 -5
  63. package/skills/ce-handoff/SKILL.md +1 -1
  64. package/skills/jgengine/SKILL.md +22 -2
  65. package/skills/jgengine/api.md +28 -17
  66. package/skills/jgengine/recipes/minimal-game.md +7 -2
  67. package/skills/jgengine-editor/SKILL.md +9 -1
  68. package/skills/jgengine-editor/api.md +102 -16
  69. package/skills/jgengine-editor/capabilities.md +40 -0
  70. package/skills/jgengine-editor/reference.md +149 -1
  71. package/skills/jgengine-editor/references/live-sync.md +15 -0
@@ -4,6 +4,7 @@ import { memo, useEffect, useMemo } from "react";
4
4
  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
+ import { placementGround, pathSegmentDistance, selectionGizmoMode, snapPlacement } from "./authoringPlacement.js";
7
8
  import { isPointerClick } from "./viewportContextMenu.js";
8
9
  import { resolvePivotPosition } from "./gizmoPivot.js";
9
10
  import { newPlacementId, } from "./uiStore.js";
@@ -108,13 +109,17 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
108
109
  const planeHit = new THREE.Vector3();
109
110
  let down = null;
110
111
  const session = () => api.getSession();
111
- const screenDistance = (point, lift, clickX, clickY, rect) => {
112
+ const screenPoint = (point, lift, rect) => {
112
113
  projected.set(point.x, point.y + lift, point.z).project(camera);
113
114
  if (projected.z < -1 || projected.z > 1)
114
115
  return null;
115
116
  const px = (projected.x * 0.5 + 0.5) * rect.width;
116
117
  const py = (-projected.y * 0.5 + 0.5) * rect.height;
117
- return Math.hypot(px - clickX, py - clickY);
118
+ return { x: px, y: py };
119
+ };
120
+ const screenDistance = (point, lift, clickX, clickY, rect) => {
121
+ const projectedPoint = screenPoint(point, lift, rect);
122
+ return projectedPoint === null ? null : Math.hypot(projectedPoint.x - clickX, projectedPoint.y - clickY);
118
123
  };
119
124
  const groundPoint = (clickX, clickY, rect) => {
120
125
  ndc.set((clickX / rect.width) * 2 - 1, -(clickY / rect.height) * 2 + 1);
@@ -135,6 +140,11 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
135
140
  const placement = ui.getState().placement;
136
141
  if (placement === null)
137
142
  return;
143
+ const ground = placementGround(session().getState().document.terrain, api.getTerrainSampler());
144
+ const snapped = snapPlacement(point, ui.getState().snapMode, ui.getState().gridSize, (x, z) => ground?.sampleHeight(x, z) ?? 0);
145
+ if (snapped === null)
146
+ return;
147
+ point = snapped;
138
148
  if (placement.tool === "path") {
139
149
  ui.pushDraftPoint(point);
140
150
  return;
@@ -201,11 +211,17 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
201
211
  if (visibility[path.kind] === false || path.hidden === true)
202
212
  continue;
203
213
  let best = null;
214
+ let previous = null;
204
215
  for (const point of path.points) {
205
- const distance = screenDistance(point, 0.8, clickX, clickY, rect);
206
- if (distance !== null && distance < SCREEN_PICK_RADIUS_PX && (best === null || distance < best)) {
207
- best = distance;
216
+ const projectedPoint = screenPoint(point, 0.8, rect);
217
+ if (projectedPoint !== null) {
218
+ const distance = previous === null
219
+ ? Math.hypot(projectedPoint.x - clickX, projectedPoint.y - clickY)
220
+ : pathSegmentDistance({ x: clickX, y: clickY }, previous, projectedPoint);
221
+ if (distance < SCREEN_PICK_RADIUS_PX && (best === null || distance < best))
222
+ best = distance;
208
223
  }
224
+ previous = projectedPoint;
209
225
  }
210
226
  if (best !== null)
211
227
  candidates.push({ id: path.id, distance: best });
@@ -410,7 +426,7 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
410
426
  });
411
427
  function resolveTarget(session, selection, pathPoint, pivot) {
412
428
  const { document } = session.getState();
413
- if (pathPoint !== null) {
429
+ if (pathPoint !== null && selection.length === 1 && selection[0] === pathPoint.pathId) {
414
430
  const path = findEditorPath(document, pathPoint.pathId);
415
431
  const point = path?.points[pathPoint.index];
416
432
  if (point !== undefined)
@@ -436,13 +452,6 @@ function resolveTarget(session, selection, pathPoint, pivot) {
436
452
  // Multi-select always translates as a group from the pivot; rotation/scale stay primary-kind limited.
437
453
  return { position: pivotPos, lift: primary.lift, rotationY: primary.rotationY, kind: primary.kind };
438
454
  }
439
- function effectiveMode(mode, kind) {
440
- if (kind === "marker")
441
- return mode === "scale" ? "translate" : mode;
442
- if (kind === "volume")
443
- return mode === "rotate" ? "translate" : mode;
444
- return "translate";
445
- }
446
455
  /**
447
456
  * Drag-to-transform gizmo bound to the current selection, dispatching editor commands on
448
457
  * release. Mounts the shared shell `TransformGizmo` and maps release poses onto editor
@@ -453,7 +462,7 @@ export const SelectionGizmo = memo(function SelectionGizmo({ session, ui, ground
453
462
  const state = session.getState();
454
463
  const selectedId = state.selection[0];
455
464
  const target = useMemo(() => resolveTarget(session, state.selection, uiState.pathPoint, uiState.gizmoPivot), [state, uiState.pathPoint, uiState.gizmoPivot, session]);
456
- const mode = target === null ? "translate" : effectiveMode(uiState.gizmoMode, target.kind);
465
+ const mode = target === null ? "translate" : selectionGizmoMode(uiState.gizmoMode, target.kind, target.kind === "pathPoint" ? 1 : state.selection.length);
457
466
  if (target === null)
458
467
  return null;
459
468
  const onRelease = (pose) => {
@@ -0,0 +1,9 @@
1
+ import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
2
+ import type { EditorSession } from "@jgengine/core/editor/commands";
3
+ /** Production authoring controls share the same validated command and undo history as RPC.
4
+ * @capability simulation-inspector author weather schedules, effects, fire, wind zones, forces and habitats with validated undoable controls
5
+ */
6
+ export declare function SimulationInspector({ session, creatorPolicy }: {
7
+ session: EditorSession;
8
+ creatorPolicy?: CreatorPolicy;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useStoreSelector } from "./useStoreSelector.js";
4
+ const modes = ["clear", "rain", "snow", "mixed", "dust"];
5
+ function NumberInput({ label, value, onChange, min = 0, step = 0.1 }) {
6
+ return _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { children: label }), _jsx("input", { "aria-label": label, className: "w-24 rounded border border-neutral-700 bg-neutral-900 p-1", type: "number", min: min, step: step, value: value, onChange: (event) => onChange(Number(event.target.value)) })] });
7
+ }
8
+ /** Production authoring controls share the same validated command and undo history as RPC.
9
+ * @capability simulation-inspector author weather schedules, effects, fire, wind zones, forces and habitats with validated undoable controls
10
+ */
11
+ export function SimulationInspector({ session, creatorPolicy }) {
12
+ const simulation = useStoreSelector(session, (state) => state.document.simulation);
13
+ const [error, setError] = useState(null);
14
+ const [advanced, setAdvanced] = useState(null);
15
+ const [species, setSpecies] = useState("");
16
+ const weather = simulation?.weather ?? {};
17
+ const allowWeather = creatorPolicy === undefined || (creatorPolicy.maxSimulationParticles ?? 0) >= 5556;
18
+ const allowEmitters = creatorPolicy === undefined || (creatorPolicy.maxSimulationParticles ?? 0) > 0;
19
+ const allowFire = creatorPolicy === undefined || (creatorPolicy.maxFireCells ?? 0) > 0;
20
+ const allowFlocks = creatorPolicy === undefined || (creatorPolicy.maxPopulation ?? 0) > 0;
21
+ const allowForces = creatorPolicy === undefined || (creatorPolicy.maxForceFields ?? 0) > 0;
22
+ function update(next) {
23
+ try {
24
+ session.dispatch({ type: "setSimulation", simulation: next });
25
+ setError(null);
26
+ }
27
+ catch (reason) {
28
+ setError(reason instanceof Error ? reason.message : String(reason));
29
+ }
30
+ }
31
+ function setWeather(next) { update({ ...simulation, weather: next }); }
32
+ function id(prefix, rows) {
33
+ let index = 1;
34
+ while (rows.some((row) => row.id === `${prefix}-${index}`))
35
+ index += 1;
36
+ return `${prefix}-${index}`;
37
+ }
38
+ return _jsxs("section", { "aria-label": "Simulation authoring", className: "space-y-4 p-3 text-xs text-neutral-300", children: [_jsx("h3", { className: "text-sm font-semibold text-neutral-100", children: "Weather & simulation" }), _jsx("p", { children: "Metres \u00B7 game seconds \u00B7 wind on world X/Z. Save stores authored inputs; play state saves separately." }), error !== null ? _jsx("p", { role: "alert", className: "text-red-300", children: error }) : null, _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Ambient weather" }), _jsxs("label", { className: "flex justify-between", children: ["Mode", _jsx("select", { "aria-label": "Weather mode", value: weather.ambient?.mode ?? "clear", onChange: (event) => setWeather({ ...weather, ambient: { ...weather.ambient, mode: event.target.value, intensity: weather.ambient?.intensity ?? 1 } }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) })] }), _jsx(NumberInput, { label: "Weather intensity", value: weather.ambient?.intensity ?? 0, onChange: (intensity) => setWeather({ ...weather, ambient: { mode: "clear", ...weather.ambient, intensity } }) }), _jsx(NumberInput, { label: "Temperature offset \u00B0C", min: -100, value: weather.ambient?.temperatureOffset ?? 0, onChange: (temperatureOffset) => setWeather({ ...weather, ambient: { mode: "clear", intensity: 0, ...weather.ambient, temperatureOffset } }) }), _jsx(NumberInput, { label: "Wind speed m/s", value: weather.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, wind: { ...weather.wind, speed } }) }), _jsx(NumberInput, { label: "Wind X direction", min: -1, value: weather.wind?.direction?.[0] ?? 1, onChange: (x) => setWeather({ ...weather, wind: { ...weather.wind, direction: [x, weather.wind?.direction?.[1] ?? 0] } }) }), _jsx(NumberInput, { label: "Wind Z direction", min: -1, value: weather.wind?.direction?.[1] ?? 0, onChange: (z) => setWeather({ ...weather, wind: { ...weather.wind, direction: [weather.wind?.direction?.[0] ?? 1, z] } }) })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Named profiles" }), (weather.profiles ?? []).map((profile, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("label", { children: ["Profile id", _jsx("input", { "aria-label": `Profile ${index + 1} id`, value: profile.id, onChange: (event) => {
39
+ const nextId = event.target.value;
40
+ setWeather({ ...weather, initialProfileId: weather.initialProfileId === profile.id ? nextId : weather.initialProfileId, profiles: weather.profiles.map((row, i) => i === index ? { ...row, id: nextId } : row), schedule: weather.schedule?.map((row) => row.profileId === profile.id ? { ...row, profileId: nextId } : row) });
41
+ } })] }), _jsx("select", { "aria-label": `Profile ${profile.id} mode`, value: profile.mode, onChange: (event) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, mode: event.target.value } : row) }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) }), _jsx(NumberInput, { label: `Profile ${profile.id} intensity`, value: profile.intensity, onChange: (intensity) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, intensity } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: weather.profiles.filter((_, i) => i !== index), initialProfileId: weather.initialProfileId === profile.id ? undefined : weather.initialProfileId, schedule: weather.schedule?.filter((row) => row.profileId !== profile.id) }), children: "Remove profile" })] }, profile.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: [...(weather.profiles ?? []), { id: id("profile", weather.profiles ?? []), mode: "rain", intensity: 0.5 }] }), children: "Add weather profile" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Schedule" }), (weather.schedule ?? []).map((transition, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("select", { "aria-label": `Transition ${index + 1} profile`, value: transition.profileId, onChange: (event) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, profileId: event.target.value } : row) }), children: weather.profiles?.map((profile) => _jsx("option", { children: profile.id }, profile.id)) }), _jsx(NumberInput, { label: `Transition ${index + 1} at seconds`, value: transition.atSeconds, onChange: (atSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, atSeconds } : row) }) }), _jsx(NumberInput, { label: `Transition ${index + 1} duration seconds`, value: transition.transitionSeconds ?? 0, onChange: (transitionSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, transitionSeconds } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, schedule: weather.schedule.filter((_, i) => i !== index) }), children: "Remove transition" })] }, index)), _jsx("button", { type: "button", disabled: !weather.profiles?.length, onClick: () => setWeather({ ...weather, schedule: [...(weather.schedule ?? []), { atSeconds: (weather.schedule?.at(-1)?.atSeconds ?? 0) + 10, profileId: weather.profiles[0].id, transitionSeconds: 2 }] }), children: "Add transition" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Local wind zones" }), (weather.zones ?? []).map((zone, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: zone.id }), _jsx(NumberInput, { label: `${zone.id} X metres`, min: -1e6, value: zone.center[0], onChange: (x) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [x, row.center[1]] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} Z metres`, min: -1e6, value: zone.center[1], onChange: (z) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [row.center[0], z] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} radius metres`, value: zone.radius, onChange: (radius) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radius } : row) }) }), _jsx(NumberInput, { label: `${zone.id} speed m/s`, value: zone.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, wind: { ...row.wind, speed } } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: zone.radial ?? false, onChange: (event) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radial: event.target.checked } : row) }) }), " Radial wind"] }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: weather.zones.filter((_, i) => i !== index) }), children: "Remove wind zone" })] }, zone.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: [...(weather.zones ?? []), { id: id("wind", weather.zones ?? []), center: [0, 0], radius: 10, falloff: 5, wind: { direction: [1, 0], speed: 3 } }] }), children: "Add wind zone" })] }), _jsxs("fieldset", { hidden: !allowForces, className: "space-y-2", children: [_jsx("legend", { children: "Spatial forces" }), (simulation?.forces ?? []).map((field, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [[0, 1, 2].map((axis) => _jsx(NumberInput, { label: `Force ${index + 1} ${["X", "Y", "Z"][axis]} metres`, min: -1e6, value: field.center[axis], onChange: (value) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, center: row.center.map((entry, j) => j === axis ? value : entry) } : row) }) }, axis)), field.shape.kind === "sphere" ? _jsx(NumberInput, { label: `Force ${index + 1} radius metres`, value: field.shape.radius, onChange: (radius) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, shape: { kind: "sphere", radius } } : row) }) }) : _jsx("span", { children: "Box bounds are available in advanced editing." }), _jsx(NumberInput, { label: `Force ${index + 1} attraction m/s²`, min: -1e6, value: field.strength, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, strength } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} vortex m/s²`, min: -1e6, value: field.vortex?.strength ?? 0, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], ...row.vortex, strength } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} lift m/s²`, min: -1e6, value: field.vortex?.lift ?? 0, onChange: (lift) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], strength: 0, ...row.vortex, lift } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} falloff`, value: field.attenuation ?? 1, onChange: (attenuation) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, attenuation } : row) }) }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: simulation.forces.filter((_, i) => i !== index) }), children: "Remove force" })] }, index)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: [...(simulation?.forces ?? []), { center: [0, 0, 0], shape: { kind: "sphere", radius: 10 }, strength: 0, attenuation: 1, vortex: { axis: [0, 1, 0], strength: 2, lift: 1 }, mask: 1 }] }), children: "Add spatial force" })] }), _jsxs("fieldset", { hidden: !allowEmitters, className: "space-y-2", children: [_jsx("legend", { children: "Named effects" }), (simulation?.emitters ?? []).map((emitter, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: emitter.id }), _jsx("select", { "aria-label": `${emitter.id} appearance`, value: emitter.options?.render?.shape ?? "point", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, render: { shape: event.target.value } } } : row) }), children: ["point", "streak", "flake", "flame", "smoke", "ribbon", "ripple"].map((shape) => _jsx("option", { children: shape }, shape)) }), _jsx(NumberInput, { label: `${emitter.id} particles/second`, value: emitter.config.rate ?? 0, onChange: (rate) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, rate } } : row) }) }), _jsx(NumberInput, { label: `${emitter.id} particle capacity`, step: 1, value: emitter.config.max ?? 512, onChange: (max) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, max } } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${emitter.id} ${axis.toUpperCase()} metres`, min: -1e6, value: emitter.position[axis], onChange: (value) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: emitter.options?.active !== false, onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, active: event.target.checked } } : row) }) }), " Spawning"] }), _jsxs("label", { children: ["Fire binding", _jsxs("select", { "aria-label": `${emitter.id} fire area`, value: emitter.fireAreaId ?? "", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, fireAreaId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Independent" }), simulation?.fires?.map((area) => _jsx("option", { children: area.id }, area.id))] })] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: simulation.emitters.filter((_, i) => i !== index) }), children: "Remove emitter" })] }, emitter.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: [...(simulation?.emitters ?? []), { id: id("emitter", simulation?.emitters ?? []), position: { x: 0, y: 1, z: 0 }, config: { rate: 8, max: 64, lifetime: { min: 1, max: 2 }, seed: "authored" }, options: { render: { shape: "smoke" } } }] }), children: "Add named emitter" })] }), _jsxs("fieldset", { hidden: !allowFire, className: "space-y-2", children: [_jsx("legend", { children: "Fire areas" }), (simulation?.fires ?? []).map((area, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: area.id }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${area.id} ${axis.toUpperCase()} metres`, min: -1e6, value: area.position[axis], onChange: (value) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsx(NumberInput, { label: `${area.id} cell size metres`, value: area.config.cellSize, onChange: (cellSize) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, cellSize } } : row) }) }), _jsx(NumberInput, { label: `${area.id} fuel consumed/second`, value: area.config.burnRate ?? 0.25, onChange: (burnRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, burnRate } } : row) }) }), _jsx(NumberInput, { label: `${area.id} rain cooling heat/second`, value: area.extinguishRate ?? 0, onChange: (extinguishRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, extinguishRate } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: (area.ignitions?.length ?? 0) > 0, onChange: (event) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, ignitions: event.target.checked ? [{ col: 0, row: 0 }] : [] } : row) }) }), " Ignite first cell on play"] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: simulation.fires.filter((_, i) => i !== index), emitters: simulation?.emitters?.map((emitter) => emitter.fireAreaId === area.id ? { ...emitter, fireAreaId: undefined } : emitter) }), children: "Remove fire area" })] }, area.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: [...(simulation?.fires ?? []), { id: id("fire", simulation?.fires ?? []), position: { x: 0, y: 0, z: 0 }, config: { cols: 4, rows: 4, cellSize: 1 }, ignitions: [] }] }), children: "Add fire area" })] }), _jsxs("fieldset", { hidden: !allowFlocks, className: "space-y-2", children: [_jsx("legend", { children: "Flock habitats" }), (simulation?.habitats ?? []).map((habitat, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("span", { children: [habitat.id, " \u00B7 ", habitat.species] }), _jsxs("select", { "aria-label": `${habitat.id} role`, value: habitat.role, onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, role: event.target.value } : row) }), children: [_jsx("option", { children: "cosmetic" }), _jsx("option", { children: "gameplay" })] }), _jsx(NumberInput, { label: `${habitat.id} bird count`, step: 1, value: habitat.count, onChange: (count) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, count } : row) }) }), _jsx(NumberInput, { label: `${habitat.id} radius metres`, value: habitat.radius, onChange: (radius) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, radius } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${habitat.id} ${axis.toUpperCase()} metres`, min: -1e6, value: habitat.position[axis], onChange: (value) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("select", { "aria-label": `${habitat.id} route`, value: habitat.routeId ?? "", onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, routeId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Stay in habitat" }), session.getState().document.paths.filter((path) => path.points.length > 1).map((path) => _jsx("option", { value: path.id, children: path.label ?? path.id }, path.id))] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, habitats: simulation.habitats.filter((_, i) => i !== index) }), children: "Remove habitat" })] }, habitat.id)), _jsxs("label", { children: ["Game species", _jsx("input", { "aria-label": "Game species", value: species, onChange: (event) => setSpecies(event.target.value) })] }), _jsx("button", { type: "button", disabled: !species.trim(), onClick: () => update({ ...simulation, habitats: [...(simulation?.habitats ?? []), { id: id("habitat", simulation?.habitats ?? []), species: species.trim(), position: { x: 0, y: 4, z: 0 }, radius: 8, count: 8, seed: "authored", role: "cosmetic", steering: { maxSpeed: 4, separationRadius: 1, neighborRadius: 5 } }] }), children: "Add flock habitat" })] }), _jsx("button", { type: "button", onClick: () => setAdvanced(JSON.stringify(simulation ?? {}, null, 2)), children: "Edit complete simulation data" }), advanced !== null ? _jsxs("div", { className: "space-y-2", children: [_jsx("textarea", { "aria-label": "Simulation JSON", className: "h-60 w-full bg-neutral-900 p-2 font-mono", value: advanced, onChange: (event) => setAdvanced(event.target.value) }), _jsx("button", { type: "button", onClick: () => { try {
42
+ update(JSON.parse(advanced));
43
+ }
44
+ catch (reason) {
45
+ setError(String(reason));
46
+ } }, children: "Apply simulation data" }), _jsx("button", { type: "button", onClick: () => setAdvanced(null), children: "Close data editor" })] }) : null] });
47
+ }
@@ -172,6 +172,7 @@ function unionRect(a, b) {
172
172
  }
173
173
  /** Memoized so cursor-hover state updates in the parent never reconcile the mesh subtree. */
174
174
  const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revision, pendingRef, paletteAt, heightRange, contourStrength, }) {
175
+ const invalidate = useThree((state) => state.invalidate);
175
176
  const geometry = useMemo(() => {
176
177
  const width = Math.max(1, bounds.maxX - bounds.minX);
177
178
  const depth = Math.max(1, bounds.maxZ - bounds.minZ);
@@ -188,8 +189,9 @@ const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revisio
188
189
  pendingRef.current = null;
189
190
  const region = pending !== null && pending.kind === "region" ? pending.region : null;
190
191
  displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength);
192
+ invalidate();
191
193
  // eslint-disable-next-line react-hooks/exhaustive-deps
192
- }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
194
+ }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength, invalidate]);
193
195
  return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
194
196
  });
195
197
  function selectSculptUi(state) {
@@ -10,6 +10,8 @@ const READ_ONLY_METHODS = new Set([
10
10
  "camera_goto",
11
11
  "camera_frame",
12
12
  "scene_summary",
13
+ "get_simulation",
14
+ "set_simulation",
13
15
  "export_document",
14
16
  "list_assets",
15
17
  "perf_report",
@@ -0,0 +1,21 @@
1
+ import type { EditorVec3 } from "@jgengine/core/editor/index";
2
+ import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ import { type TerraformSnapshot } from "@jgengine/core/world/terraform";
4
+ import type { GizmoMode, SnapMode } from "./uiStore.js";
5
+ /** Compose authored sculpt offsets over the same base field as the terrain preview. @internal */
6
+ export declare function placementGround(terrain: TerraformSnapshot | undefined, base: TerrainField | null): TerrainField | null;
7
+ /** Snap XZ and resample the shared ground field; reject a nonfinite surface height. @internal */
8
+ export declare function snapPlacement(point: EditorVec3, mode: SnapMode, gridSize: number, groundHeightAt: (x: number, z: number) => number): EditorVec3 | null;
9
+ /** Distance from a pointer to a projected path segment, including either endpoint. @internal */
10
+ export declare function pathSegmentDistance(pointer: {
11
+ x: number;
12
+ y: number;
13
+ }, a: {
14
+ x: number;
15
+ y: number;
16
+ }, b: {
17
+ x: number;
18
+ y: number;
19
+ }): number;
20
+ /** Group transforms use the translation command for every selected object. @internal */
21
+ export declare function selectionGizmoMode(mode: GizmoMode, kind: string, count: number): GizmoMode;
@@ -0,0 +1,34 @@
1
+ import { editableTerrainFromSnapshot } from "@jgengine/core/world/terraform";
2
+ /** Compose authored sculpt offsets over the same base field as the terrain preview. @internal */
3
+ export function placementGround(terrain, base) {
4
+ return terrain === undefined ? base : editableTerrainFromSnapshot(terrain, base ?? undefined);
5
+ }
6
+ /** Snap XZ and resample the shared ground field; reject a nonfinite surface height. @internal */
7
+ export function snapPlacement(point, mode, gridSize, groundHeightAt) {
8
+ if (mode !== "grid" || !Number.isFinite(gridSize) || gridSize <= 0)
9
+ return point;
10
+ const x = Math.round(point.x / gridSize) * gridSize;
11
+ const z = Math.round(point.z / gridSize) * gridSize;
12
+ if (!Number.isFinite(x) || !Number.isFinite(z))
13
+ return null;
14
+ const y = groundHeightAt(x, z);
15
+ return Number.isFinite(y) ? { x, y, z } : null;
16
+ }
17
+ /** Distance from a pointer to a projected path segment, including either endpoint. @internal */
18
+ export function pathSegmentDistance(pointer, a, b) {
19
+ const dx = b.x - a.x;
20
+ const dy = b.y - a.y;
21
+ const lengthSquared = dx * dx + dy * dy;
22
+ const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((pointer.x - a.x) * dx + (pointer.y - a.y) * dy) / lengthSquared));
23
+ return Math.hypot(pointer.x - a.x - t * dx, pointer.y - a.y - t * dy);
24
+ }
25
+ /** Group transforms use the translation command for every selected object. @internal */
26
+ export function selectionGizmoMode(mode, kind, count) {
27
+ if (count > 1)
28
+ return "translate";
29
+ if (kind === "marker")
30
+ return mode === "scale" ? "translate" : mode;
31
+ if (kind === "volume")
32
+ return mode === "rotate" ? "translate" : mode;
33
+ return "translate";
34
+ }
@@ -16,6 +16,7 @@ export interface HandlerContext {
16
16
  api: EditorHostApi;
17
17
  session: EditorSession;
18
18
  liveSync: DocumentLiveSync;
19
+ persistAssetUrls?: boolean;
19
20
  gameId: string;
20
21
  catalogDefinitions: readonly EditorCatalogDefinition[];
21
22
  catalogById: ReadonlyMap<string, EditorCatalogDefinition>;
@@ -1,3 +1,3 @@
1
1
  import type { HandlerTable } from "./context.js";
2
2
  /** Document, selection, camera, mode, asset placement, and status verbs. */
3
- export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "add_path" | "add_marker" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "assign_material" | "set_object_flags">;
3
+ export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "add_path" | "add_marker" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "get_simulation" | "set_simulation" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "list_material_assets" | "list_material_slots" | "upsert_material_asset" | "remove_material_asset" | "assign_material_asset" | "clear_material_assets" | "assign_material" | "set_object_flags">;
@@ -1,3 +1,4 @@
1
+ import { parseEditorMaterialAsset } from "@jgengine/core/editor/materialAuthoring";
1
2
  import { editorDocumentBounds, listEditorKinds, summarizeEditorSession, } from "@jgengine/core/editor/index";
2
3
  import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
3
4
  import { findSchemaPreset, getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
@@ -314,6 +315,11 @@ export const documentHandlers = {
314
315
  ctx.api.setFocusTarget(target);
315
316
  return { ok: true, result: { target, bounds } };
316
317
  },
318
+ get_simulation: (ctx) => ({ ok: true, result: { simulation: structuredClone(ctx.session.getState().document.simulation ?? {}) } }),
319
+ set_simulation: (ctx, request) => {
320
+ ctx.session.dispatch({ type: "setSimulation", simulation: request.simulation === null ? undefined : request.simulation });
321
+ return { ok: true, result: { simulation: structuredClone(ctx.session.getState().document.simulation ?? {}) } };
322
+ },
317
323
  scene_summary: (ctx) => ({
318
324
  ok: true,
319
325
  result: {
@@ -361,7 +367,7 @@ export const documentHandlers = {
361
367
  kind: request.kind,
362
368
  knownKind: asset?.kind,
363
369
  knownLabel: asset?.label,
364
- knownUrl: asset?.url,
370
+ knownUrl: ctx.persistAssetUrls === false ? undefined : asset?.url,
365
371
  });
366
372
  const placedMarker = toEditorMarker(placed);
367
373
  // URL-backed catalog models need a first-class catalogId so AuthoredObjects places the mesh;
@@ -411,6 +417,28 @@ export const documentHandlers = {
411
417
  });
412
418
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
413
419
  },
420
+ list_material_assets: (ctx) => ({ ok: true, result: { assets: ctx.session.getState().document.materialAssets ?? [], assignments: ctx.session.getState().document.markers.filter((marker) => marker.meta?.materialAssignments !== undefined).map((marker) => ({ id: marker.id, assignments: marker.meta.materialAssignments })) } }),
421
+ list_material_slots: (ctx, request) => {
422
+ const inventory = ctx.api.getMaterialSlots(request.id);
423
+ return inventory.status === "ready" ? { ok: true, result: inventory } : { ok: false, result: inventory, error: inventory.status === "loading" ? "Material slots are still loading." : inventory.reason };
424
+ },
425
+ upsert_material_asset: (ctx, request) => {
426
+ const asset = parseEditorMaterialAsset(request.asset);
427
+ ctx.session.dispatch({ type: "upsertMaterialAsset", asset }, request.coalesce === undefined ? undefined : { coalesce: request.coalesce });
428
+ return { ok: true, result: { asset } };
429
+ },
430
+ remove_material_asset: (ctx, request) => {
431
+ const result = ctx.session.transaction([{ type: "removeMaterialAsset", id: request.id }]);
432
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
433
+ },
434
+ assign_material_asset: (ctx, request) => {
435
+ const result = ctx.session.transaction([{ type: "assignMaterialAsset", ids: request.ids, materialId: request.materialId, selector: request.selector }]);
436
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
437
+ },
438
+ clear_material_assets: (ctx, request) => {
439
+ const result = ctx.session.transaction([{ type: "clearMaterialAssets", ids: request.ids }]);
440
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
441
+ },
414
442
  assign_material: (ctx, request) => {
415
443
  if (!documentHasAnyId(ctx.session.getState().document, request.ids)) {
416
444
  return { ok: false, error: "assign_material matched no objects" };
@@ -1,3 +1,3 @@
1
1
  import type { HandlerTable } from "./context.js";
2
2
  /** Parent/child hierarchy and prefab library verbs. */
3
- export declare const hierarchyHandlers: Pick<HandlerTable, "set_parent" | "hierarchy" | "list_prefabs" | "create_prefab" | "insert_prefab" | "detach_prefab_instance" | "delete_prefab">;
3
+ export declare const hierarchyHandlers: Pick<HandlerTable, "set_parent" | "hierarchy" | "list_prefabs" | "create_prefab" | "insert_prefab" | "detach_prefab_instance" | "delete_prefab" | "set_prefab_static_bake">;
@@ -45,6 +45,10 @@ export const hierarchyHandlers = {
45
45
  ctx.session.dispatch({ type: "detachPrefabInstance", instanceId: request.instanceId });
46
46
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
47
47
  },
48
+ set_prefab_static_bake: (ctx, request) => {
49
+ const result = ctx.session.transaction([{ type: "setPrefabStaticBake", prefabId: request.prefabId, bake: request.bake }]);
50
+ return result.ok ? { ok: true, result: findEditorPrefab(ctx.session.getState().document, request.prefabId) } : { ok: false, error: result.error };
51
+ },
48
52
  delete_prefab: (ctx, request) => {
49
53
  if (findEditorPrefab(ctx.session.getState().document, request.prefabId) === undefined) {
50
54
  return { ok: false, error: `prefab not found: ${request.prefabId}` };
@@ -11,8 +11,8 @@ export const runtimeHandlers = {
11
11
  if (patch.type !== "snapshot" && patch.type !== "commands") {
12
12
  return { ok: false, error: `unknown document patch type: ${String(patch.type)} (snapshot | commands)` };
13
13
  }
14
- if (typeof patch.baseRevision !== "number") {
15
- return { ok: false, error: "push_document_patch requires a numeric baseRevision" };
14
+ if (!Number.isSafeInteger(patch.baseRevision) || patch.baseRevision < 0) {
15
+ return { ok: false, error: "push_document_patch requires a nonnegative safe-integer baseRevision" };
16
16
  }
17
17
  if (!force && patch.baseRevision !== ctx.liveSync.getRevision()) {
18
18
  return { ok: false, error: `baseRevision mismatch: patch=${patch.baseRevision} current=${ctx.liveSync.getRevision()}` };
@@ -32,13 +32,10 @@ export const runtimeHandlers = {
32
32
  return { ok: false, error: "commands patch requires a commands array" };
33
33
  if (patch.commands.length === 0)
34
34
  return { ok: false, error: "commands patch is empty" };
35
- for (const command of patch.commands) {
36
- const { applied } = ctx.dispatchGuarded(command);
37
- if (!applied && command.type !== "select" && command.type !== "clearSelection") {
38
- return { ok: false, error: `${command.type} rejected while applying patch` };
39
- }
40
- }
41
- return { ok: true, result: { revision: ctx.liveSync.getRevision(), ...summarizeEditorSession(ctx.session.getState()) } };
35
+ const transaction = ctx.session.transaction(patch.commands);
36
+ if (!transaction.ok)
37
+ return { ok: false, error: transaction.error, result: { commandIndex: transaction.commandIndex, revision: ctx.liveSync.getRevision() } };
38
+ return { ok: true, result: { revision: ctx.liveSync.getRevision(), changed: transaction.changed, ...summarizeEditorSession(transaction.state) } };
42
39
  },
43
40
  pull_document_patches: (ctx, request) => ({
44
41
  ok: true,
@@ -60,14 +57,20 @@ export const runtimeHandlers = {
60
57
  });
61
58
  return { ok: true, result: delta };
62
59
  },
63
- pull_runtime_deltas: (ctx, request) => ({
64
- ok: true,
65
- result: {
66
- seq: ctx.liveSync.getRuntimeState().seq,
67
- deltas: ctx.liveSync.pullRuntimeDeltas(request.sinceSeq ?? 0),
68
- ...(request.includeSnapshot === true ? { snapshot: ctx.liveSync.getRuntimeState() } : {}),
69
- },
70
- }),
60
+ pull_runtime_deltas: (ctx, request) => {
61
+ const sinceSeq = request.sinceSeq ?? 0;
62
+ const snapshot = ctx.liveSync.getRuntimeState();
63
+ const deltas = ctx.liveSync.pullRuntimeDeltas(sinceSeq);
64
+ const historyGap = deltas.length > 0 && deltas[0].seq > sinceSeq + 1;
65
+ return {
66
+ ok: true,
67
+ result: {
68
+ seq: snapshot.seq,
69
+ deltas: historyGap ? [] : deltas,
70
+ ...(request.includeSnapshot === true || historyGap ? { snapshot } : {}),
71
+ },
72
+ };
73
+ },
71
74
  runtime_snapshot: (ctx) => ({ ok: true, result: ctx.liveSync.getRuntimeState() }),
72
75
  runtime_summary: (ctx) => ({
73
76
  ok: true,
@@ -1,6 +1,15 @@
1
1
  import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
2
2
  import { resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, SCATTER_PATH_KIND, } from "@jgengine/core/world/scatterRegion";
3
3
  import { TERRAIN_MATERIALS } from "../uiStore.js";
4
+ function brushError(request) {
5
+ if (![request.x, request.z, request.radius ?? 8, request.strength ?? 1].every(Number.isFinite))
6
+ return "brush coordinates, radius and strength must be finite";
7
+ if ((request.radius ?? 8) <= 0)
8
+ return "brush radius must be positive";
9
+ if ((request.strength ?? 1) < 0)
10
+ return "brush strength must be nonnegative";
11
+ return null;
12
+ }
4
13
  /** Sculpt heightfield, material painting, terrain layers, and foliage/scatter verbs. */
5
14
  export const terrainHandlers = {
6
15
  create_terrain: (ctx, request) => {
@@ -8,17 +17,35 @@ export const terrainHandlers = {
8
17
  const depth = request.depth ?? 200;
9
18
  const cx = request.centerX ?? 0;
10
19
  const cz = request.centerZ ?? 0;
20
+ const cellSize = request.cellSize ?? 2;
21
+ if (![width, depth, cx, cz, cellSize].every(Number.isFinite) || width <= 0 || depth <= 0 || cellSize <= 0) {
22
+ return { ok: false, error: "terrain dimensions and cellSize must be finite and positive; center must be finite" };
23
+ }
24
+ if ((Math.max(1, Math.round(width / cellSize)) + 1) * (Math.max(1, Math.round(depth / cellSize)) + 1) > 1_000_000) {
25
+ return { ok: false, error: "terrain exceeds 1,000,000 vertices — increase cellSize or reduce width/depth" };
26
+ }
27
+ const bounds = { minX: cx - width / 2, minZ: cz - depth / 2, maxX: cx + width / 2, maxZ: cz + depth / 2 };
28
+ if (!Object.values(bounds).every(Number.isFinite) || bounds.maxX <= bounds.minX || bounds.maxZ <= bounds.minZ) {
29
+ return { ok: false, error: "terrain bounds must have a finite, nonzero extent" };
30
+ }
11
31
  const terrain = createTerrainSnapshot({
12
- bounds: { minX: cx - width / 2, minZ: cz - depth / 2, maxX: cx + width / 2, maxZ: cz + depth / 2 },
13
- cellSize: request.cellSize ?? 2,
32
+ bounds,
33
+ cellSize,
14
34
  });
15
35
  ctx.session.dispatch({ type: "setTerrain", terrain });
16
36
  return { ok: true, result: { cols: terrain.cols, rows: terrain.rows, cellSize: terrain.cellSize } };
17
37
  },
18
38
  sculpt_terrain: (ctx, request) => {
39
+ const invalid = brushError(request);
40
+ if (invalid !== null)
41
+ return { ok: false, error: invalid };
19
42
  const terrain = ctx.session.getState().document.terrain;
20
43
  if (terrain === undefined)
21
44
  return { ok: false, error: "no terrain — call create_terrain first" };
45
+ if (request.mode === "ramp" && (request.toX === undefined || request.toZ === undefined))
46
+ return { ok: false, error: "ramp requires toX and toZ" };
47
+ if ([request.target, request.toX, request.toZ, request.seed].some((value) => value !== undefined && !Number.isFinite(value)))
48
+ return { ok: false, error: "sculpt target, ramp endpoint and seed must be finite" };
22
49
  const live = editableTerrainFromSnapshot(terrain);
23
50
  const edit = {
24
51
  mode: request.mode,
@@ -66,6 +93,9 @@ export const terrainHandlers = {
66
93
  };
67
94
  },
68
95
  paint_terrain: (ctx, request) => {
96
+ const invalid = brushError(request);
97
+ if (invalid !== null)
98
+ return { ok: false, error: invalid };
69
99
  const terrain = ctx.session.getState().document.terrain;
70
100
  if (terrain === undefined)
71
101
  return { ok: false, error: "no terrain — call create_terrain first" };
@@ -120,23 +150,31 @@ export const terrainHandlers = {
120
150
  if (ctx.session.getState().document.terrain === undefined) {
121
151
  return { ok: false, error: "no terrain — call create_terrain first" };
122
152
  }
153
+ if (new Set(request.layers.map((layer) => layer.id)).size !== request.layers.length || request.layers.some((layer) => layer.id.length === 0 || layer.surface.length === 0)) {
154
+ return { ok: false, error: "terrain layers require unique nonempty ids and nonempty surfaces" };
155
+ }
123
156
  ctx.session.dispatch({ type: "setTerrainLayers", layers: request.layers });
124
157
  return { ok: true, result: { layers: ctx.session.getState().document.terrain?.layers ?? [] } };
125
158
  },
126
159
  blend_terrain: (ctx, request) => {
160
+ const invalid = brushError(request);
161
+ if (invalid !== null)
162
+ return { ok: false, error: invalid };
127
163
  const terrain = ctx.session.getState().document.terrain;
128
164
  if (terrain === undefined)
129
165
  return { ok: false, error: "no terrain — call create_terrain first" };
130
- // Auto-add the surface as a layer if the stack does not carry it yet.
131
- const layers = terrain.layers ?? [];
132
- if (!layers.some((layer) => layer.surface === request.surface)) {
133
- const next = [...layers, { id: request.surface, surface: request.surface }];
134
- ctx.session.dispatch({ type: "setTerrainLayers", layers: next });
166
+ const live = editableTerrainFromSnapshot(terrain);
167
+ if ((request.strength ?? 1) > 1)
168
+ return { ok: false, error: "blend strength must be between 0 and 1" };
169
+ const addLayer = !live.layers.some((layer) => layer.surface === request.surface);
170
+ if (addLayer) {
171
+ let id = request.surface;
172
+ let suffix = 1;
173
+ while (live.layers.some((layer) => layer.id === id))
174
+ id = `${request.surface}_${suffix++}`;
175
+ const layers = [...live.layers, { id, surface: request.surface }];
176
+ live.setLayers(layers);
135
177
  }
136
- const snapshot = ctx.session.getState().document.terrain;
137
- if (snapshot === undefined)
138
- return { ok: false, error: "no terrain — call create_terrain first" };
139
- const live = editableTerrainFromSnapshot(snapshot);
140
178
  const delta = live.blendPaintDelta({
141
179
  mode: "paint",
142
180
  center: [request.x, request.z],
@@ -147,7 +185,9 @@ export const terrainHandlers = {
147
185
  });
148
186
  if (delta.indices.length === 0)
149
187
  return { ok: false, error: "blend touched no cells (check radius/position)" };
150
- ctx.session.dispatch({ type: "blendTerrain", delta });
188
+ ctx.session.dispatch(addLayer
189
+ ? { type: "setTerrain", terrain: live.snapshot() }
190
+ : { type: "blendTerrain", delta });
151
191
  return { ok: true, result: { changed: delta.indices.length, layers: ctx.session.getState().document.terrain?.layers ?? [] } };
152
192
  },
153
193
  convert_scatter: (ctx, request) => {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { EditorApp, type EditorAppProps, type EditorSaveFn } from "./EditorApp.js";
2
+ export { CreatorApp, type CreatorConfig } from "./CreatorApp.js";
2
3
  export { buildEditorNetworkSnapshot, isNetworkMultiplayerConfigured, type EditorNetworkAdapterKind, type EditorNetworkPresenceActor, type EditorNetworkPresenceInput, type EditorNetworkSession, type EditorNetworkSnapshot, } from "./networkSnapshot.js";
3
4
  export { StandaloneEditor, createBlankPlayable, blankWorld, downloadSaver, importAssetToHost, loadDroppedAssets, mergeStandaloneAssets, type StandaloneEditorProps, type StandaloneAsset, type AssetImporter, type BlankPlayableOptions, } from "./StandaloneEditor.js";
4
5
  export { EditorLayerOverlays, PathDraftPreview } from "./DebugDraw.js";
@@ -9,7 +10,7 @@ export { TerrainSculpt } from "./TerrainSculpt.js";
9
10
  export { ScatterPreview } from "./ScatterPreview.js";
10
11
  export { EditorCameraDriver } from "./EditorCameraDriver.js";
11
12
  export { AssetBrowser, assetsFromCatalog, editorAssetFromImport, mergeEditorAssets, type EditorAssetEntry, } from "./AssetBrowser.js";
12
- export { createEditorHost, getEditorHost, installEditorHost, type EditorAssetInfo, type EditorBridgeRequest, type EditorBridgeResponse, type EditorHostApi, type EditorPerfSample, type EditorRunMode, } from "./session.js";
13
+ export { createEditorHost, getEditorHost, installEditorHost, type EditorAssetInfo, type EditorBridgeRequest, type EditorBridgeResponse, type EditorHostApi, type EditorMaterialSlotInfo, type EditorMaterialSlotReport, type EditorMaterialSlotInventory, type EditorPerfSample, type EditorRunMode, } from "./session.js";
13
14
  export { PerfProbe } from "./PerfProbe.js";
14
15
  export { useStoreSelector, shallowArrayEqual, virtualWindow, type SubscribableStore, type VirtualWindow, } from "./useStoreSelector.js";
15
16
  export { useF2Chord } from "./useF2Chord.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { EditorApp } from "./EditorApp.js";
2
+ export { CreatorApp } from "./CreatorApp.js";
2
3
  export { buildEditorNetworkSnapshot, isNetworkMultiplayerConfigured, } from "./networkSnapshot.js";
3
4
  export { StandaloneEditor, createBlankPlayable, blankWorld, downloadSaver, importAssetToHost, loadDroppedAssets, mergeStandaloneAssets, } from "./StandaloneEditor.js";
4
5
  export { EditorLayerOverlays, PathDraftPreview } from "./DebugDraw.js";
@@ -0,0 +1,32 @@
1
+ import { type MaterialAsset, type MaterialFamily, type MaterialSurfaceParameters } from "@jgengine/core/material/materialAsset";
2
+ /** Prepare an explicit artist edit by retaining declarations and adding its shader requirements. @internal */
3
+ export declare function prepareMaterialAuthoringEdit(asset: MaterialAsset): MaterialAsset;
4
+ /** One physical surface field in the material workspace. */
5
+ export interface MaterialControl {
6
+ key: keyof MaterialSurfaceParameters;
7
+ label: string;
8
+ kind: "number" | "color";
9
+ fallback: number | string;
10
+ min?: number;
11
+ max?: number;
12
+ step?: number;
13
+ hint?: string;
14
+ }
15
+ /** A family-specific group of primary controls; maps and full physical fields remain available. */
16
+ export interface MaterialControlGroup {
17
+ label: string;
18
+ hint: string;
19
+ controls: readonly MaterialControl[];
20
+ }
21
+ /**
22
+ * Returns six primary groups suited to the material family without hiding the physical model.
23
+ * @capability editor-material-controls Group physical controls by surface family while retaining advanced fields.
24
+ */
25
+ export declare function materialControlGroups(family: MaterialFamily): readonly MaterialControlGroup[];
26
+ /** Complete supported physical scalar/color fields, independent of the six primary groups. */
27
+ export declare const advancedMaterialControls: readonly MaterialControl[];
28
+ /**
29
+ * Reports geometry prerequisites and renderer approximations to authors.
30
+ * @capability editor-material-controls Explain geometry prerequisites and approximations for a material asset.
31
+ */
32
+ export declare function materialAuthoringNotes(asset: MaterialAsset): readonly string[];