@jgengine/editor 0.18.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +24 -26
  4. package/dist/CreatorApp.d.ts +12 -0
  5. package/dist/CreatorApp.js +107 -0
  6. package/dist/DebugDraw.js +3 -1
  7. package/dist/EditorApp.d.ts +14 -2
  8. package/dist/EditorApp.js +29 -14
  9. package/dist/EditorChrome.d.ts +9 -1
  10. package/dist/EditorChrome.js +61 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/LightingPanel.js +5 -1
  14. package/dist/MaterialAssetEditor.d.ts +13 -0
  15. package/dist/MaterialAssetEditor.js +109 -0
  16. package/dist/MaterialPreview.d.ts +16 -0
  17. package/dist/MaterialPreview.js +41 -0
  18. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  19. package/dist/MaterialsWorkspacePanel.js +78 -4
  20. package/dist/RuntimePlayBridge.js +1 -6
  21. package/dist/SelectionGizmo.js +23 -14
  22. package/dist/SimulationInspector.d.ts +9 -0
  23. package/dist/SimulationInspector.js +47 -0
  24. package/dist/TerrainSculpt.js +3 -1
  25. package/dist/agent/toolBridge.js +2 -0
  26. package/dist/authoringPlacement.d.ts +21 -0
  27. package/dist/authoringPlacement.js +34 -0
  28. package/dist/handlers/context.d.ts +1 -0
  29. package/dist/handlers/document.d.ts +1 -1
  30. package/dist/handlers/document.js +31 -1
  31. package/dist/handlers/hierarchy.d.ts +1 -1
  32. package/dist/handlers/hierarchy.js +4 -0
  33. package/dist/handlers/navmesh.d.ts +2 -0
  34. package/dist/handlers/navmesh.js +16 -4
  35. package/dist/handlers/runtime.js +20 -17
  36. package/dist/handlers/terrain.js +52 -12
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.js +1 -0
  39. package/dist/materialControls.d.ts +32 -0
  40. package/dist/materialControls.js +79 -0
  41. package/dist/mcp/fieldSpec.d.ts +1 -1
  42. package/dist/mcp/fieldSpec.js +13 -4
  43. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  44. package/dist/mcp/importOptionalGameModule.js +13 -0
  45. package/dist/mcp/loadGameCatalogs.js +5 -12
  46. package/dist/mcp/rpcRequest.d.ts +3 -3
  47. package/dist/mcp/rpcRequest.js +10 -4
  48. package/dist/mcp/tools.js +13 -4
  49. package/dist/modelAnimationAuthoring.d.ts +49 -6
  50. package/dist/modelAnimationAuthoring.js +118 -64
  51. package/dist/session.d.ts +64 -0
  52. package/dist/session.js +58 -7
  53. package/dist/shell/AnimationGraphPanel.d.ts +16 -0
  54. package/dist/shell/AnimationGraphPanel.js +180 -0
  55. package/dist/shell/AnimationPanel.d.ts +3 -1
  56. package/dist/shell/AnimationPanel.js +9 -7
  57. package/dist/shell/PlayModeBar.d.ts +2 -1
  58. package/dist/shell/PlayModeBar.js +2 -2
  59. package/dist/shell/SceneToolbar.d.ts +2 -1
  60. package/dist/shell/SceneToolbar.js +5 -4
  61. package/dist/shell/TopAppBar.d.ts +2 -1
  62. package/dist/shell/TopAppBar.js +2 -2
  63. package/dist/shell/WorkspaceRail.js +1 -1
  64. package/dist/shell/animGraphPreview.d.ts +58 -0
  65. package/dist/shell/animGraphPreview.js +95 -0
  66. package/dist/shell/clipPreview.d.ts +9 -0
  67. package/dist/useDocumentSave.d.ts +20 -0
  68. package/dist/useDocumentSave.js +33 -0
  69. package/package.json +6 -5
  70. package/skills/ce-handoff/SKILL.md +2 -2
  71. package/skills/jgengine/SKILL.md +28 -6
  72. package/skills/jgengine/api.md +139 -99
  73. package/skills/jgengine/capabilities.md +28 -0
  74. package/skills/jgengine/recipes/minimal-game.md +10 -2
  75. package/skills/jgengine-editor/SKILL.md +12 -2
  76. package/skills/jgengine-editor/api.md +120 -16
  77. package/skills/jgengine-editor/capabilities.md +40 -0
  78. package/skills/jgengine-editor/reference.md +161 -3
  79. package/skills/jgengine-editor/references/live-sync.md +15 -0
  80. package/skills/level-design/SKILL.md +1 -1
