@jgengine/editor 0.14.0 → 0.15.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 (39) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/EditorApp.js +2 -2
  3. package/dist/EditorCameraDriver.d.ts +6 -1
  4. package/dist/EditorCameraDriver.js +25 -1
  5. package/dist/EditorChrome.js +2 -4
  6. package/dist/InspectorPanel.js +12 -1
  7. package/dist/ParentPickerMenu.js +2 -6
  8. package/dist/RuntimePlayBridge.js +10 -15
  9. package/dist/SchemaInspector.js +55 -29
  10. package/dist/TerrainPanel.js +3 -4
  11. package/dist/TerrainReadout.js +1 -3
  12. package/dist/agent/AgentPanel.js +7 -11
  13. package/dist/camera/orbitFraming.d.ts +62 -0
  14. package/dist/camera/orbitFraming.js +69 -0
  15. package/dist/chromeFields.js +9 -4
  16. package/dist/handlers/document.d.ts +1 -1
  17. package/dist/handlers/document.js +78 -9
  18. package/dist/mcp/fieldSpec.js +4 -2
  19. package/dist/mcp/tools.js +4 -2
  20. package/dist/session.d.ts +54 -15
  21. package/dist/shell/AnimationPanel.js +14 -20
  22. package/dist/shell/CommandPalette.js +1 -5
  23. package/dist/shell/HierarchyPanel.js +17 -26
  24. package/dist/shell/PlayModeBar.d.ts +7 -0
  25. package/dist/shell/PlayModeBar.js +12 -3
  26. package/dist/shell/SceneToolbar.js +9 -5
  27. package/dist/shell/fields.js +8 -8
  28. package/dist/shell/glbThumbnailCache.js +39 -8
  29. package/dist/shell/scatterCoverage.js +10 -1
  30. package/dist/useStoreSelector.d.ts +12 -0
  31. package/dist/useStoreSelector.js +23 -0
  32. package/package.json +5 -5
  33. package/skills/ce-handoff/SKILL.md +149 -0
  34. package/skills/jgengine/SKILL.md +3 -1
  35. package/skills/jgengine/api.md +17 -7
  36. package/skills/jgengine/capabilities.md +12 -0
  37. package/skills/jgengine/recipes/minimal-game.md +7 -4
  38. package/skills/jgengine-editor/api.md +16 -3
  39. package/skills/jgengine-editor/reference.md +44 -0
package/dist/EditorApp.js CHANGED
@@ -261,7 +261,6 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
261
261
  if (readoutStoreRef.current === null)
262
262
  readoutStoreRef.current = createTerrainReadoutStore();
263
263
  const readout = readoutStoreRef.current;
264
- const [mode, setModeState] = useState("edit");
265
264
  const catalogAssets = useMemo(() => {
266
265
  try {
267
266
  const ids = playable.game.assets.ids();
@@ -286,7 +285,8 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
286
285
  };
287
286
  }, [gameId, resolvedLayers, catalogs, catalogAssets]);
288
287
  useEffect(() => host.dispose, [host]);
289
- useEffect(() => host.api.subscribeMode(setModeState), [host]);
288
+ const modeStore = useMemo(() => ({ getState: host.api.getMode, subscribe: host.api.subscribeMode }), [host]);
289
+ const mode = useStoreSelector(modeStore, (m) => m);
290
290
  const [pendingDraft, setPendingDraft] = useState(null);
