@jgengine/editor 0.13.0 → 0.15.0-next.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 (53) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ClipPreviewLayer.d.ts +15 -0
  3. package/dist/ClipPreviewLayer.js +60 -0
  4. package/dist/EditorApp.js +4 -3
  5. package/dist/EditorCameraDriver.d.ts +6 -1
  6. package/dist/EditorCameraDriver.js +25 -1
  7. package/dist/EditorChrome.js +4 -6
  8. package/dist/InspectorPanel.d.ts +4 -1
  9. package/dist/InspectorPanel.js +47 -5
  10. package/dist/ParentPickerMenu.js +2 -6
  11. package/dist/RuntimePlayBridge.js +10 -15
  12. package/dist/SchemaInspector.d.ts +3 -1
  13. package/dist/SchemaInspector.js +63 -32
  14. package/dist/TerrainPanel.js +3 -4
  15. package/dist/TerrainReadout.js +1 -3
  16. package/dist/agent/AgentPanel.js +7 -11
  17. package/dist/camera/orbitFraming.d.ts +62 -0
  18. package/dist/camera/orbitFraming.js +69 -0
  19. package/dist/chromeFields.js +9 -4
  20. package/dist/handlers/document.d.ts +1 -1
  21. package/dist/handlers/document.js +78 -9
  22. package/dist/mcp/fieldSpec.js +4 -2
  23. package/dist/mcp/tools.js +4 -2
  24. package/dist/modelAnimationAuthoring.d.ts +75 -0
  25. package/dist/modelAnimationAuthoring.js +164 -0
  26. package/dist/session.d.ts +54 -15
  27. package/dist/shell/AnimationPanel.d.ts +10 -8
  28. package/dist/shell/AnimationPanel.js +120 -26
  29. package/dist/shell/BottomDock.d.ts +3 -1
  30. package/dist/shell/BottomDock.js +2 -2
  31. package/dist/shell/CommandPalette.js +1 -5
  32. package/dist/shell/HierarchyPanel.js +17 -26
  33. package/dist/shell/PlayModeBar.d.ts +7 -0
  34. package/dist/shell/PlayModeBar.js +12 -3
  35. package/dist/shell/SceneToolbar.js +9 -5
  36. package/dist/shell/clipPreview.d.ts +85 -0
  37. package/dist/shell/clipPreview.js +82 -0
  38. package/dist/shell/fields.js +8 -8
  39. package/dist/shell/glbThumbnailCache.js +39 -8
  40. package/dist/shell/scatterCoverage.d.ts +22 -0
  41. package/dist/shell/scatterCoverage.js +40 -0
  42. package/dist/uiStore.d.ts +6 -0
  43. package/dist/uiStore.js +1 -0
  44. package/dist/useStoreSelector.d.ts +12 -0
  45. package/dist/useStoreSelector.js +23 -0
  46. package/package.json +5 -5
  47. package/skills/ce-handoff/SKILL.md +149 -0
  48. package/skills/jgengine/SKILL.md +7 -3
  49. package/skills/jgengine/api.md +17 -7
  50. package/skills/jgengine/capabilities.md +12 -0
  51. package/skills/jgengine/recipes/minimal-game.md +7 -4
  52. package/skills/jgengine-editor/api.md +56 -4
  53. package/skills/jgengine-editor/reference.md +44 -0
@@ -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
  }