@@ -0,0 +1,180 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
+ import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSettingResult, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
5
+ import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
6
+ import { graphParamControls, graphTriggers, MAX_PREVIEW_SECONDS, recordTrigger, simulateGraphPreview, } from "./animGraphPreview.js";
7
+ import { initialClipPreviewState } from "./clipPreview.js";
8
+ import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
9
+ import { EmptyState, IconButton } from "./ui.js";
10
+ const SOURCE_LABEL = {
11
+ authored: "Stored on this placement",
12
+ locomotion: "Built from the placement's states and one-shots",
13
+ auto: "Derived from the rig's clip names",
14
+ };
15
+ function conditionText(condition) {
16
+ return `${condition.param} ${condition.op} ${String(condition.value)}`;
17
+ }
18
+ function stateClips(state) {
19
+ if (state.kind === "clip")
20
+ return state.clip;
21
+ if (state.kind === "blend1D")
22
+ return state.points.map((point) => `${point.clip}@${point.at}`).join(" · ");
23
+ return state.points.map((point) => `${point.clip}@${point.at[0]},${point.at[1]}`).join(" · ");
24
+ }
25
+ /**
26
+ * Graph mode of the Animation dock: the selected placement's animation graph (layers, states,
27
+ * transitions), a scrubbed preview driven by the headless graph runtime and shown on the rig in the
28
+ * viewport, trigger buttons that record presses on the timeline, and crossfade editing that stores
29
+ * the graph on the placement as an undoable edit.
30
+ *
31
+ * @internal
32
+ */
33
+ export function AnimationGraphPanel({ session, ui, rigged, }) {
34
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
35
+ const markers = useStoreSelector(session, (state) => state.document.markers);
36
+ const target = useMemo(() => {
37
+ for (const id of selection) {
38
+ const marker = markers.find((entry) => entry.id === id);
39
+ if (marker === undefined)
40
+ continue;
41
+ if (readAnimationSettingResult({ markers }, marker.id).diagnostics.length > 0)
42
+ return { marker, asset: undefined };
43
+ const catalogId = markerCatalogId(marker);
44
+ const asset = catalogId === null ? undefined : rigged.find((entry) => entry.id === catalogId);
45
+ if (asset?.url !== undefined && asset.clips !== undefined)
46
+ return { marker, asset };
47
+ }
48
+ return null;
49
+ }, [selection, markers, rigged]);
50
+ if (target === null) {
51
+ return (_jsx(EmptyState, { icon: "film", title: "Animation graph", badge: "Placed rig", description: "Select a placed instance of a rigged asset to see the graph it plays: layers, states and transitions, with a scrubbed preview on the rig in the viewport." }));
52
+ }
53
+ return _jsx(GraphInspector, { session: session, ui: ui, marker: target.marker, asset: target.asset }, target.marker.id);
54
+ }
55
+ function GraphInspector({ session, ui, marker, asset, }) {
56
+ const clips = asset?.clips ?? [];
57
+ const document = useStoreSelector(session, (state) => state.document);
58
+ const { setting, diagnostics } = readAnimationSettingResult(document, marker.id);
59
+ const invalid = diagnostics.length > 0;
60
+ const effective = useMemo(() => invalid || asset === undefined ? null : effectiveAnimGraph(setting, clips), [JSON.stringify(setting), invalid, asset, clips]);
61
+ const graph = effective?.graph ?? null;
62
+ const controls = useMemo(() => (graph === null ? [] : graphParamControls(graph)), [graph]);
63
+ const triggerNames = useMemo(() => (graph === null ? [] : graphTriggers(graph)), [graph]);
64
+ const [time, setTime] = useState(0);
65
+ const [playing, setPlaying] = useState(false);
66
+ const [params, setParams] = useState({});
67
+ const [triggers, setTriggers] = useState([]);
68
+ useEffect(() => { if (invalid)
69
+ setPlaying(false); }, [invalid]);
70
+ const durations = useStoreSelector(ui, (state) => state.clipPreview?.clipDurations);
71
+ const frame = useMemo(() => {
72
+ if (graph === null)
73
+ return null;
74
+ const merged = {};
75
+ for (const control of controls)
76
+ merged[control.name] = params[control.name] ?? (control.kind === "boolean" ? false : 0);
77
+ return simulateGraphPreview({ graph, durations: durations ?? {}, time, params: merged, triggers });
78
+ }, [graph, controls, params, triggers, time, durations]);
79
+ useEffect(() => {
80
+ if (asset?.url === undefined)
81
+ return;
82
+ const current = ui.getState().clipPreview;
83
+ const sameAsset = current?.source.assetId === asset.id;
84
+ ui.patch({
85
+ clipPreview: {
86
+ source: { assetId: asset.id, label: asset.label, url: asset.url, clips },
87
+ driver: initialClipPreviewState(null),
88
+ duration: 0,
89
+ ...(sameAsset && current?.clipDurations !== undefined ? { clipDurations: current.clipDurations } : {}),
90
+ },
91
+ });
92
+ return () => {
93
+ if (ui.getState().clipPreview?.source.assetId === asset.id)
94
+ ui.patch({ clipPreview: null });
95
+ };
96
+ }, [asset?.id, asset?.url, asset?.label, clips, ui]);
97
+ useEffect(() => {
98
+ const current = ui.getState().clipPreview;
99
+ if (asset === undefined || current === null || current.source.assetId !== asset.id)
100
+ return;
101
+ if (graph === null || frame === null) {
102
+ if (current.graphPose !== undefined)
103
+ ui.patch({ clipPreview: { ...current, graphPose: undefined } });
104
+ return;
105
+ }
106
+ ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips, ...(frame.rootMotion === true ? { rootMotion: true } : {}) } } });
107
+ }, [graph, frame, asset?.id, ui, durations]);
108
+ const timeRef = useRef(time);
109
+ timeRef.current = time;
110
+ useEffect(() => {
111
+ if (!playing)
112
+ return;
113
+ let handle = 0;
114
+ let last = performance.now();
115
+ const tick = (now) => {
116
+ const next = timeRef.current + Math.min(0.05, Math.max(0, (now - last) / 1000));
117
+ last = now;
118
+ if (next >= MAX_PREVIEW_SECONDS) {
119
+ setTime(MAX_PREVIEW_SECONDS);
120
+ setPlaying(false);
121
+ return;
122
+ }
123
+ setTime(next);
124
+ handle = requestAnimationFrame(tick);
125
+ };
126
+ handle = requestAnimationFrame(tick);
127
+ return () => cancelAnimationFrame(handle);
128
+ }, [playing]);
129
+ const commit = (next) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...animationMetaPatch(next) } } }, { coalesce: `animation:${marker.id}` });
130
+ if (invalid)
131
+ return (_jsxs("div", { className: "space-y-2 p-3 text-[11px]", children: [_jsxs("div", { role: "alert", "aria-label": "Animation diagnostics", className: "space-y-1 text-amber-200", children: [_jsx("p", { children: "The saved animation override is invalid. Its graph cannot be previewed or edited until repaired or replaced." }), diagnostics.map((entry) => _jsxs("div", { children: [_jsxs("div", { className: "break-all", children: [entry.path, ": ", entry.message] }), _jsx("div", { children: entry.repair })] }, `${entry.path}:${entry.message}`))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(undefined), children: "Remove invalid animation override" }), _jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit("auto"), children: "Replace invalid override with Auto" })] }), _jsx("p", { className: "text-neutral-500", children: "Replacement discards the invalid override and can be undone. Repair its saved fields through the existing marker metadata API to retain the intended graph." })] }));
132
+ if (graph === null || effective === null || frame === null || asset === undefined) {
133
+ return (_jsx(EmptyState, { icon: "film", title: "No graph", badge: marker.label ?? marker.id, description: "This placement plays no graph: its animation is off, a single clip, or the rig has no idle clip. Pick Custom or Auto in the inspector's Animation section." }));
134
+ }
135
+ const weights = new Map(frame.clips.map((clip) => [`${clip.layer}:${clip.clip}`, clip.weight]));
136
+ const trigger = (name) => setTriggers((current) => recordTrigger(current, name, timeRef.current));
137
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-7 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: marker.label ?? marker.id }), _jsxs("span", { className: "text-[10px] text-neutral-600", children: [asset.label, " \u00B7 ", SOURCE_LABEL[effective.source]] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: effective.source === "authored" ? (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(clearAnimGraph(setting)), title: "Drop the stored graph", children: "Use derived graph" })) : (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(storeAnimGraph(setting, graph)), title: "Store this graph on the placement", children: "Store graph" })) })] }), _jsxs("div", { className: "flex min-h-0 flex-1 gap-3 overflow-auto p-3", children: [_jsxs("div", { className: "flex w-64 shrink-0 flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause preview" : "Play preview", size: 14, active: playing, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => {
138
+ setPlaying(false);
139
+ setTime(0);
140
+ setTriggers([]);
141
+ }, children: "Reset" }), _jsxs("span", { className: `ml-auto text-[10px] text-neutral-400 ${NUMERIC}`, children: [time.toFixed(2), " s"] })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Scrub" }), _jsx("input", { type: "range", min: 0, max: MAX_PREVIEW_SECONDS, step: 0.01, value: time, "aria-label": "Graph preview time", className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => {
142
+ setPlaying(false);
143
+ setTime(Number(event.target.value));
144
+ } }), _jsx(TriggerTicks, { triggers: triggers })] }), controls.map((control) => control.kind === "boolean" ? (_jsxs("label", { className: "flex items-center gap-2 text-[11px] text-neutral-300", children: [_jsx("input", { type: "checkbox", checked: params[control.name] === true, "aria-label": `Parameter ${control.name}`, onChange: (event) => setParams((current) => ({ ...current, [control.name]: event.target.checked })) }), control.name] }, control.name)) : (_jsxs("label", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between text-[9px] uppercase tracking-wider text-neutral-600", children: [_jsx("span", { children: control.name }), _jsx("span", { className: NUMERIC, children: Number(params[control.name] ?? 0).toFixed(2) })] }), _jsx("input", { type: "range", min: control.min, max: control.max, step: (control.max - control.min) / 200, value: Number(params[control.name] ?? 0), "aria-label": `Parameter ${control.name}`, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => setParams((current) => ({ ...current, [control.name]: Number(event.target.value) })) })] }, control.name))), triggerNames.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Triggers at the playhead" }), _jsx("div", { className: "flex flex-wrap gap-1", children: triggerNames.map((name) => (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => trigger(name), children: name }, name))) })] })) : null, frame.events.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Clip events" }), frame.events.slice(-6).map((event, index) => (_jsxs("span", { className: `text-[10px] text-amber-200/80 ${NUMERIC}`, children: [event.at.toFixed(2), " s \u00B7 ", event.name, " (", event.clip, ")"] }, index)))] })) : null] }), _jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: graph.layers.map((layer) => (_jsxs("section", { className: "rounded-[6px] border border-white/[0.07] bg-white/[0.02]", "aria-label": `Layer ${layer.id}`, children: [_jsxs("header", { className: "flex items-center gap-2 border-b border-white/[0.06] px-2.5 py-1.5", children: [_jsx("span", { className: "text-[11px] font-medium text-neutral-100", children: layer.id }), _jsxs("span", { className: "text-[10px] text-neutral-500", children: ["entry ", layer.entry] }), layer.mask !== undefined ? _jsxs(Chip, { children: ["mask ", layer.mask.join(", ")] }) : null, layer.additive === true ? _jsx(Chip, { children: "additive" }) : null, layer.weight !== undefined ? _jsxs(Chip, { children: ["weight ", layer.weight] }) : null, _jsxs("span", { className: "ml-auto text-[10px] text-cyan-200", children: ["\u25B6 ", frame.states[layer.id]] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3 p-2.5", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "States" }), Object.entries(layer.states).map(([name, state]) => {
145
+ const active = frame.states[layer.id] === name;
146
+ const clipsOfState = state.kind === "clip" ? [state.clip] : state.points.map((point) => point.clip);
147
+ const weight = clipsOfState.reduce((sum, clip) => sum + (weights.get(`${layer.id}:${clip}`) ?? 0), 0);
148
+ return (_jsxs("div", { className: `rounded-[5px] border px-2 py-1 ${active ? "border-cyan-400/40 bg-cyan-500/10" : "border-white/[0.05]"}`, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[11px] text-neutral-100", children: name }), _jsx("span", { className: "text-[10px] text-neutral-500", children: state.kind }), _jsx("span", { className: `ml-auto text-[10px] text-neutral-400 ${NUMERIC}`, children: weight > 0 ? weight.toFixed(2) : "" })] }), _jsx("div", { className: "truncate text-[10px] text-neutral-500", title: stateClips(state), children: stateClips(state) }), _jsx("div", { className: "mt-1 h-1 rounded bg-white/[0.05]", children: _jsx("div", { className: "h-1 rounded bg-cyan-400/70", style: { width: `${Math.round(Math.min(1, weight) * 100)}%` } }) })] }, name));
149
+ })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Transitions" }), layer.transitions.length === 0 ? _jsx("span", { className: "text-[10px] text-neutral-600", children: "None" }) : null, layer.transitions.map((transition, index) => (_jsx(TransitionRow, { transition: transition, onTrigger: transition.trigger === undefined ? undefined : () => trigger(transition.trigger), onDuration: (duration) => commit(setTransitionDuration(setting, graph, layer.id, index, duration)) }, index)))] })] })] }, layer.id))) })] })] }));
150
+ }
151
+ const SMALL_BUTTON = `rounded-[5px] border border-white/[0.08] bg-white/[0.03] px-2 py-0.5 text-[10px] text-neutral-300 transition-colors hover:bg-white/[0.07] ${FOCUS_RING}`;
152
+ function Chip({ children }) {
153
+ return _jsx("span", { className: "rounded-[4px] bg-white/[0.05] px-1.5 py-0.5 text-[9px] text-neutral-400", children: children });
154
+ }
155
+ function TriggerTicks({ triggers }) {
156
+ if (triggers.length === 0)
157
+ return null;
158
+ return (_jsx("div", { className: "relative h-3", "aria-label": "Recorded triggers", children: triggers.map((entry, index) => (_jsx("span", { title: `${entry.name} at ${entry.at.toFixed(2)} s`, className: "absolute top-0 h-3 w-0.5 bg-amber-300/80", style: { left: `${(entry.at / MAX_PREVIEW_SECONDS) * 100}%` } }, index))) }));
159
+ }
160
+ function TransitionRow({ transition, onTrigger, onDuration, }) {
161
+ const [draft, setDraft] = useState(String(transition.duration ?? 0.2));
162
+ useEffect(() => setDraft(String(transition.duration ?? 0.2)), [transition.duration]);
163
+ const label = `${transition.from} → ${transition.to}`;
164
+ return (_jsxs("div", { className: "flex items-center gap-1.5 rounded-[5px] border border-white/[0.05] px-2 py-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-[11px] text-neutral-100", children: label }), _jsx("div", { className: "truncate text-[10px] text-neutral-500", children: [
165
+ transition.trigger === undefined ? null : `trigger ${transition.trigger}`,
166
+ ...(transition.when ?? []).map(conditionText),
167
+ transition.exitTime === undefined ? null : `exit ${transition.exitTime}`,
168
+ ]
169
+ .filter((part) => part !== null)
170
+ .join(" · ") || "always" })] }), _jsx("input", { className: `${INPUT_CLS} h-6 w-14 px-1 text-right text-[11px] ${NUMERIC}`, value: draft, "aria-label": `Crossfade seconds for ${label}`, inputMode: "decimal", onChange: (event) => setDraft(event.target.value), onBlur: () => {
171
+ const value = Number(draft);
172
+ if (Number.isFinite(value) && value !== (transition.duration ?? 0.2))
173
+ onDuration(value);
174
+ else
175
+ setDraft(String(transition.duration ?? 0.2));
176
+ }, onKeyDown: (event) => {
177
+ if (event.key === "Enter")
178
+ event.target.blur();
179
+ } }), _jsx("span", { className: "text-[9px] text-neutral-600", children: "s" }), onTrigger !== undefined ? _jsx(IconButton, { icon: "play", label: `Trigger ${transition.trigger}`, size: 11, onClick: onTrigger }) : null] }));
180
+ }
@@ -3,7 +3,9 @@ import type { EditorAssetEntry } from "../AssetBrowser.js";
3
3
  import type { EditorHostApi } from "../session.js";