291
291
  useEffect(() => {
292
292
  const draft = readDraft(gameId);
@@ -1,5 +1,10 @@
1
1
  import type { EditorHostApi } from "./session.js";
2
- /** Smoothly pans the orbit camera to the editor host's focus target when it changes. */
2
+ /**
3
+ * Drives the editor orbit camera from the host's focus target. When the target carries only a
4
+ * point, the camera pans to keep it centered (the historical behavior). When it also carries
5
+ * placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
6
+ * how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
7
+ */
3
8
  export declare const EditorCameraDriver: import("react").MemoExoticComponent<({ api }: {
4
9
  api: EditorHostApi;
5
10
  }) => null>;
@@ -1,6 +1,12 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { memo, useEffect } from "react";
3
- /** Smoothly pans the orbit camera to the editor host's focus target when it changes. */
3
+ import { orbitCameraPosition } from "./camera/orbitFraming.js";
4
+ /**
5
+ * Drives the editor orbit camera from the host's focus target. When the target carries only a
6
+ * point, the camera pans to keep it centered (the historical behavior). When it also carries
7
+ * placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
8
+ * how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
9
+ */
4
10
  export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
5
11
  const controls = useThree((state) => state.controls);
6
12
  const camera = useThree((state) => state.camera);
@@ -8,6 +14,24 @@ export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
8
14
  return api.subscribeFocus((target) => {
9
15
  if (target === null || controls === null || controls.target === undefined)
10
16
  return;
17
+ const hasPlacement = target.distance !== undefined ||
18
+ target.pitch !== undefined ||
19
+ target.yaw !== undefined ||
20
+ target.height !== undefined;
21
+ if (hasPlacement) {
22
+ controls.target.set(target.x, target.y, target.z);
23
+ const distance = target.distance ?? camera.position.distanceTo(controls.target);
24
+ const pose = orbitCameraPosition({
25
+ target,
26
+ distance,
27
+ pitchDeg: target.pitch ?? 45,
28
+ yawDeg: target.yaw,
29
+ height: target.height,
30
+ });
31
+ camera.position.set(pose.x, pose.y, pose.z);
32
+ controls.update();
33
+ return;
34
+ }
11
35
  const previous = controls.target.clone();
12
36
  controls.target.set(target.x, target.y, target.z);
13
37
  camera.position.add(controls.target.clone().sub(previous));
@@ -15,6 +15,7 @@ import { buildOutlinerGroups } from "./outlinerModel.js";
15
15
  import { importAssetToHost, loadDroppedAssets, } from "./assetImport.js";
16
16
  import { newPlacementId } from "./uiStore.js";
17
17
  import { useF2Chord } from "./useF2Chord.js";
18
+ import { useStoreVersion } from "./useStoreSelector.js";
18
19
  import { TerrainPanel } from "./TerrainPanel.js";
19
20
  import { LightingPanel } from "./LightingPanel.js";
20
21
  import { InspectorPanel } from "./InspectorPanel.js";
@@ -112,10 +113,10 @@ const LEFT_PAGES = [
112
113
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
113
114
  */
114
115
  export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
115
- const [, setTick] = useState(0);
116
116
  const layoutRef = useRef(null);
117
117
  layoutRef.current ??= createShellLayoutStore(gameId);
118
118
  const layout = layoutRef.current;
119
+ useStoreVersion(session, ui, layout);
119
120
  const consoleRef = useRef(null);
120
121
  consoleRef.current ??= createEditorConsoleStore();
121
122
  const consoleStore = consoleRef.current;
@@ -171,9 +172,6 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
171
172
  useF2Chord("KeyE", () => api.setMode("play"));
172
173
  // F2+Q leaves the editor entirely (back to the game) — only meaningful when summoned over one.
173
174
  useF2Chord("KeyQ", useCallback(() => onExitEditor?.(), [onExitEditor]));
174
- useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
175
- useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
176
- useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
177
175
  const copySelection = useCallback(() => {
178
176
  const current = session.getState();
179
177
  if (current.selection.length === 0)
@@ -6,6 +6,7 @@ import { getSceneKind, isSceneKind, parseParams, } from "@jgengine/core/scene/sc
6
6
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
7
7
  import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
8
8
  import { useGameContext } from "@jgengine/react/provider";
9
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
9
10
  import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatch, defaultTriggerInstallPatch, hasAuthoredTrigger, hasMaterialAssignment, } from "./authoredComponentMeta.js";
10
11
  import { SchemaInspector } from "./SchemaInspector.js";
11
12
  import { TriggerInspector } from "./TriggerInspector.js";
@@ -21,6 +22,16 @@ import { Icon, kindIcon } from "./shell/icons.js";
21
22
  import { CoverageSection } from "./shell/scatterCoverage.js";
22
23
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
23
24
  import { CollapsibleSection, EmptyState, IconButton, PanelTabs } from "./shell/ui.js";
25
+ /** Live-mirrored, debounced density slider — grass regen only fires on pause/release, not per step (#1372). */
26
+ function VegetationDensitySlider({ density, sliderMax, onMeta, }) {
27
+ const { value: local, onInput, flush } = useDebouncedCommit(density, (next) => onMeta({ density: next }, "veg:density"));
28
+ return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: local.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(local, sliderMax), onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
29
+ }
30
+ /** Live-mirrored, debounced item/seed text — avoids a scene patch (and grass regen) per keystroke (#1372). */
31
+ function VegetationTextField({ label, field, value, placeholder, onMeta, }) {
32
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field]: next }, `veg:${field}`));
33
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { className: `w-32 ${INPUT}`, value: local, placeholder: placeholder, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
34
+ }
24
35
  function VegetationFields({ volume, onMeta, }) {
25
36
  const settings = readVegetationSettings(volume);
26
37
  if (settings === null)
@@ -29,7 +40,7 @@ function VegetationFields({ volume, onMeta, }) {
29
40
  const areaM2 = (footprint.maxX - footprint.minX) * (footprint.maxZ - footprint.minZ);
30
41
  const sliderMax = settings.item === "grass" ? 12 : 1;
31
42
  const estimated = Math.floor(areaM2 * settings.density);
32
- return (_jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "item" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.item, placeholder: "grass", onChange: (event) => onMeta({ item: event.target.value }, "veg:item") })] }), _jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: settings.density.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(settings.density, sliderMax), onChange: (event) => onMeta({ density: Number(event.target.value) }, "veg:density") })] }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "seed" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.seed, placeholder: "reroll\u2026", onChange: (event) => onMeta({ seed: event.target.value }, "veg:seed") })] }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
43
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(VegetationTextField, { label: "item", field: "item", value: settings.item, placeholder: "grass", onMeta: onMeta }), _jsx(VegetationDensitySlider, { density: settings.density, sliderMax: sliderMax, onMeta: onMeta }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsx(VegetationTextField, { label: "seed", field: "seed", value: settings.seed, placeholder: "reroll\u2026", onMeta: onMeta }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
33
44
  }
34
45
  /** Builds the resolver-facing view of a document object for a registered scene kind's inspector. */
35
46
  function markerObject(marker) {
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
4
4
  /**
5
5
  * Searchable parent-target menu for hierarchy / viewport "Parent to…".
@@ -8,17 +8,13 @@ import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
8
8
  */
9
9
  export function ParentPickerMenu({ x, y, candidates, onPick, onClose, }) {
10
10
  const [query, setQuery] = useState("");
11
- const inputRef = useRef(null);
12
- useEffect(() => {
13
- inputRef.current?.focus();
14
- }, []);
15
11
  const filtered = useMemo(() => {
16
12
  const q = query.trim().toLowerCase();
17
13
  if (q.length === 0)
18
14
  return candidates;
19
15
  return candidates.filter((entry) => entry.label.toLowerCase().includes(q) || entry.id.toLowerCase().includes(q));
20
16
  }, [candidates, query]);
21
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", { ref: inputRef, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
17
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", { autoFocus: true, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
22
18
  if (event.key === "Escape") {
23
19
  event.preventDefault();
24
20
  onClose();
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { useFrame } from "@react-three/fiber";
4
4
  import { useGameContext } from "@jgengine/react/provider";
5
5
  import { MICRO } from "./chromeStyles.js";
6
- import { PlayModeBar } from "./shell/PlayModeBar.js";
6
+ import { PlayModeBar, usePlayControl } from "./shell/PlayModeBar.js";
7
7
  const PUBLISH_MS = 100;
8
8
  function metaValues(meta) {
9
9
  if (meta === null || typeof meta !== "object" || Array.isArray(meta))
@@ -83,11 +83,9 @@ export function RuntimePlayPublisher({ api }) {
83
83
  * @internal
84
84
  */
85
85
  export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
86
- const [play, setPlay] = useState(() => api.getPlayControl());
86
+ const play = usePlayControl(api);
87
87
  const [summary, setSummary] = useState(null);
88
88
  const [selectedId, setSelectedId] = useState(null);
89
- const [detail, setDetail] = useState("");
90
- useEffect(() => api.subscribePlayControl(setPlay), [api]);
91
89
  useEffect(() => {
92
90
  const tick = () => {
93
91
  const result = api.handle({ method: "runtime_summary" });
@@ -99,17 +97,14 @@ export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
99
97
  const timer = setInterval(tick, PUBLISH_MS);
100
98
  return () => clearInterval(timer);
101
99
  }, [api]);
102
- useEffect(() => {
103
- if (selectedId === null) {
104
- setDetail("");
105
- return;
106
- }
100
+ const detail = useMemo(() => {
101
+ if (selectedId === null)
102
+ return "";
107
103
  const got = api.handle({ method: "runtime_get", id: selectedId });
108
- if (!got.ok) {
109
- setDetail(got.error ?? "missing");
110
- return;
111
- }
112
- setDetail(JSON.stringify(got.result, null, 2));
104
+ if (!got.ok)
105
+ return got.error ?? "missing";
106
+ return JSON.stringify(got.result, null, 2);
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- entityCount/play are poll-tick cache keys
113
108
  }, [api, selectedId, summary?.entityCount, play.pendingSteps, play.paused]);
114
109
  const nudgeSelected = (axis, delta) => {
115
110
  if (selectedId === null)
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { parseParams, randomizeGroupParams, resetGroupParams, } from "@jgengine/core/scene/sceneKinds";
4
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
4
5
  import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
5
6
  const INPUT = `px-2 py-1 ${INPUT_CLS}`;
6
7
  const MICRO = MICRO_LABEL;
@@ -19,22 +20,56 @@ function PresetRow({ schema, onMeta }) {
19
20
  function labelOf(field) {
20
21
  return field.label ?? field.key;
21
22
  }
23
+ /** Range field: live-mirrored thumb + readout, debounced commit, flush on release/blur/keyup (#1372). */
24
+ function RangeField({ field, value, coalesce, onMeta, }) {
25
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
26
+ return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsxs("span", { className: "text-cyan-200", children: [local.toFixed(field.step !== undefined && field.step >= 1 ? 0 : 2), field.unit ?? ""] })] }), _jsx("input", { type: "range", min: field.min, max: field.max, step: field.step ?? (field.max - field.min) / 100, value: local, className: "w-full accent-emerald-400", onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
27
+ }
28
+ /** Number field: debounced commit, flush on blur (#1372). */
29
+ function NumberInputField({ field, value, coalesce, onMeta, }) {
30
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
31
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "number", step: field.step ?? 1, className: `w-32 ${INPUT}`, value: local, onChange: (event) => {
32
+ const next = Number(event.target.value);
33
+ if (Number.isFinite(next))
34
+ onInput(next);
35
+ }, onBlur: flush })] }));
36
+ }
37
+ /** Color field: debounced commit while the picker drags, flush on blur (#1372). */
38
+ function ColorField({ field, value, coalesce, onMeta, }) {
39
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
40
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "color", className: `h-7 w-14 cursor-pointer rounded-[5px] border border-white/10 bg-black/40 ${FOCUS_RING}`, value: local, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
41
+ }
42
+ /** Text field: debounced commit while typing, flush on blur (#1372). */
43
+ function TextField({ field, value, coalesce, onMeta, }) {
44
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
45
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-32 min-w-0 ${INPUT}`, value: local, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
46
+ }
47
+ /** Seed field: debounced text commit while typing (reroll button commits immediately) (#1372). */
48
+ function SeedField({ field, value, coalesce, onMeta, }) {
49
+ const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
50
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-full min-w-0 ${INPUT}`, value: local, placeholder: "reroll\u2026", onChange: (event) => onInput(event.target.value), onBlur: flush })] }), _jsx("button", { type: "button", className: BTN, title: "Reroll seed", onClick: () => {
51
+ flush();
52
+ onMeta({ [field.key]: `r${Math.abs(Math.round((local.length + 1) * 2654435761)).toString(36).slice(0, 6)}${local.length}` }, coalesce);
53
+ }, children: "\u27F3" })] }));
54
+ }
55
+ /** Weighted-list field: debounced item/weight commits share one mirrored array; add/remove commit now (#1372). */
56
+ function WeightedListField({ field, value, coalesce, onMeta, }) {
57
+ const { value: list, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
58
+ // Add/remove are discrete structural edits — commit them immediately through the same mirror.
59
+ const setNow = (next) => {
60
+ onInput(next);
61
+ flush();
62
+ };
63
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: MICRO, children: labelOf(field) }), list.map((entry, index) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: `min-w-0 flex-1 ${INPUT}`, value: entry.item, placeholder: field.itemLabel ?? "id", onChange: (event) => onInput(list.map((e, i) => (i === index ? { ...e, item: event.target.value } : e))), onBlur: flush }), _jsx("input", { type: "number", step: 1, min: 0, className: `w-14 ${INPUT}`, value: entry.weight, onChange: (event) => onInput(list.map((e, i) => (i === index ? { ...e, weight: Math.max(0, Number(event.target.value)) } : e))), onBlur: flush }), _jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-1 text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200 disabled:opacity-40`, disabled: list.length <= 1, onClick: () => setNow(list.filter((_, i) => i !== index)), children: "\u00D7" })] }, index))), _jsx("button", { type: "button", className: `w-full ${BTN}`, onClick: () => setNow([...list, { item: "", weight: 1 }]), children: "+ row" })] }));
64
+ }
22
65
  function FieldRow({ field, meta, onMeta }) {
23
66
  const params = parseParams({ fields: [field] }, meta);
24
67
  const coalesce = `kind:${field.key}`;
25
68
  switch (field.type) {
26
- case "range": {
27
- const value = params[field.key];
28
- return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsxs("span", { className: "text-cyan-200", children: [value.toFixed(field.step !== undefined && field.step >= 1 ? 0 : 2), field.unit ?? ""] })] }), _jsx("input", { type: "range", min: field.min, max: field.max, step: field.step ?? (field.max - field.min) / 100, value: value, className: "w-full accent-emerald-400", onChange: (event) => onMeta({ [field.key]: Number(event.target.value) }, coalesce) })] }));
29
- }
30
- case "number": {
31
- const value = params[field.key];
32
- return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "number", step: field.step ?? 1, className: `w-32 ${INPUT}`, value: value, onChange: (event) => {
33
- const next = Number(event.target.value);
34
- if (Number.isFinite(next))
35
- onMeta({ [field.key]: next }, coalesce);
36
- } })] }));
37
- }
69
+ case "range":
70
+ return _jsx(RangeField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
71
+ case "number":
72
+ return _jsx(NumberInputField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
38
73
  case "bool": {
39
74
  const value = params[field.key];
40
75
  return (_jsxs("label", { className: "flex items-center gap-1.5 text-[10px] text-neutral-400", children: [_jsx("input", { type: "checkbox", className: "accent-emerald-400", checked: value, onChange: (event) => onMeta({ [field.key]: event.target.checked }, coalesce) }), labelOf(field)] }));
@@ -43,23 +78,14 @@ function FieldRow({ field, meta, onMeta }) {
43
78
  const value = params[field.key];
44
79
  return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("select", { className: `w-32 ${INPUT}`, value: value, onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce), children: field.options.map((option) => (_jsx("option", { value: option.value, children: option.label ?? option.value }, option.value))) })] }));
45
80
  }
46
- case "color": {
47
- const value = params[field.key];
48
- return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "color", className: `h-7 w-14 cursor-pointer rounded-[5px] border border-white/10 bg-black/40 ${FOCUS_RING}`, value: value, onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce) })] }));
49
- }
50
- case "text": {
51
- const value = params[field.key];
52
- return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-32 min-w-0 ${INPUT}`, value: value, onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce) })] }));
53
- }
54
- case "seed": {
55
- const value = params[field.key];
56
- return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-full min-w-0 ${INPUT}`, value: value, placeholder: "reroll\u2026", onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce) })] }), _jsx("button", { type: "button", className: BTN, title: "Reroll seed", onClick: () => onMeta({ [field.key]: `r${Math.abs(Math.round((value.length + 1) * 2654435761)).toString(36).slice(0, 6)}${value.length}` }, coalesce), children: "\u27F3" })] }));
57
- }
58
- case "weightedList": {
59
- const list = params[field.key];
60
- const set = (next) => onMeta({ [field.key]: next }, coalesce);
61
- return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: MICRO, children: labelOf(field) }), list.map((entry, index) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: `min-w-0 flex-1 ${INPUT}`, value: entry.item, placeholder: field.itemLabel ?? "id", onChange: (event) => set(list.map((e, i) => (i === index ? { ...e, item: event.target.value } : e))) }), _jsx("input", { type: "number", step: 1, min: 0, className: `w-14 ${INPUT}`, value: entry.weight, onChange: (event) => set(list.map((e, i) => (i === index ? { ...e, weight: Math.max(0, Number(event.target.value)) } : e))) }), _jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-1 text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200 disabled:opacity-40`, disabled: list.length <= 1, onClick: () => set(list.filter((_, i) => i !== index)), children: "\u00D7" })] }, index))), _jsx("button", { type: "button", className: `w-full ${BTN}`, onClick: () => set([...list, { item: "", weight: 1 }]), children: "+ row" })] }));
62
- }
81
+ case "color":
82
+ return _jsx(ColorField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
83
+ case "text":
84
+ return _jsx(TextField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
85
+ case "seed":
86
+ return _jsx(SeedField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
87
+ case "weightedList":
88
+ return _jsx(WeightedListField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
63
89
  case "action":
64
90
  return null; // Action buttons are rendered by the group section, which owns the whole schema.
65
91
  }
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { useState } from "react";
3
3
  import { editorDocumentBounds } from "@jgengine/core/editor/index";
4
4
  import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
5
5
  import { TERRAIN_MATERIALS } from "./uiStore.js";
6
6
  import { SliderRow } from "./chromeFields.js";
7
+ import { useStoreVersion } from "./useStoreSelector.js";
7
8
  import { BORDER, CONTROL, CONTROL_ACTIVE, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
8
9
  const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
9
10
  const BTN_SM = `${CONTROL} px-2 py-0.5 text-[11px]`;
@@ -49,10 +50,8 @@ function PaintControls({ session, ui }) {
49
50
  }
50
51
  /** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
51
52
  export function TerrainPanel({ session, ui, api }) {
52
- const [, setTick] = useState(0);
53
53
  const [bakeStatus, setBakeStatus] = useState(null);
54
- useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
55
- useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
54
+ useStoreVersion(ui, session);
56
55
  const uiState = ui.getState();
57
56
  const document = session.getState().document;
58
57
  const hasTerrain = document.terrain !== undefined;
@@ -115,9 +115,7 @@ export function TerrainReadout({ groundHeightAt, region, showContours, showSurfa
115
115
  const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
116
116
  const hitRef = useRef(new THREE.Vector3());
117
117
  const accum = useRef(0);
118
- useEffect(() => {
119
- planeRef.current.constant = -(guides.summary.mean ?? 0);
120
- }, [guides.summary]);
118
+ planeRef.current.constant = -(guides.summary.mean ?? 0);
121
119
  useFrame((_, delta) => {
122
120
  accum.current += delta;
123
121
  if (accum.current < CURSOR_SAMPLE_INTERVAL)
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useStoreVersion } from "../useStoreSelector.js";
3
4
  import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL, PANEL_BG } from "../shell/theme.js";
4
5
  const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
5
6
  const INPUT = `px-2 py-1 ${INPUT_CLS}`;
@@ -44,17 +45,12 @@ export function AgentPanel({ api, onClose, embedded = false, }) {
44
45
  const [contextTick, setContextTick] = useState(0);
45
46
  const listRef = useRef(null);
46
47
  const endpoint = useMemo(() => buildEndpoint(url, apiKey), [url, apiKey]);
47
- const context = useMemo(() => packAgentContext(api), [api, contextTick]);
48
- useEffect(() => {
49
- const unsubSession = api.getSession().subscribe(() => setContextTick((v) => v + 1));
50
- const unsubMode = api.subscribeMode(() => setContextTick((v) => v + 1));
51
- const unsubFocus = api.subscribeFocus(() => setContextTick((v) => v + 1));
52
- return () => {
53
- unsubSession();
54
- unsubMode();
55
- unsubFocus();
56
- };
57
- }, [api]);
48
+ // Coarse invalidation of the packed context: any session/mode/focus change (or a finished agent
49
+ // turn, via contextTick) rebuilds the memo.
50
+ const modeSource = useMemo(() => ({ subscribe: api.subscribeMode }), [api]);
51
+ const focusSource = useMemo(() => ({ subscribe: api.subscribeFocus }), [api]);
52
+ const contextVersion = useStoreVersion(api.getSession(), modeSource, focusSource);
53
+ const context = useMemo(() => packAgentContext(api), [api, contextTick, contextVersion]);
58
54
  useEffect(() => {
59
55
  const el = listRef.current;
60
56
  if (el !== null)
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure aerial-framing math for the editor orbit camera. `camera_goto`/`camera_frame` used to only
3
+ * pan the orbit *target*, leaving distance and pitch wherever the artist last dragged them — so a
4
+ * `KeyF` frame could bury the camera inside terrain or a building and composing a clean district
5
+ * aerial took many guess-the-y-offset round-trips. These helpers turn an explicit
6
+ * distance + pitch (+ optional yaw / height) into a camera position, and derive a fit-to-region
7
+ * distance from document bounds so one call frames a whole district from above.
8
+ *
9
+ * Dependency-free and side-effect-free so it is trivially unit-testable and safe to import into the
10
+ * R3F camera driver.
11
+ */
12
+ /** A world-space point the orbit camera looks at. */
13
+ export interface Vec3 {
14
+ x: number;
15
+ y: number;
16
+ z: number;
17
+ }
18
+ /** Axis-aligned bounds as produced by `editorDocumentBounds`. */
19
+ export interface Bounds3 {
20
+ min: Vec3;
21
+ max: Vec3;
22
+ }
23
+ /** Inputs to {@link orbitCameraPosition}. */
24
+ export interface OrbitFrameInput {
25
+ /** Point the camera orbits and looks at. */
26
+ target: Vec3;
27
+ /** Radial distance from target to camera, in world units. Clamped to a small positive minimum. */
28
+ distance: number;
29
+ /** Elevation above the horizon, degrees: 0 = level with the target, 90 = straight-down aerial. */
30
+ pitchDeg: number;
31
+ /** Azimuth around the target, degrees: 0 looks along +Z toward the target, increasing toward +X. */
32
+ yawDeg?: number;
33
+ /**
34
+ * Explicit camera height above the target (world units). When set it overrides the pitch-derived
35
+ * height, letting a caller pin a fixed altitude while pitch still sets the horizontal pull-back.
36
+ */
37
+ height?: number;
38
+ }
39
+ /** Clamp a pitch to the horizon..straight-down range the orbit rig can express. */
40
+ export declare function clampPitchDeg(pitchDeg: number): number;
41
+ /**
42
+ * Camera position for an orbit rig framing `target` at the given distance, pitch, and yaw. Pitch is
43
+ * measured up from the horizon (90° = directly overhead); `height`, when provided, replaces the
44
+ * pitch-derived vertical offset. Pure: same inputs always yield the same position.
45
+ */
46
+ export declare function orbitCameraPosition(input: OrbitFrameInput): Vec3;
47
+ /** Center point of axis-aligned bounds. */
48
+ export declare function boundsCenter(bounds: Bounds3): Vec3;
49
+ /** Options for {@link frameDistanceForBounds}. */
50
+ export interface FrameDistanceOptions {
51
+ /** Vertical field of view of the camera, degrees. Default 50 (the shell's default perspective FOV). */
52
+ fovDeg?: number;
53
+ /** Extra breathing room around the region as a multiplier on its radius. Default 1.25. */
54
+ margin?: number;
55
+ }
56
+ /**
57
+ * Distance at which the camera fits the whole `bounds` region into its vertical FOV — the value
58
+ * `camera_frame` uses when a caller asks for an aerial but does not pin an explicit distance, so a
59
+ * district frames itself instead of burying the camera. Uses the region's XZ half-diagonal plus its
60
+ * vertical extent as the radius to cover.
61
+ */
62
+ export declare function frameDistanceForBounds(bounds: Bounds3, options?: FrameDistanceOptions): number;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure aerial-framing math for the editor orbit camera. `camera_goto`/`camera_frame` used to only
3
+ * pan the orbit *target*, leaving distance and pitch wherever the artist last dragged them — so a
4
+ * `KeyF` frame could bury the camera inside terrain or a building and composing a clean district
5
+ * aerial took many guess-the-y-offset round-trips. These helpers turn an explicit
6
+ * distance + pitch (+ optional yaw / height) into a camera position, and derive a fit-to-region
7
+ * distance from document bounds so one call frames a whole district from above.
8
+ *
9
+ * Dependency-free and side-effect-free so it is trivially unit-testable and safe to import into the
10
+ * R3F camera driver.
11
+ */
12
+ const DEG_TO_RAD = Math.PI / 180;
13
+ /** Smallest distance we will place the camera at, so `distance: 0` never collapses onto the target. */
14
+ const MIN_DISTANCE = 0.01;
15
+ /** Clamp a pitch to the horizon..straight-down range the orbit rig can express. */
16
+ export function clampPitchDeg(pitchDeg) {
17
+ if (!Number.isFinite(pitchDeg))
18
+ return 45;
19
+ return Math.max(0, Math.min(90, pitchDeg));
20
+ }
21
+ /**
22
+ * Camera position for an orbit rig framing `target` at the given distance, pitch, and yaw. Pitch is
23
+ * measured up from the horizon (90° = directly overhead); `height`, when provided, replaces the
24
+ * pitch-derived vertical offset. Pure: same inputs always yield the same position.
25
+ */
26
+ export function orbitCameraPosition(input) {
27
+ const distance = Math.max(MIN_DISTANCE, Number.isFinite(input.distance) ? input.distance : MIN_DISTANCE);
28
+ const pitchRad = clampPitchDeg(input.pitchDeg) * DEG_TO_RAD;
29
+ const yawRad = (Number.isFinite(input.yawDeg) ? input.yawDeg : 0) * DEG_TO_RAD;
30
+ const horizontal = distance * Math.cos(pitchRad);
31
+ const vertical = distance * Math.sin(pitchRad);
32
+ const height = input.height !== undefined && Number.isFinite(input.height) ? input.height : vertical;
33
+ return {
34
+ x: input.target.x + horizontal * Math.sin(yawRad),
35
+ y: input.target.y + height,
36
+ z: input.target.z + horizontal * Math.cos(yawRad),
37
+ };
38
+ }
39
+ /** Center point of axis-aligned bounds. */
40
+ export function boundsCenter(bounds) {
41
+ return {
42
+ x: (bounds.min.x + bounds.max.x) / 2,
43
+ y: (bounds.min.y + bounds.max.y) / 2,
44
+ z: (bounds.min.z + bounds.max.z) / 2,
45
+ };
46
+ }
47
+ /**
48
+ * Distance at which the camera fits the whole `bounds` region into its vertical FOV — the value
49
+ * `camera_frame` uses when a caller asks for an aerial but does not pin an explicit distance, so a
50
+ * district frames itself instead of burying the camera. Uses the region's XZ half-diagonal plus its
51
+ * vertical extent as the radius to cover.
52
+ */
53
+ export function frameDistanceForBounds(bounds, options = {}) {
54
+ const fovDeg = clampFovDeg(options.fovDeg ?? 50);
55
+ const margin = options.margin !== undefined && Number.isFinite(options.margin) ? options.margin : 1.25;
56
+ const halfX = (bounds.max.x - bounds.min.x) / 2;
57
+ const halfZ = (bounds.max.z - bounds.min.z) / 2;
58
+ const halfY = (bounds.max.y - bounds.min.y) / 2;
59
+ const radius = Math.hypot(halfX, halfZ) + Math.max(0, halfY);
60
+ const halfFovRad = (fovDeg / 2) * DEG_TO_RAD;
61
+ const tan = Math.tan(halfFovRad);
62
+ const distance = tan > 1e-4 ? (radius * Math.max(0.1, margin)) / tan : radius;
63
+ return Math.max(MIN_DISTANCE, distance);
64
+ }
65
+ function clampFovDeg(fovDeg) {
66
+ if (!Number.isFinite(fovDeg))
67
+ return 50;
68
+ return Math.max(1, Math.min(179, fovDeg));
69
+ }
@@ -1,13 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
2
3
  import { INPUT } from "./chromeStyles.js";
3
4
  export function NumberField({ label, value, onCommit, step = 1, }) {
4
- return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { type: "number", step: step, className: `w-32 ${INPUT}`, value: value, onChange: (event) => {
5
+ // Mirror locally + debounce the commit so a held spinner / paste doesn't storm scene regen (#1372).
6
+ const { value: local, onInput, flush } = useDebouncedCommit(value, onCommit);
7
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { type: "number", step: step, className: `w-32 ${INPUT}`, value: local, onChange: (event) => {
5
8
  const next = Number(event.target.value);
6
9
  if (!Number.isFinite(next))
7
10
  return;
8
- onCommit(next);
9
- } })] }));
11
+ onInput(next);
12
+ }, onBlur: flush })] }));
10
13
  }
11
14
  export function SliderRow({ label, value, min, max, step, onChange, format, }) {
12
- return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("span", { className: "text-cyan-200", children: format ? format(value) : value.toFixed(2) })] }), _jsx("input", { type: "range", min: min, max: max, step: step, value: value, onChange: (event) => onChange(Number(event.target.value)), className: "w-full accent-amber-400" })] }));
15
+ // The readout + thumb track the live local value; onChange is debounced and flushed on release (#1372).
16
+ const { value: local, onInput, flush } = useDebouncedCommit(value, onChange);
17
+ return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("span", { className: "text-cyan-200", children: format ? format(local) : local.toFixed(2) })] }), _jsx("input", { type: "range", min: min, max: max, step: step, value: local, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, className: "w-full accent-amber-400" })] }));
13
18
  }
@@ -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" | "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" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "assign_material" | "set_object_flags">;