@@ -82,10 +108,15 @@ function GroupSection({ title, accent, fields, schema, meta, onMeta, defaultColl
82
108
  * seam owns the inspector. Replaces the hand-written per-kind field blocks (`ScatterFields`).
83
109
  * @internal — mounted by `EditorChrome`'s inspector; not a game-author entry point.
84
110
  */
85
- export function SchemaInspector({ schema, label, accent = "#34d399", note, meta, onMeta, }) {
111
+ export function SchemaInspector({ schema, label, accent = "#34d399", note, meta, onMeta, hideKeys, }) {
86
112
  const groups = schema.groups ?? [];
87
113
  const grouped = new Set(groups.map((group) => group.id));
114
+ const hidden = hideKeys === undefined || hideKeys.length === 0 ? null : new Set(hideKeys);
115
+ const visible = hidden === null ? schema.fields : schema.fields.filter((field) => !hidden.has(field.key));
88
116
  // Fields with no group (or a group not declared in schema.groups) render first, headerless.
89
- const ungrouped = schema.fields.filter((field) => field.group === undefined || !grouped.has(field.group));
90
- return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: label }), _jsx(PresetRow, { schema: schema, onMeta: onMeta }), ungrouped.map((field) => field.type === "action" ? (_jsx(ActionButton, { field: field, schema: schema, onMeta: onMeta }, field.key)) : (_jsx(FieldRow, { field: field, meta: meta, onMeta: onMeta }, field.key))), groups.map((group) => (_jsx(GroupSection, { title: group.label, accent: accent, fields: schema.fields.filter((field) => field.group === group.id), schema: schema, meta: meta, onMeta: onMeta, defaultCollapsed: group.collapsed ?? false }, group.id))), note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
117
+ const ungrouped = visible.filter((field) => field.group === undefined || !grouped.has(field.group));
118
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: label }), _jsx(PresetRow, { schema: schema, onMeta: onMeta }), ungrouped.map((field) => field.type === "action" ? (_jsx(ActionButton, { field: field, schema: schema, onMeta: onMeta }, field.key)) : (_jsx(FieldRow, { field: field, meta: meta, onMeta: onMeta }, field.key))), groups
119
+ .map((group) => ({ group, fields: visible.filter((field) => field.group === group.id) }))
120
+ .filter((entry) => entry.fields.length > 0)
121
+ .map(({ group, fields }) => (_jsx(GroupSection, { title: group.label, accent: accent, fields: fields, schema: schema, meta: meta, onMeta: onMeta, defaultCollapsed: group.collapsed ?? false }, group.id))), note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
91
122
  }
@@ -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">;
@@ -2,6 +2,20 @@ import { editorDocumentBounds, listEditorKinds, summarizeEditorSession, } from "
2
2
  import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
3
3
  import { findSchemaPreset, getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
4
4
  import { EDITOR_RUN_MODES } from "../session.js";
5
+ import { boundsCenter, frameDistanceForBounds } from "../camera/orbitFraming.js";
6
+ /** Collect the finite placement fields present on a camera request into a focus-target patch. */
7
+ function cameraPlacement(request) {
8
+ const placement = {};
9
+ if (typeof request.distance === "number" && Number.isFinite(request.distance))
10
+ placement.distance = request.distance;
11
+ if (typeof request.pitch === "number" && Number.isFinite(request.pitch))
12
+ placement.pitch = request.pitch;
13
+ if (typeof request.yaw === "number" && Number.isFinite(request.yaw))
14
+ placement.yaw = request.yaw;
15
+ if (typeof request.height === "number" && Number.isFinite(request.height))
16
+ placement.height = request.height;
17
+ return placement;
18
+ }
5
19
  /** True when any of `ids` names a placeable object (marker/volume/path/note) in the document — used
6
20
  * to reject batch mutations that would match nothing, so the caller hears an honest `ok:false`. */
7
21
  function documentHasAnyId(doc, ids) {
@@ -134,6 +148,49 @@ export const documentHandlers = {
134
148
  });
135
149
  return { ok: true, result: ctx.session.getState().document.paths.find((p) => p.id === request.id) };
136
150
  },