4
4
  import type { EditorUiStore } from "../uiStore.js";
5
5
  /**
6
- * Animation dock with two modes:
6
+ * Animation dock with three modes:
7
+ * - **Graph** — the selected placement's animation graph with a scrubbed, triggerable preview posed
8
+ * on the rig in the viewport (see {@link AnimationGraphPanel}).
7
9
  * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
8
10
  * no fabricated keyframes).
9
11
  * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
4
  import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
5
5
  import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
6
+ import { AnimationGraphPanel } from "./AnimationGraphPanel.js";
6
7
  import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
7
8
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
8
9
  import { EmptyState, IconButton } from "./ui.js";
@@ -13,7 +14,9 @@ function riggedAssets(assets) {
13
14
  return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
14
15
  }
15
16
  /**
16
- * Animation dock with two modes:
17
+ * Animation dock with three modes:
18
+ * - **Graph** — the selected placement's animation graph with a scrubbed, triggerable preview posed
19
+ * on the rig in the viewport (see {@link AnimationGraphPanel}).
17
20
  * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
18
21
  * no fabricated keyframes).
19
22
  * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
@@ -23,9 +26,7 @@ function riggedAssets(assets) {
23
26
  */
24
27
  export function AnimationPanel({ session, api, ui, assets, }) {
25
28
  const rigged = useMemo(() => riggedAssets(assets), [assets]);
26
- // Default to clips only when the scene has rigged assets but no scrubbable path yet.
27
- const paths = useStoreSelector(session, (state) => state.document.paths);
28
- const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
29
+ const [mode, setMode] = useState(() => (rigged.length > 0 ? "graph" : "path"));
29
30
  // Tear down the viewport preview when the panel unmounts; leaving clip mode tears down in the
30
31
  // tab click below.
31
32
  useEffect(() => () => {
@@ -33,13 +34,14 @@ export function AnimationPanel({ session, api, ui, assets, }) {
33
34
  ui.patch({ clipPreview: null });
34
35
  }, [ui]);
35
36
  return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: _jsx("div", { className: "flex items-center gap-0.5", role: "tablist", "aria-label": "Animation mode", children: [
36
- ["path", "Path flythrough"],
37
+ ["graph", "Graph"],
37
38
  ["clips", "Clip preview"],
39
+ ["path", "Path flythrough"],
38
40
  ].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === id, className: `rounded-[5px] px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${mode === id ? "bg-cyan-500/15 text-cyan-200" : "text-neutral-500 hover:text-neutral-300"}`, onClick: () => {
39
- if (id !== "clips" && ui.getState().clipPreview !== null)
41
+ if (id !== mode && ui.getState().clipPreview !== null)
40
42
  ui.patch({ clipPreview: null });
41
43
  setMode(id);
42
- }, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
44
+ }, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : mode === "graph" ? (_jsx(AnimationGraphPanel, { session: session, ui: ui, rigged: rigged })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
43
45
  }
44
46
  /** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
45
47
  function ClipPreview({ session, ui, rigged, }) {
@@ -12,8 +12,9 @@ export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
12
12
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
13
13
  * @internal
14
14
  */
15
- export declare function PlayModeBar({ gameId, api, onExit, }: {
15
+ export declare function PlayModeBar({ gameId, api, onExit, exitLabel, }: {
16
16
  gameId: string;
17
17
  api: EditorHostApi;
18
18
  onExit: () => void;
19
+ exitLabel?: string;
19
20
  }): import("react").JSX.Element;
@@ -19,7 +19,7 @@ export function usePlayControl(api) {
19
19
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
20
20
  * @internal
21
21
  */
22
- export function PlayModeBar({ gameId, api, onExit, }) {
22
+ export function PlayModeBar({ gameId, api, onExit, exitLabel = "Editor", }) {
23
23
  const play = usePlayControl(api);
24
24
  const pause = () => {
25
25
  api.handle({ method: "runtime_pause" });
@@ -32,5 +32,5 @@ export function PlayModeBar({ gameId, api, onExit, }) {
32
32
  };
33
33
  return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, role: "banner", "aria-label": "Play mode", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-emerald-500/30 to-cyan-500/25 text-[11px] font-bold tracking-wide text-emerald-200 ring-1 ring-inset ring-emerald-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Play mode" })] }), _jsxs("span", { className: `ml-1 hidden items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:flex ${play.paused
34
34
  ? "bg-amber-500/15 text-amber-200 ring-1 ring-inset ring-amber-400/25"
35
- : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), "Editor"] })] })] }));
35
+ : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), exitLabel] })] })] }));
36
36
  }
