@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.
- package/CHANGELOG.md +529 -1
- package/dist/ClipPreviewLayer.d.ts +15 -0
- package/dist/ClipPreviewLayer.js +60 -0
- package/dist/EditorApp.js +4 -3
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +4 -6
- package/dist/InspectorPanel.d.ts +4 -1
- package/dist/InspectorPanel.js +47 -5
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.d.ts +3 -1
- package/dist/SchemaInspector.js +63 -32
- package/dist/TerrainPanel.js +3 -4
- package/dist/TerrainReadout.js +1 -3
- package/dist/agent/AgentPanel.js +7 -11
- package/dist/camera/orbitFraming.d.ts +62 -0
- package/dist/camera/orbitFraming.js +69 -0
- package/dist/chromeFields.js +9 -4
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +78 -9
- package/dist/mcp/fieldSpec.js +4 -2
- package/dist/mcp/tools.js +4 -2
- package/dist/modelAnimationAuthoring.d.ts +75 -0
- package/dist/modelAnimationAuthoring.js +164 -0
- package/dist/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.d.ts +10 -8
- package/dist/shell/AnimationPanel.js +120 -26
- package/dist/shell/BottomDock.d.ts +3 -1
- package/dist/shell/BottomDock.js +2 -2
- package/dist/shell/CommandPalette.js +1 -5
- package/dist/shell/HierarchyPanel.js +17 -26
- package/dist/shell/PlayModeBar.d.ts +7 -0
- package/dist/shell/PlayModeBar.js +12 -3
- package/dist/shell/SceneToolbar.js +9 -5
- package/dist/shell/clipPreview.d.ts +85 -0
- package/dist/shell/clipPreview.js +82 -0
- package/dist/shell/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.d.ts +22 -0
- package/dist/shell/scatterCoverage.js +40 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +1 -0
- package/dist/useStoreSelector.d.ts +12 -0
- package/dist/useStoreSelector.js +23 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-editor/api.md +56 -4
- package/skills/jgengine-editor/reference.md +44 -0
package/dist/SchemaInspector.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case "
|
|
51
|
-
|
|
52
|
-
|
|
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 =
|
|
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
|
|
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
|
}
|
package/dist/TerrainPanel.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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;
|
package/dist/TerrainReadout.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/agent/AgentPanel.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
package/dist/chromeFields.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
} })] }));
|
|
11
|
+
onInput(next);
|
|
12
|
+
}, onBlur: flush })] }));
|
|
10
13
|
}
|
|
11
14
|
export function SliderRow({ label, value, min, max, step, onChange, format, }) {
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
},
|
package/dist/mcp/fieldSpec.js
CHANGED
|
@@ -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." },
|