151
+ add_path: (ctx, request) => {
152
+ if (request.points.length < 2)
153
+ return { ok: false, error: "add_path needs at least 2 points" };
154
+ const kind = request.kind ?? "route";
155
+ const invalid = validateMetaForKind(kind, request.meta);
156
+ if (invalid !== null)
157
+ return { ok: false, error: invalid };
158
+ const path = {
159
+ id: request.id,
160
+ kind,
161
+ points: request.points.map((point) => ({ x: point.x, y: point.y ?? 0, z: point.z })),
162
+ ...(request.width === undefined ? {} : { width: request.width }),
163
+ ...(request.color === undefined ? {} : { color: request.color }),
164
+ ...(request.label === undefined ? {} : { label: request.label }),
165
+ ...(request.meta === undefined ? {} : { meta: request.meta }),
166
+ };
167
+ // addPath re-ids on a document-global collision, so read the created id back from the selection.
168
+ const { applied, state } = ctx.dispatchGuarded({ type: "addPath", path });
169
+ if (!applied)
170
+ return { ok: false, error: "add_path rejected: no effect" };
171
+ const createdId = state.selection[0] ?? request.id;
172
+ return { ok: true, result: state.document.paths.find((p) => p.id === createdId) };
173
+ },
174
+ add_marker: (ctx, request) => {
175
+ const invalid = validateMetaForKind(request.kind, request.meta);
176
+ if (invalid !== null)
177
+ return { ok: false, error: invalid };
178
+ const marker = {
179
+ id: request.id,
180
+ kind: request.kind,
181
+ position: { x: request.x, y: request.y ?? 0, z: request.z },
182
+ ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
183
+ ...(request.color === undefined ? {} : { color: request.color }),
184
+ ...(request.label === undefined ? {} : { label: request.label }),
185
+ ...(request.meta === undefined ? {} : { meta: request.meta }),
186
+ };
187
+ // addMarker re-ids on a document-global collision, so read the created id back from the selection.
188
+ const { applied, state } = ctx.dispatchGuarded({ type: "addMarker", marker });
189
+ if (!applied)
190
+ return { ok: false, error: "add_marker rejected: no effect" };
191
+ const createdId = state.selection[0] ?? request.id;
192
+ return { ok: true, result: state.document.markers.find((m) => m.id === createdId) };
193
+ },
137
194
  set_marker: (ctx, request) => {
138
195
  const marker = ctx.session.getState().document.markers.find((m) => m.id === request.id);
139
196
  if (marker === undefined)
@@ -218,6 +275,7 @@ export const documentHandlers = {
218
275
  return { ok: true, result: { selection: [] } };
219
276
  },
220
277
  camera_goto: (ctx, request) => {
278
+ const placement = cameraPlacement(request);
221
279
  if (request.id !== undefined) {
222
280
  const state = ctx.session.getState();
223
281
  const marker = state.document.markers.find((m) => m.id === request.id);
@@ -225,26 +283,32 @@ export const documentHandlers = {
225
283
  const point = marker?.position ?? volume?.center;
226
284
  if (point === undefined)
227
285
  return { ok: false, error: `id not found: ${request.id}` };
228
- ctx.api.setFocusTarget({ ...point });
286
+ const target = { x: point.x, y: point.y, z: point.z, ...placement };
287
+ ctx.api.setFocusTarget(target);
229
288
  ctx.session.dispatch({ type: "select", ids: [request.id] });
230
- return { ok: true, result: { target: point } };
289
+ return { ok: true, result: { target } };
231
290
  }
232
291
  if (request.x === undefined || request.z === undefined) {
233
292
  return { ok: false, error: "camera_goto requires id or x/z" };
234
293
  }
235
- const target = { x: request.x, y: request.y ?? 0, z: request.z };
294
+ const target = { x: request.x, y: request.y ?? 0, z: request.z, ...placement };
236
295
  ctx.api.setFocusTarget(target);
237
296
  return { ok: true, result: { target } };
238
297
  },
239
- camera_frame: (ctx) => {
298
+ camera_frame: (ctx, request) => {
240
299
  const bounds = editorDocumentBounds(ctx.session.getState().document);
241
300
  if (bounds === null)
242
301
  return { ok: false, error: "document is empty" };
243
- const target = {
244
- x: (bounds.min.x + bounds.max.x) / 2,
245
- y: (bounds.min.y + bounds.max.y) / 2,
246
- z: (bounds.min.z + bounds.max.z) / 2,
247
- };
302
+ const center = boundsCenter(bounds);
303
+ const placement = cameraPlacement(request);
304
+ // An aerial is requested when the caller supplies any camera placement. Fit the region into the
305
+ // camera's FOV when no explicit distance is given, so one call frames a whole district from
306
+ // above instead of burying the camera at the last-used distance.
307
+ const wantsAerial = Object.keys(placement).length > 0;
308
+ const resolved = wantsAerial && placement.distance === undefined
309
+ ? { ...placement, distance: frameDistanceForBounds(bounds, { fovDeg: 50 }) }
310
+ : placement;
311
+ const target = { ...center, ...resolved };
248
312
  ctx.api.setFocusTarget(target);
249
313
  return { ok: true, result: { target, bounds } };
250
314
  },
@@ -259,6 +323,11 @@ export const documentHandlers = {
259
323
  }),
260
324
  export_document: (ctx) => ({ ok: true, result: { json: ctx.session.exportJson(true) } }),
261
325
  import_document: (ctx, request) => {
326
+ // Name the expected param instead of letting a missing/mis-keyed value fall into JSON.parse as
327
+ // the literal string "undefined" and surface a cryptic "JSON Parse error: Unexpected identifier".
328
+ if (typeof request.json !== "string") {
329
+ return { ok: false, error: "import_document requires a `json` param — the document JSON text (as export_document returns in result.json)" };
330
+ }
262
331
  ctx.session.dispatch({ type: "importJson", json: request.json });
263
332
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
264
333
  },
@@ -44,14 +44,16 @@ export const RPC_FIELD_SCHEMAS = {
44
44
  set_transform: [req(s("id")), n("x"), n("y"), n("z"), n("rotationY")],
45
45
  set_volume: [req(s("id")), n("radius"), n("height"), n("x"), n("y"), n("z")],
46
46
  set_path: [req(s("id")), s("kind"), n("width"), s("color"), s("label"), o("meta")],
47
+ add_path: [req(s("id")), req(oa("points", [req(n("x")), n("y"), req(n("z"))])), s("kind"), n("width"), s("color"), s("label"), o("meta")],
48
+ add_marker: [req(s("id")), req(s("kind")), req(n("x")), n("y"), req(n("z")), s("color"), s("label"), n("rotationY"), o("meta")],
47
49
  set_marker: [req(s("id")), s("kind"), s("color"), s("label"), n("rotationY"), o("meta")],
48
50
  set_note: [req(s("id")), s("text"), o("meta")],
49
51
  set_meta: [req(s("id")), req(o("patch"))],
50
52
  apply_preset: [req(s("id")), req(s("preset"))],
51
53
  select: [req(sa("ids"))],
52
54
  clear_selection: [],
53
- camera_goto: [s("id"), n("x"), n("y"), n("z")],
54
- camera_frame: [],
55
+ camera_goto: [s("id"), n("x"), n("y"), n("z"), n("distance"), n("pitch"), n("yaw"), n("height")],
56
+ camera_frame: [n("distance"), n("pitch"), n("yaw"), n("height")],
55
57
  scene_summary: [],
56
58
  export_document: [],
57
59
  import_document: [req(s("json"))],
package/dist/mcp/tools.js CHANGED
@@ -22,13 +22,15 @@ const TOOL_DESCRIPTIONS = [
22
22
  { name: "set_transform", description: "Move a marker or volume center by id." },
23
23
  { name: "set_volume", description: "Patch a volume radius/height/center." },
24
24
  { name: "set_path", description: "Patch a path's kind/width/color/label and merge-patch its meta (studio sliders: scatter density, pole spacing, …). Validated against the kind schema." },
25
+ { name: "add_path", description: "Author a new path/route into the scene document from an ordered list of ≥2 {x,z} (optional y) points — the headless one-shot for placing a route without an export/import roundtrip. kind defaults to `route`; width/color/label/meta are optional and meta is validated against the kind schema. Re-ids on a document-global id collision." },
26
+ { name: "add_marker", description: "Author a new point marker into the scene document at x/z (optional y) — the headless one-shot for placing a logical marker (spawn, checkpoint, pickup, bounty, or a game's own custom kind) without an export/import roundtrip. kind is required; color/label/rotationY/meta are optional and meta is validated against the kind schema when registered. Re-ids on a document-global id collision." },
25
27
  { name: "set_marker", description: "Patch a marker's kind/color/label/rotationY and merge-patch its meta. Validated against the kind schema when registered." },
26
28
  { name: "set_note", description: "Patch a note's text and merge-patch its meta." },
27
29
  { name: "set_meta", description: "Merge-patch the meta bag of any document object (marker/volume/path/note) by id — the generic studio-slider primitive. Rejected if it violates the kind's param schema." },
28
30
  { name: "apply_preset", description: "Apply a named preset bundle from the object's kind schema (e.g. a city archetype like manhattan/ruralohio) as one meta patch, ready to tweak afterwards." },
29
31
  { name: "select", description: "Select editor object ids." },
30
- { name: "camera_goto", description: "Focus the editor camera on an id or world x/z." },
31
- { name: "camera_frame", description: "Frame the whole editor document." },
32
+ { name: "camera_goto", description: "Focus the editor camera on an id or world x/z. Optional distance/pitch/yaw/height reposition the orbit camera (pitch is degrees above the horizon; 90 = straight-down aerial) so an aerial composes in one call." },
33
+ { name: "camera_frame", description: "Frame the whole editor document. Optional distance/pitch/yaw/height compose an aerial; when pitch is set without distance the region is auto-fit into view so the camera never buries in terrain." },
32
34
  { name: "scene_summary", description: "Compact summary of the editor document and bounds." },
33
35
  { name: "export_document", description: "Export the editor document as JSON text." },
34
36
  { name: "import_document", description: "Replace the session document from JSON text." },