@@ -8,7 +8,7 @@ export declare const ADD_VOLUME_ENTRIES: readonly {
8
8
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
9
9
  * snapping, viewport overlays, framing, projection, and the Add menu.
10
10
  */
11
- export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }: {
11
+ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }: {
12
12
  tool: EditorTool;
13
13
  gizmoMode: GizmoMode;
14
14
  gizmoSpace: GizmoSpace;
@@ -40,4 +40,5 @@ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot,
40
40
  onStartPlacement: (tool: PlacementTool) => void;
41
41
  onOpenAssistant: () => void;
42
42
  assistantOpen: boolean;
43
+ allowedKinds?: readonly string[];
43
44
  }): import("react").JSX.Element;
@@ -70,7 +70,7 @@ const PIVOT_LABEL = {
70
70
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
71
71
  * snapping, viewport overlays, framing, projection, and the Add menu.
72
72
  */
73
- export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }) {
73
+ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }) {
74
74
  const [addOpen, setAddOpen] = useState(false);
75
75
  const [snapOpen, setSnapOpen] = useState(false);
76
76
  const [pivotOpen, setPivotOpen] = useState(false);
@@ -79,7 +79,8 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
79
79
  lastSnapRef.current = snapMode;
80
80
  // Recomputed every render (opening the menu rerenders): studios may register after the chrome
81
81
  // mounts (game modules load lazily), and a once-on-mount snapshot would silently hide them.
82
- const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined);
82
+ const allowed = (kind) => allowedKinds === undefined || allowedKinds.includes(kind);
83
+ const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined && allowed(definition.kind));
83
84
  const pick = (placement) => {
84
85
  setAddOpen(false);
85
86
  onStartPlacement(placement);
@@ -112,7 +113,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
112
113
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
113
114
  : "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
114
115
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
115
- : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
+ : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.filter(allowed).map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.filter((entry) => ("kind" in entry.tool && allowed(entry.tool.kind))).map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
117
  const category = definition.addCategory ?? "Studios";
117
118
  (groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
118
119
  return groups;
@@ -120,7 +121,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
120
121
  ? { tool: "path", kind: definition.kind }
121
122
  : definition.target === "volume"
122
123
  ? { tool: "volume", kind: definition.kind, shape: "box" }
123
- : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
+ : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", disabled: !allowed("route"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", disabled: !allowed("road"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
125
  ? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
125
126
  : "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
126
127
  }
@@ -8,10 +8,11 @@ export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
8
8
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
9
9
  * runtime pause/step RPCs so mode switches keep shell chrome.
10
10
  */
11
- export declare function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
11
+ export declare function TopAppBar({ gameId, onExitToGame, exitLabel, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
12
12
  gameId: string;
13
13
  /** When set (editor summoned over a game), renders an "Exit to game" control that closes the editor. */
14
14
  onExitToGame?: () => void;
15
+ exitLabel?: string;
15
16
  dirty: boolean;
16
17
  saveState: TopBarSaveState;
17
18
  /** Epoch ms of the last successful save this session; omit/null when never saved. */
@@ -34,7 +34,7 @@ function SaveStatus({ dirty, saveState, lastSavedAt, }) {
34
34
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
35
35
  * runtime pause/step RPCs so mode switches keep shell chrome.
36
36
  */
37
- export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
37
+ export function TopAppBar({ gameId, onExitToGame, exitLabel = "Exit to game", dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
38
38
  const [menuOpen, setMenuOpen] = useState(false);
39
39
  const menuRef = useRef(null);
40
40
  useEffect(() => {
@@ -56,7 +56,7 @@ export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt
56
56
  };
57
57
  }, [menuOpen]);
58
58
  const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
59
- return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: "Close the editor and return to the game (F2+Q)", className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), "Exit to game"] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
59
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: `${exitLabel} (F2+Q)`, className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), exitLabel] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
60
60
  ? "bg-rose-600/90 text-white hover:bg-rose-500"
61
61
  : dirty
62
62
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
@@ -8,7 +8,7 @@ export const WORKSPACES = [
8
8
  { id: "assets", label: "Assets", icon: "image", supported: true },
9
9
  { id: "materials", label: "Materials", icon: "sphere", supported: true },
10
10
  { id: "scripting", label: "Scripting", icon: "script", supported: true },
11
- { id: "animation", label: "Animation", icon: "film", supported: false },
11
+ { id: "animation", label: "Animation", icon: "film", supported: true },
12
12
  { id: "audio", label: "Audio", icon: "audio", supported: false },
13
13
  { id: "lighting", label: "Lighting", icon: "bulb", supported: true },
14
14
  { id: "ai", label: "AI", icon: "sparkle", supported: true },
@@ -0,0 +1,58 @@
1
+ import { type AnimClipOutput, type AnimGraph, type AnimParams } from "@jgengine/core/anim/animGraph";
2
+ /**
3
+ * Editor animation-graph preview: pure logic, no JSX. Replays a graph from its entry states to a
4
+ * scrub time with fixed steps, arming each recorded trigger at its time, so scrubbing back and forth
5
+ * always shows the same pose the runtime would reach.
6
+ *
7
+ * @internal
8
+ */
9
+ /** A trigger pressed during preview, at a time on the scrub timeline. */
10
+ export interface GraphPreviewTrigger {
11
+ at: number;
12
+ name: string;
13
+ }
14
+ /** Inputs for {@link simulateGraphPreview}. */
15
+ export interface GraphPreviewInput {
16
+ graph: AnimGraph;
17
+ /** Clip durations in seconds from the loaded rig; unknown clips count as one second. */
18
+ durations: Readonly<Record<string, number>>;
19
+ time: number;
20
+ params: AnimParams;
21
+ triggers: readonly GraphPreviewTrigger[];
22
+ /** Fixed step in seconds. Default 1/30. */
23
+ step?: number;
24
+ }
25
+ /** What the rig shows at the scrub time. */
26
+ export interface GraphPreviewFrame {
27
+ time: number;
28
+ /** Current state per layer id. */
29
+ states: Record<string, string>;
30
+ clips: AnimClipOutput[];
31
+ /** Clip events fired from zero to `time`, with when they fired. */
32
+ events: {
33
+ at: number;
34
+ name: string;
35
+ clip: string;
36
+ }[];
37
+ /** Active root-motion playback stays in place in the editor, matching the collision-authoritative shell. */
38
+ rootMotion?: true;
39
+ }
40
+ /** Longest preview timeline the scrubber offers, in seconds. */
41
+ export declare const MAX_PREVIEW_SECONDS = 20;
42
+ /** Replays `graph` from its entry states to `time` and reports the pose there. */
43
+ export declare function simulateGraphPreview(input: GraphPreviewInput): GraphPreviewFrame;
44
+ /** Every clip name a graph plays. */
45
+ export declare function graphClipNames(graph: AnimGraph): string[];
46
+ /** Every trigger name a graph's transitions listen for, in first-seen order. */
47
+ export declare function graphTriggers(graph: AnimGraph): string[];
48
+ /** A parameter the preview needs a control for: blend inputs and condition operands. */
49
+ export interface GraphParamControl {
50
+ name: string;
51
+ kind: "number" | "boolean";
52
+ min: number;
53
+ max: number;
54
+ }
55
+ /** Parameters a graph reads, with a range covering its blend points and condition values. */
56
+ export declare function graphParamControls(graph: AnimGraph): GraphParamControl[];
57
+ /** Adds a trigger press at `at`, keeping the log in time order. */
58
+ export declare function recordTrigger(triggers: readonly GraphPreviewTrigger[], name: string, at: number): GraphPreviewTrigger[];
@@ -0,0 +1,95 @@
1
+ import { createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
2
+ /** Longest preview timeline the scrubber offers, in seconds. */
3
+ export const MAX_PREVIEW_SECONDS = 20;
4
+ /** Replays `graph` from its entry states to `time` and reports the pose there. */
5
+ export function simulateGraphPreview(input) {
6
+ const step = input.step ?? 1 / 30;
7
+ const time = Math.max(0, Math.min(MAX_PREVIEW_SECONDS, input.time));
8
+ const runtime = createAnimGraphRuntime(input.graph);
9
+ const clips = {};
10
+ for (const name of graphClipNames(input.graph))
11
+ clips[name] = input.durations[name] ?? 1;
12
+ const pending = [...input.triggers].filter((trigger) => trigger.at <= time).sort((a, b) => a.at - b.at);
13
+ const events = [];
14
+ let elapsed = 0;
15
+ let next = 0;
16
+ let out = runtime.advance(0, input.params, clips);
17
+ while (elapsed < time - 1e-9) {
18
+ const dt = Math.min(step, time - elapsed);
19
+ while (next < pending.length && pending[next].at <= elapsed + 1e-9)
20
+ runtime.trigger(pending[next++].name);
21
+ out = runtime.advance(dt, input.params, clips);
22
+ elapsed += dt;
23
+ for (const event of out.events)
24
+ events.push({ at: elapsed, name: event.name, clip: event.clip });
25
+ }
26
+ if (next < pending.length) {
27
+ while (next < pending.length)
28
+ runtime.trigger(pending[next++].name);
29
+ out = runtime.advance(0, input.params, clips);
30
+ }
31
+ const states = {};
32
+ for (const layer of input.graph.layers)
33
+ states[layer.id] = runtime.stateOf(layer.id) ?? layer.entry;
34
+ return { time, states, clips: out.clips, events, ...(out.rootMotion === true ? { rootMotion: true } : {}) };
35
+ }
36
+ /** Every clip name a graph plays. */
37
+ export function graphClipNames(graph) {
38
+ const names = new Set();
39
+ for (const layer of graph.layers) {
40
+ for (const state of Object.values(layer.states)) {
41
+ if (state.kind === "clip") {
42
+ names.add(state.clip);
43
+ for (const clip of state.variants ?? [])
44
+ names.add(clip);
45
+ }
46
+ else
47
+ for (const point of state.points)
48
+ names.add(point.clip);
49
+ }
50
+ }
51
+ return [...names];
52
+ }
53
+ /** Every trigger name a graph's transitions listen for, in first-seen order. */
54
+ export function graphTriggers(graph) {
55
+ const names = new Set();
56
+ for (const layer of graph.layers)
57
+ for (const transition of layer.transitions)
58
+ if (transition.trigger !== undefined)
59
+ names.add(transition.trigger);
60
+ return [...names];
61
+ }
62
+ /** Parameters a graph reads, with a range covering its blend points and condition values. */
63
+ export function graphParamControls(graph) {
64
+ const controls = new Map();
65
+ const widen = (name, value) => {
66
+ const kind = typeof value === "boolean" ? "boolean" : "number";
67
+ const current = controls.get(name) ?? { name, kind, min: 0, max: 1 };
68
+ if (kind === "number") {
69
+ current.min = Math.min(current.min, value);
70
+ current.max = Math.max(current.max, value * 1.25, value + 0.5);
71
+ }
72
+ controls.set(name, current);
73
+ };
74
+ for (const layer of graph.layers) {
75
+ for (const state of Object.values(layer.states)) {
76
+ if (state.kind === "blend1D")
77
+ for (const point of state.points)
78
+ widen(state.param, point.at);
79
+ if (state.kind === "blend2D") {
80
+ for (const point of state.points) {
81
+ widen(state.params[0], point.at[0]);
82
+ widen(state.params[1], point.at[1]);
83
+ }
84
+ }
85
+ }
86
+ for (const transition of layer.transitions)
87
+ for (const condition of transition.when ?? [])
88
+ widen(condition.param, condition.value);
89
+ }
90
+ return [...controls.values()];
91
+ }
92
+ /** Adds a trigger press at `at`, keeping the log in time order. */
93
+ export function recordTrigger(triggers, name, at) {
94
+ return [...triggers, { at, name }].sort((a, b) => a.at - b.at);
95
+ }
@@ -1,3 +1,4 @@
1
+ import type { AnimClipOutput, AnimGraph } from "@jgengine/core/anim/animGraph";
1
2
  import { type ClipRole } from "@jgengine/core/game/clipRoles";
2
3
  import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
3
4
  /**
@@ -39,6 +40,14 @@ export interface ClipPreviewSession {
39
40
  readonly source: ClipPreviewSource;
40
41
  readonly driver: ClipPreviewState;
41
42
  readonly duration: number;
43
+ /** Every clip's duration, published by the viewport layer once the GLB loads. */
44
+ readonly clipDurations?: Readonly<Record<string, number>>;
45
+ /** When set, the viewport poses the rig from this graph output instead of playing `driver`'s clip. */
46
+ readonly graphPose?: {
47
+ readonly graph: AnimGraph;
48
+ readonly clips: readonly AnimClipOutput[];
49
+ readonly rootMotion?: true;
50
+ };
42
51
  }
43
52
  /** Playback state of the clip preview driver. `time` is seconds into the clip. */
44
53
  export interface ClipPreviewState {
@@ -0,0 +1,20 @@
1
+ import type { EditorSession } from "@jgengine/core/editor/commands";
2
+ /** Document save lifecycle owned by the mounted authoring session, across viewport modes. */
3
+ export interface EditorDocumentSave {
4
+ available: boolean;
5
+ dirty: boolean;
6
+ saveState: "idle" | "saving" | "saved" | "error";
7
+ saveError: string | null;
8
+ lastSavedAt: number | null;
9
+ doSave(): void;
10
+ }
11
+ /** Save status shared with editor chrome without changing the viewport component identity. @internal */
12
+ export declare const EditorDocumentSaveContext: import("react").Context<EditorDocumentSave | undefined>;
13
+ type SaveFn = (json: string) => Promise<{
14
+ ok: boolean;
15
+ path?: string;
16
+ error?: string;
17
+ }>;
18
+ /** Keep the acknowledged document and pending/error status alive while editor chrome unmounts. @internal */
19
+ export declare function useDocumentSave(session: EditorSession, save: SaveFn | undefined, onResult?: (ok: boolean, detail: string) => void): EditorDocumentSave;
20
+ export {};