@jgengine/editor 0.19.0 → 0.22.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 (73) hide show
  1. package/CHANGELOG.md +364 -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 +12 -59
  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/uiStore.d.ts +1 -10
  61. package/dist/uiStore.js +1 -13
  62. package/dist/useDocumentSave.d.ts +20 -0
  63. package/dist/useDocumentSave.js +33 -0
  64. package/package.json +6 -5
  65. package/skills/ce-handoff/SKILL.md +1 -1
  66. package/skills/jgengine/SKILL.md +23 -2
  67. package/skills/jgengine/api.md +28 -17
  68. package/skills/jgengine/recipes/minimal-game.md +7 -2
  69. package/skills/jgengine-editor/SKILL.md +9 -1
  70. package/skills/jgengine-editor/api.md +111 -16
  71. package/skills/jgengine-editor/capabilities.md +40 -0
  72. package/skills/jgengine-editor/reference.md +149 -1
  73. 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
+ }
@@ -5,18 +5,14 @@ import * as THREE from "three";
5
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 { displaceHeightfieldGeometry, normalizeHeightBlend, TerraformBrushCursor, } from "@jgengine/shell/terrain";
8
+ import { displaceHeightfieldGeometry, normalizeHeightBlend, createTerrainPaintColorSampler, TerraformBrushCursor, } from "@jgengine/shell/terrain";
9
9
  import { editorPerfMarks } from "./perfMarks.js";
10
- import { TERRAIN_MATERIAL_COLORS, } from "./uiStore.js";
10
+ import {} from "./uiStore.js";
11
11
  import { useStoreSelector } from "./useStoreSelector.js";
12
12
  const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
13
13
  const LOW = new THREE.Color("#3f5d34");
14
14
  const HIGH = new THREE.Color("#8f9c66");
15
15
  const COLOR_SPAN = 24;
16
- /** Resolves a material layer to its render color (its tint, else its palette surface color). */
17
- function layerColor(layer) {
18
- return layer.tint ?? TERRAIN_MATERIAL_COLORS[layer.surface] ?? "#7c7f86";
19
- }
20
16
  /** The game's `environment({ terrain })` descriptor, when the world it declares carries one — the same descriptor `TerrainGround` samples at runtime via `createTerrainPaletteSampler`. */
21
17
  function terrainDescriptorOf(world) {
22
18
  return world?.kind === "environment" ? world.terrain : undefined;
@@ -65,28 +61,11 @@ function editFromSettings(settings, center, clickHeight) {
65
61
  ...(heightLimit === undefined ? {} : { heightLimit }),
66
62
  };
67
63
  }
68
- const BLEND_A = new THREE.Color();
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
- }
80
64
  /** World-unit spacing between topographic relief bands drawn on the sculpt preview. */
81
65
  const CONTOUR_INTERVAL = 1.5;
82
- /**
83
- * Topographic relief baked into a vertex's color so elevation reads from the editor's high camera,
84
- * where lighting alone leaves gentle hills invisible. Smooth cosine bands every `CONTOUR_INTERVAL`
85
- * darken slopes toward each band trough (a contour-map look) and higher ground brightens slightly —
86
- * a `multiply`, so painted grass/dirt/rock keep their hue. Flat ground at a band height (the common
87
- * unsculpted case at y=0) is left untouched: only varying height shows the bands. `strength` 0 is a
88
- * no-op.
89
- */
66
+ const BLEND_A = new THREE.Color();
67
+ const BLEND_B = new THREE.Color();
68
+ /** Multiplicative contour bands preserve painted hue while making elevation readable. */
90
69
  function applyRelief(out, height, strength) {
91
70
  if (strength <= 0)
92
71
  return;
@@ -102,37 +81,9 @@ function applyRelief(out, height, strength) {
102
81
  * default — so `materialRegions`/base `colors`/`material` (worn paths, rock outcrops, biome tint)
103
82
  * show through in the editor the same way `TerrainGround` renders them at runtime.
104
83
  */
105
- function toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors) {
106
- const layers = terrain.layers;
107
- if (layers.length > 0) {
108
- const weights = terrain.weightsAt(x, z);
109
- if (weights.length === layers.length) {
110
- let total = 0;
111
- let r = 0;
112
- let g = 0;
113
- let b = 0;
114
- for (let l = 0; l < layers.length; l += 1) {
115
- const w = weights[l] * (layers[l].opacity ?? 1);
116
- if (w <= 0)
117
- continue;
118
- const color = layerColors[l];
119
- r += color.r * w;
120
- g += color.g * w;
121
- b += color.b * w;
122
- total += w;
123
- }
124
- if (total > 0) {
125
- out.setRGB(r / total, g / total, b / total);
126
- return;
127
- }
128
- }
129
- }
130
- const surface = terrain.surfaceAt(x, z);
131
- const painted = surface === null ? undefined : TERRAIN_MATERIAL_COLORS[surface];
132
- if (painted !== undefined) {
133
- out.copy(surfaceColor(painted));
84
+ function toneAt(x, z, height, out, paletteAt, heightRange, paintColorAt) {
85
+ if (paintColorAt(x, z, out))
134
86
  return;
135
- }
136
87
  if (paletteAt !== null) {
137
88
  const palette = paletteAt(x, z);
138
89
  const blend = normalizeHeightBlend(height, heightRange[0], heightRange[1]);
@@ -151,12 +102,12 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors)
151
102
  */
152
103
  function displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength) {
153
104
  const started = performance.now();
154
- const layerColors = terrain.layers.map((layer) => surfaceColor(layerColor(layer)));
105
+ const paintColorAt = createTerrainPaintColorSampler(terrain);
155
106
  displaceHeightfieldGeometry(geometry, terrain.sampleHeight, {
156
107
  bounds,
157
108
  region,
158
109
  color: (x, z, height, out) => {
159
- toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors);
110
+ toneAt(x, z, height, out, paletteAt, heightRange, paintColorAt);
160
111
  applyRelief(out, height, contourStrength);
161
112
  },
162
113
  });
@@ -172,6 +123,7 @@ function unionRect(a, b) {
172
123
  }
173
124
  /** Memoized so cursor-hover state updates in the parent never reconcile the mesh subtree. */
174
125
  const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revision, pendingRef, paletteAt, heightRange, contourStrength, }) {
126
+ const invalidate = useThree((state) => state.invalidate);
175
127
  const geometry = useMemo(() => {
176
128
  const width = Math.max(1, bounds.maxX - bounds.minX);
177
129
  const depth = Math.max(1, bounds.maxZ - bounds.minZ);
@@ -188,8 +140,9 @@ const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revisio
188
140
  pendingRef.current = null;
189
141
  const region = pending !== null && pending.kind === "region" ? pending.region : null;
190
142
  displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength);
143
+ invalidate();
191
144
  // eslint-disable-next-line react-hooks/exhaustive-deps
192
- }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
145
+ }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength, invalidate]);
193
146
  return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
194
147
  });
195
148
  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,