@jgengine/editor 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/EditorApp.js +2 -2
  3. package/dist/EditorCameraDriver.d.ts +6 -1
  4. package/dist/EditorCameraDriver.js +25 -1
  5. package/dist/EditorChrome.js +2 -4
  6. package/dist/InspectorPanel.js +12 -1
  7. package/dist/ParentPickerMenu.js +2 -6
  8. package/dist/RuntimePlayBridge.js +10 -15
  9. package/dist/SchemaInspector.js +55 -29
  10. package/dist/TerrainPanel.js +3 -4
  11. package/dist/TerrainReadout.js +1 -3
  12. package/dist/agent/AgentPanel.js +7 -11
  13. package/dist/camera/orbitFraming.d.ts +62 -0
  14. package/dist/camera/orbitFraming.js +69 -0
  15. package/dist/chromeFields.js +9 -4
  16. package/dist/handlers/document.d.ts +1 -1
  17. package/dist/handlers/document.js +78 -9
  18. package/dist/mcp/fieldSpec.js +4 -2
  19. package/dist/mcp/tools.js +4 -2
  20. package/dist/session.d.ts +54 -15
  21. package/dist/shell/AnimationPanel.js +14 -20
  22. package/dist/shell/CommandPalette.js +1 -5
  23. package/dist/shell/HierarchyPanel.js +17 -26
  24. package/dist/shell/PlayModeBar.d.ts +7 -0
  25. package/dist/shell/PlayModeBar.js +12 -3
  26. package/dist/shell/SceneToolbar.js +9 -5
  27. package/dist/shell/fields.js +8 -8
  28. package/dist/shell/glbThumbnailCache.js +39 -8
  29. package/dist/shell/scatterCoverage.js +10 -1
  30. package/dist/useStoreSelector.d.ts +12 -0
  31. package/dist/useStoreSelector.js +23 -0
  32. package/package.json +5 -5
  33. package/skills/ce-handoff/SKILL.md +149 -0
  34. package/skills/jgengine/SKILL.md +3 -1
  35. package/skills/jgengine/api.md +17 -7
  36. package/skills/jgengine/capabilities.md +12 -0
  37. package/skills/jgengine/recipes/minimal-game.md +7 -4
  38. package/skills/jgengine-editor/api.md +16 -3
  39. package/skills/jgengine-editor/reference.md +44 -0
@@ -2,6 +2,20 @@ import { editorDocumentBounds, listEditorKinds, summarizeEditorSession, } from "
2
2
  import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
3
3
  import { findSchemaPreset, getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
4
4
  import { EDITOR_RUN_MODES } from "../session.js";
5
+ import { boundsCenter, frameDistanceForBounds } from "../camera/orbitFraming.js";
6
+ /** Collect the finite placement fields present on a camera request into a focus-target patch. */
7
+ function cameraPlacement(request) {
8
+ const placement = {};
9
+ if (typeof request.distance === "number" && Number.isFinite(request.distance))
10
+ placement.distance = request.distance;
11
+ if (typeof request.pitch === "number" && Number.isFinite(request.pitch))
12
+ placement.pitch = request.pitch;
13
+ if (typeof request.yaw === "number" && Number.isFinite(request.yaw))
14
+ placement.yaw = request.yaw;
15
+ if (typeof request.height === "number" && Number.isFinite(request.height))
16
+ placement.height = request.height;
17
+ return placement;
18
+ }
5
19
  /** True when any of `ids` names a placeable object (marker/volume/path/note) in the document — used
6
20
  * to reject batch mutations that would match nothing, so the caller hears an honest `ok:false`. */
7
21
  function documentHasAnyId(doc, ids) {
@@ -134,6 +148,49 @@ export const documentHandlers = {
134
148
  });
135
149
  return { ok: true, result: ctx.session.getState().document.paths.find((p) => p.id === request.id) };
136
150
  },
151
+ add_path: (ctx, request) => {
152
+ if (request.points.length < 2)
153
+ return { ok: false, error: "add_path needs at least 2 points" };
154
+ const kind = request.kind ?? "route";
155
+ const invalid = validateMetaForKind(kind, request.meta);
156
+ if (invalid !== null)
157
+ return { ok: false, error: invalid };
158
+ const path = {
159
+ id: request.id,
160
+ kind,
161
+ points: request.points.map((point) => ({ x: point.x, y: point.y ?? 0, z: point.z })),
162
+ ...(request.width === undefined ? {} : { width: request.width }),
163
+ ...(request.color === undefined ? {} : { color: request.color }),
164
+ ...(request.label === undefined ? {} : { label: request.label }),
165
+ ...(request.meta === undefined ? {} : { meta: request.meta }),
166
+ };
167
+ // addPath re-ids on a document-global collision, so read the created id back from the selection.
168
+ const { applied, state } = ctx.dispatchGuarded({ type: "addPath", path });
169
+ if (!applied)
170
+ return { ok: false, error: "add_path rejected: no effect" };
171
+ const createdId = state.selection[0] ?? request.id;
172
+ return { ok: true, result: state.document.paths.find((p) => p.id === createdId) };
173
+ },
174
+ add_marker: (ctx, request) => {
175
+ const invalid = validateMetaForKind(request.kind, request.meta);
176
+ if (invalid !== null)
177
+ return { ok: false, error: invalid };
178
+ const marker = {
179
+ id: request.id,
180
+ kind: request.kind,
181
+ position: { x: request.x, y: request.y ?? 0, z: request.z },
182
+ ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
183
+ ...(request.color === undefined ? {} : { color: request.color }),
184
+ ...(request.label === undefined ? {} : { label: request.label }),
185
+ ...(request.meta === undefined ? {} : { meta: request.meta }),
186
+ };
187
+ // addMarker re-ids on a document-global collision, so read the created id back from the selection.
188
+ const { applied, state } = ctx.dispatchGuarded({ type: "addMarker", marker });
189
+ if (!applied)
190
+ return { ok: false, error: "add_marker rejected: no effect" };
191
+ const createdId = state.selection[0] ?? request.id;
192
+ return { ok: true, result: state.document.markers.find((m) => m.id === createdId) };
193
+ },
137
194
  set_marker: (ctx, request) => {
138
195
  const marker = ctx.session.getState().document.markers.find((m) => m.id === request.id);
139
196
  if (marker === undefined)
@@ -218,6 +275,7 @@ export const documentHandlers = {
218
275
  return { ok: true, result: { selection: [] } };
219
276
  },
220
277
  camera_goto: (ctx, request) => {
278
+ const placement = cameraPlacement(request);
221
279
  if (request.id !== undefined) {
222
280
  const state = ctx.session.getState();
223
281
  const marker = state.document.markers.find((m) => m.id === request.id);
@@ -225,26 +283,32 @@ export const documentHandlers = {
225
283
  const point = marker?.position ?? volume?.center;
226
284
  if (point === undefined)
227
285
  return { ok: false, error: `id not found: ${request.id}` };
228
- ctx.api.setFocusTarget({ ...point });
286
+ const target = { x: point.x, y: point.y, z: point.z, ...placement };
287
+ ctx.api.setFocusTarget(target);
229
288
  ctx.session.dispatch({ type: "select", ids: [request.id] });
230
- return { ok: true, result: { target: point } };
289
+ return { ok: true, result: { target } };
231
290
  }
232
291
  if (request.x === undefined || request.z === undefined) {
233
292
  return { ok: false, error: "camera_goto requires id or x/z" };
234
293
  }
235
- const target = { x: request.x, y: request.y ?? 0, z: request.z };
294
+ const target = { x: request.x, y: request.y ?? 0, z: request.z, ...placement };
236
295
  ctx.api.setFocusTarget(target);
237
296
  return { ok: true, result: { target } };
238
297
  },
239
- camera_frame: (ctx) => {
298
+ camera_frame: (ctx, request) => {
240
299
  const bounds = editorDocumentBounds(ctx.session.getState().document);
241
300
  if (bounds === null)
242
301
  return { ok: false, error: "document is empty" };
243
- const target = {
244
- x: (bounds.min.x + bounds.max.x) / 2,
245
- y: (bounds.min.y + bounds.max.y) / 2,
246
- z: (bounds.min.z + bounds.max.z) / 2,
247
- };
302
+ const center = boundsCenter(bounds);
303
+ const placement = cameraPlacement(request);
304
+ // An aerial is requested when the caller supplies any camera placement. Fit the region into the
305
+ // camera's FOV when no explicit distance is given, so one call frames a whole district from
306
+ // above instead of burying the camera at the last-used distance.
307
+ const wantsAerial = Object.keys(placement).length > 0;
308
+ const resolved = wantsAerial && placement.distance === undefined
309
+ ? { ...placement, distance: frameDistanceForBounds(bounds, { fovDeg: 50 }) }
310
+ : placement;
311
+ const target = { ...center, ...resolved };
248
312
  ctx.api.setFocusTarget(target);
249
313
  return { ok: true, result: { target, bounds } };
250
314
  },
@@ -259,6 +323,11 @@ export const documentHandlers = {
259
323
  }),
260
324
  export_document: (ctx) => ({ ok: true, result: { json: ctx.session.exportJson(true) } }),
261
325
  import_document: (ctx, request) => {
326
+ // Name the expected param instead of letting a missing/mis-keyed value fall into JSON.parse as
327
+ // the literal string "undefined" and surface a cryptic "JSON Parse error: Unexpected identifier".
328
+ if (typeof request.json !== "string") {
329
+ return { ok: false, error: "import_document requires a `json` param — the document JSON text (as export_document returns in result.json)" };
330
+ }
262
331
  ctx.session.dispatch({ type: "importJson", json: request.json });
263
332
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
264
333
  },
@@ -44,14 +44,16 @@ export const RPC_FIELD_SCHEMAS = {
44
44
  set_transform: [req(s("id")), n("x"), n("y"), n("z"), n("rotationY")],
45
45
  set_volume: [req(s("id")), n("radius"), n("height"), n("x"), n("y"), n("z")],
46
46
  set_path: [req(s("id")), s("kind"), n("width"), s("color"), s("label"), o("meta")],
47
+ add_path: [req(s("id")), req(oa("points", [req(n("x")), n("y"), req(n("z"))])), s("kind"), n("width"), s("color"), s("label"), o("meta")],
48
+ add_marker: [req(s("id")), req(s("kind")), req(n("x")), n("y"), req(n("z")), s("color"), s("label"), n("rotationY"), o("meta")],
47
49
  set_marker: [req(s("id")), s("kind"), s("color"), s("label"), n("rotationY"), o("meta")],
48
50
  set_note: [req(s("id")), s("text"), o("meta")],
49
51
  set_meta: [req(s("id")), req(o("patch"))],
50
52
  apply_preset: [req(s("id")), req(s("preset"))],
51
53
  select: [req(sa("ids"))],
52
54
  clear_selection: [],
53
- camera_goto: [s("id"), n("x"), n("y"), n("z")],
54
- camera_frame: [],
55
+ camera_goto: [s("id"), n("x"), n("y"), n("z"), n("distance"), n("pitch"), n("yaw"), n("height")],
56
+ camera_frame: [n("distance"), n("pitch"), n("yaw"), n("height")],
55
57
  scene_summary: [],
56
58
  export_document: [],
57
59
  import_document: [req(s("json"))],
package/dist/mcp/tools.js CHANGED
@@ -22,13 +22,15 @@ const TOOL_DESCRIPTIONS = [
22
22
  { name: "set_transform", description: "Move a marker or volume center by id." },
23
23
  { name: "set_volume", description: "Patch a volume radius/height/center." },
24
24
  { name: "set_path", description: "Patch a path's kind/width/color/label and merge-patch its meta (studio sliders: scatter density, pole spacing, …). Validated against the kind schema." },
25
+ { name: "add_path", description: "Author a new path/route into the scene document from an ordered list of ≥2 {x,z} (optional y) points — the headless one-shot for placing a route without an export/import roundtrip. kind defaults to `route`; width/color/label/meta are optional and meta is validated against the kind schema. Re-ids on a document-global id collision." },
26
+ { name: "add_marker", description: "Author a new point marker into the scene document at x/z (optional y) — the headless one-shot for placing a logical marker (spawn, checkpoint, pickup, bounty, or a game's own custom kind) without an export/import roundtrip. kind is required; color/label/rotationY/meta are optional and meta is validated against the kind schema when registered. Re-ids on a document-global id collision." },
25
27
  { name: "set_marker", description: "Patch a marker's kind/color/label/rotationY and merge-patch its meta. Validated against the kind schema when registered." },
26
28
  { name: "set_note", description: "Patch a note's text and merge-patch its meta." },
27
29
  { name: "set_meta", description: "Merge-patch the meta bag of any document object (marker/volume/path/note) by id — the generic studio-slider primitive. Rejected if it violates the kind's param schema." },
28
30
  { name: "apply_preset", description: "Apply a named preset bundle from the object's kind schema (e.g. a city archetype like manhattan/ruralohio) as one meta patch, ready to tweak afterwards." },
29
31
  { name: "select", description: "Select editor object ids." },
30
- { name: "camera_goto", description: "Focus the editor camera on an id or world x/z." },
31
- { name: "camera_frame", description: "Frame the whole editor document." },
32
+ { name: "camera_goto", description: "Focus the editor camera on an id or world x/z. Optional distance/pitch/yaw/height reposition the orbit camera (pitch is degrees above the horizon; 90 = straight-down aerial) so an aerial composes in one call." },
33
+ { name: "camera_frame", description: "Frame the whole editor document. Optional distance/pitch/yaw/height compose an aerial; when pitch is set without distance the region is auto-fit into view so the camera never buries in terrain." },
32
34
  { name: "scene_summary", description: "Compact summary of the editor document and bounds." },
33
35
  { name: "export_document", description: "Export the editor document as JSON text." },
34
36
  { name: "import_document", description: "Replace the session document from JSON text." },
package/dist/session.d.ts CHANGED
@@ -82,6 +82,30 @@ export type EditorBridgeRequest = {
82
82
  color?: string;
83
83
  label?: string;
84
84
  meta?: Record<string, unknown>;
85
+ } | {
86
+ method: "add_path";
87
+ id: string;
88
+ points: {
89
+ x: number;
90
+ y?: number;
91
+ z: number;
92
+ }[];
93
+ kind?: string;
94
+ width?: number;
95
+ color?: string;
96
+ label?: string;
97
+ meta?: Record<string, unknown>;
98
+ } | {
99
+ method: "add_marker";
100
+ id: string;
101
+ kind: string;
102
+ x: number;
103
+ y?: number;
104
+ z: number;
105
+ color?: string;
106
+ label?: string;
107
+ rotationY?: number;
108
+ meta?: Record<string, unknown>;
85
109
  } | {
86
110
  method: "set_marker";
87
111
  id: string;
@@ -114,8 +138,16 @@ export type EditorBridgeRequest = {
114
138
  x?: number;
115
139
  y?: number;
116
140
  z?: number;
141
+ distance?: number;
142
+ pitch?: number;
143
+ yaw?: number;
144
+ height?: number;
117
145
  } | {
118
146
  method: "camera_frame";
147
+ distance?: number;
148
+ pitch?: number;
149
+ yaw?: number;
150
+ height?: number;
119
151
  } | {
120
152
  method: "scene_summary";
121
153
  } | {
@@ -499,6 +531,25 @@ export interface EditorPerfSample {
499
531
  avgMs: number;
500
532
  }[];
501
533
  }
534
+ /**
535
+ * Where the editor orbit camera looks, plus optional placement so a single `camera_goto`/
536
+ * `camera_frame` can compose an aerial. `x/y/z` is the orbit target (pan-only when that is all that
537
+ * is set — the historical behavior); `distance`, `pitch` (degrees above the horizon), `yaw`
538
+ * (degrees), and `height` reposition the camera around that target when provided.
539
+ */
540
+ export interface EditorFocusTarget {
541
+ x: number;
542
+ y: number;
543
+ z: number;
544
+ /** Radial distance from the target to the camera, world units. */
545
+ distance?: number;
546
+ /** Elevation above the horizon, degrees (90 = straight-down aerial). */
547
+ pitch?: number;
548
+ /** Azimuth around the target, degrees. */
549
+ yaw?: number;
550
+ /** Explicit camera height above the target; overrides the pitch-derived height. */
551
+ height?: number;
552
+ }
502
553
  /** The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. */
503
554
  export interface EditorHostApi {
504
555
  gameId: string;
@@ -508,21 +559,9 @@ export interface EditorHostApi {
508
559
  getVisibility(): EditorKindVisibility;
509
560
  setVisibility(next: EditorKindVisibility): void;
510
561
  subscribeVisibility(listener: () => void): () => void;
511
- getFocusTarget(): {
512
- x: number;
513
- y: number;
514
- z: number;
515
- } | null;
516
- setFocusTarget(target: {
517
- x: number;
518
- y: number;
519
- z: number;
520
- } | null): void;
521
- subscribeFocus(listener: (target: {
522
- x: number;
523
- y: number;
524
- z: number;
525
- } | null) => void): () => void;
562
+ getFocusTarget(): EditorFocusTarget | null;
563
+ setFocusTarget(target: EditorFocusTarget | null): void;
564
+ subscribeFocus(listener: (target: EditorFocusTarget | null) => void): () => void;
526
565
  getAssets(): readonly EditorAssetInfo[];
527
566
  setAssets(assets: readonly EditorAssetInfo[]): void;
528
567
  getCatalogDefinitions(): readonly EditorCatalogDefinition[];
@@ -26,11 +26,8 @@ export function AnimationPanel({ session, api, ui, assets, }) {
26
26
  // Default to clips only when the scene has rigged assets but no scrubbable path yet.
27
27
  const paths = useStoreSelector(session, (state) => state.document.paths);
28
28
  const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
29
- // Tear down the viewport preview whenever the panel leaves clip mode or unmounts.
30
- useEffect(() => {
31
- if (mode !== "clips" && ui.getState().clipPreview !== null)
32
- ui.patch({ clipPreview: null });
33
- }, [mode, ui]);
29
+ // Tear down the viewport preview when the panel unmounts; leaving clip mode tears down in the
30
+ // tab click below.
34
31
  useEffect(() => () => {
35
32
  if (ui.getState().clipPreview !== null)
36
33
  ui.patch({ clipPreview: null });
@@ -38,7 +35,11 @@ export function AnimationPanel({ session, api, ui, assets, }) {
38
35
  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: [
39
36
  ["path", "Path flythrough"],
40
37
  ["clips", "Clip preview"],
41
- ].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: () => setMode(id), children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
38
+ ].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)
40
+ ui.patch({ clipPreview: null });
41
+ setMode(id);
42
+ }, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
42
43
  }
43
44
  /** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
44
45
  function ClipPreview({ session, ui, rigged, }) {
@@ -125,22 +126,15 @@ function PathFlythrough({ session, api }) {
125
126
  const [playing, setPlaying] = useState(false);
126
127
  const [loop, setLoop] = useState(true);
127
128
  const playRef = useRef({ playing: false, loop: true, progress: 0, pathId: null });
129
+ // selectedId is raw intent; a removed path falls back to the first scrubbable one at read time.
128
130
  const selected = scrubbable.find((entry) => entry.id === selectedId) ?? scrubbable[0] ?? null;
129
131
  const selectedPath = selected === null ? undefined : paths.find((path) => path.id === selected.id);
130
- useEffect(() => {
131
- if (selected !== null && selectedId !== selected.id)
132
- setSelectedId(selected.id);
133
- if (selected === null && selectedId !== null)
134
- setSelectedId(null);
135
- }, [selected, selectedId]);
136
- useEffect(() => {
137
- playRef.current = {
138
- playing,
139
- loop,
140
- progress,
141
- pathId: selected?.id ?? null,
142
- };
143
- }, [playing, loop, progress, selected?.id]);
132
+ playRef.current = {
133
+ playing,
134
+ loop,
135
+ progress,
136
+ pathId: selected?.id ?? null,
137
+ };
144
138
  const applyProgress = useCallback((next, pathId) => {
145
139
  const path = session.getState().document.paths.find((entry) => entry.id === pathId);
146
140
  if (path === undefined)
@@ -13,13 +13,9 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
13
13
  const [query, setQuery] = useState(initialQuery);
14
14
  const [cursor, setCursor] = useState(0);
15
15
  const [recentIds, setRecentIds] = useState(() => loadRecentCommandIds());
16
- const inputRef = useRef(null);
17
16
  const listRef = useRef(null);
18
17
  const filtered = useMemo(() => filterPaletteCommands(commands, query, recentIds).slice(0, 40), [commands, query, recentIds]);
19
18
  const clampedCursor = Math.min(cursor, Math.max(0, filtered.length - 1));
20
- useEffect(() => {
21
- inputRef.current?.focus();
22
- }, []);
23
19
  useEffect(() => {
24
20
  const row = listRef.current?.children[clampedCursor];
25
21
  if (row instanceof HTMLElement)
@@ -32,7 +28,7 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
32
28
  onClose();
33
29
  command.run();
34
30
  };
35
- return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", { ref: inputRef, value: query, onChange: (event) => {
31
+ return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", { autoFocus: true, value: query, onChange: (event) => {
36
32
  setQuery(event.target.value);
37
33
  setCursor(0);
38
34
  }, onKeyDown: (event) => {
@@ -44,10 +44,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
44
44
  const [query, setQuery] = useState("");
45
45
  const [collapsedKinds, setCollapsedKinds] = useState({});
46
46
  const [collapsedNodes, setCollapsedNodes] = useState({});
47
- const [visibilityTick, setVisibilityTick] = useState(0);
48
- useEffect(() => api.subscribeVisibility(() => setVisibilityTick((value) => value + 1)), [api]);
49
- void visibilityTick;
50
- const visibility = api.getVisibility();
47
+ const visibilityStore = useMemo(() => ({ getState: api.getVisibility, subscribe: api.subscribeVisibility }), [api]);
48
+ const visibility = useStoreSelector(visibilityStore, (v) => v);
51
49
  const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
52
50
  const total = document.markers.length + document.volumes.length + document.paths.length + document.annotations.length;
53
51
  const scrollRef = useRef(null);
@@ -79,15 +77,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
79
77
  const [activeId, setActiveId] = useState(null);
80
78
  const [renamingId, setRenamingId] = useState(null);
81
79
  const [renameDraft, setRenameDraft] = useState("");
82
- const renameInputRef = useRef(null);
83
- useEffect(() => {
84
- if (activeId !== null && !navigableIds.includes(activeId))
85
- setActiveId(navigableIds[0] ?? null);
86
- }, [activeId, navigableIds]);
87
- useEffect(() => {
88
- if (renamingId !== null)
89
- renameInputRef.current?.focus();
90
- }, [renamingId]);
80
+ // Raw intent clamped at read time: a filtered-away active row falls back to the first row.
81
+ const effectiveActiveId = activeId === null ? null : navigableIds.includes(activeId) ? activeId : (navigableIds[0] ?? null);
91
82
  const focusTree = () => {
92
83
  scrollRef.current?.focus({ preventScroll: true });
93
84
  };
@@ -129,7 +120,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
129
120
  const onListFocus = () => {
130
121
  if (renamingId !== null)
131
122
  return;
132
- if (activeId !== null && navigableIds.includes(activeId))
123
+ if (effectiveActiveId !== null)
133
124
  return;
134
125
  const seed = selectedId !== undefined && navigableIds.includes(selectedId)
135
126
  ? selectedId
@@ -142,7 +133,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
142
133
  return;
143
134
  if (navigableIds.length === 0)
144
135
  return;
145
- const currentIndex = activeId === null ? -1 : navigableIds.indexOf(activeId);
136
+ const currentIndex = effectiveActiveId === null ? -1 : navigableIds.indexOf(effectiveActiveId);
146
137
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
147
138
  event.preventDefault();
148
139
  const delta = event.key === "ArrowDown" ? 1 : -1;
@@ -164,17 +155,17 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
164
155
  activateId(navigableIds[navigableIds.length - 1], event.shiftKey);
165
156
  return;
166
157
  }
167
- if (event.key === "Enter" && activeId !== null) {
158
+ if (event.key === "Enter" && effectiveActiveId !== null) {
168
159
  event.preventDefault();
169
- api.handle({ method: "camera_goto", id: activeId });
160
+ api.handle({ method: "camera_goto", id: effectiveActiveId });
170
161
  return;
171
162
  }
172
- if (event.key === "F2" && activeId !== null) {
163
+ if (event.key === "F2" && effectiveActiveId !== null) {
173
164
  event.preventDefault();
174
- const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === activeId) ||
175
- (entry.type === "treeItem" && entry.id === activeId));
165
+ const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === effectiveActiveId) ||
166
+ (entry.type === "treeItem" && entry.id === effectiveActiveId));
176
167
  if (row !== undefined && (row.type === "kindItem" || row.type === "treeItem")) {
177
- beginRename(activeId, row.label);
168
+ beginRename(effectiveActiveId, row.label);
178
169
  }
179
170
  }
180
171
  };
@@ -245,7 +236,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
245
236
  return;
246
237
  session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: true } });
247
238
  };
248
- const activeDescendant = hierarchyActiveDescendantId(activeId, navigableIds);
239
+ const activeDescendant = hierarchyActiveDescendantId(effectiveActiveId, navigableIds);
249
240
  return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.06] p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx(Icon, { name: "search", size: 12, className: "pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search\u2026", "aria-label": "Search scene objects", className: `h-7 w-full pl-7 pr-2 ${INPUT_CLS}` })] }), _jsx(IconButton, { icon: "plus", label: "Add object\u2026", onClick: onAdd })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Segmented, { ariaLabel: "Outliner grouping", value: view, onChange: setView, options: [
250
241
  { value: "kind", label: "By kind", icon: "layers" },
251
242
  { value: "tree", label: "Tree", icon: "folder" },
@@ -273,7 +264,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
273
264
  const hidden = rowHidden(row.ids);
274
265
  const collectionOnlyLock = locked &&
275
266
  row.ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
276
- const active = activeId === primaryId;
267
+ const active = effectiveActiveId === primaryId;
277
268
  const renaming = renamingId === primaryId && row.ids.length === 1;
278
269
  return (_jsxs("div", { id: hierarchyRowDomId(primaryId), role: "treeitem", "aria-selected": rowSelected, "aria-level": 2, style: { height: ROW_HEIGHT }, className: `group flex w-full items-center gap-0.5 rounded-[5px] pl-6 pr-0.5 text-[11px] transition-colors ${dropActive
279
270
  ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
@@ -294,7 +285,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
294
285
  event.preventDefault();
295
286
  setActiveId(primaryId);
296
287
  onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, primaryId);
297
- }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
288
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
298
289
  event.stopPropagation();
299
290
  if (event.key === "Enter") {
300
291
  event.preventDefault();
@@ -331,7 +322,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
331
322
  const hidden = isEditorObjectHidden(document, row.id);
332
323
  const collectionOnlyLock = isEditorObjectCollectionLocked(document, row.id) && !isEditorObjectSelfLocked(document, row.id);
333
324
  const dragging = draggingObjectId === row.id;
334
- const active = activeId === row.id;
325
+ const active = effectiveActiveId === row.id;
335
326
  const renaming = renamingId === row.id;
336
327
  const expanded = row.hasChildren ? collapsedNodes[row.id] !== true : undefined;
337
328
  return (_jsxs("div", { id: hierarchyRowDomId(row.id), role: "treeitem", "aria-selected": rowSelected, "aria-level": row.depth + 1, "aria-expanded": expanded, style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: `group flex items-center ${hidden ? "opacity-55" : ""}`, children: [row.hasChildren ? (_jsx("button", { type: "button", tabIndex: -1, "aria-label": collapsedNodes[row.id] === true ? "Expand children" : "Collapse children", className: `flex h-4 w-4 shrink-0 items-center justify-center text-neutral-500 transition-colors hover:text-neutral-200 ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: _jsx(Icon, { name: collapsedNodes[row.id] === true ? "chevronRight" : "chevronDown", size: 10 }) })) : (_jsx("span", { className: "w-4 shrink-0", "aria-hidden": "true" })), _jsxs("button", { type: "button", tabIndex: -1, draggable: true, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] px-1.5 text-left text-[11px] transition-colors ${FOCUS_RING} ${materialDrop || reparentDrop
@@ -364,7 +355,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
364
355
  if (commitMaterialDrop(event, [row.id]))
365
356
  return;
366
357
  commitReparentDrop(event, row.id);
367
- }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { ref: renameInputRef, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
358
+ }, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
368
359
  event.stopPropagation();
369
360
  if (event.key === "Enter") {
370
361
  event.preventDefault();
@@ -1,4 +1,11 @@
1
+ import type { RuntimePlayControl } from "@jgengine/core/editor/index";
1
2
  import type { EditorHostApi } from "../session.js";
3
+ /**
4
+ * Subscribe to the host runtime play-control (pause / pending steps) as an external store.
5
+ * Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
6
+ * @internal
7
+ */
8
+ export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
2
9
  /**
3
10
  * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
4
11
  * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
@@ -1,8 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { useMemo } from "react";
3
+ import { useStoreSelector } from "../useStoreSelector.js";
3
4
  import { Icon } from "./icons.js";
4
5
  import { BORDER, FOCUS_RING } from "./theme.js";
5
6
  import { IconButton, ToolbarDivider } from "./ui.js";
7
+ /**
8
+ * Subscribe to the host runtime play-control (pause / pending steps) as an external store.
9
+ * Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
10
+ * @internal
11
+ */
12
+ export function usePlayControl(api) {
13
+ const store = useMemo(() => ({ getState: api.getPlayControl, subscribe: api.subscribePlayControl }), [api]);
14
+ return useStoreSelector(store, (play) => play);
15
+ }
6
16
  /**
7
17
  * Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
8
18
  * does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
@@ -10,8 +20,7 @@ import { IconButton, ToolbarDivider } from "./ui.js";
10
20
  * @internal
11
21
  */
12
22
  export function PlayModeBar({ gameId, api, onExit, }) {
13
- const [play, setPlay] = useState(() => api.getPlayControl());
14
- useEffect(() => api.subscribePlayControl(setPlay), [api]);
23
+ const play = usePlayControl(api);
15
24
  const pause = () => {
16
25
  api.handle({ method: "runtime_pause" });
17
26
  };
@@ -112,11 +112,15 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
112
112
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
113
113
  : "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
114
  ? "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))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Studios" }), studioKinds.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
116
- ? { tool: "path", kind: definition.kind }
117
- : definition.target === "volume"
118
- ? { tool: "volume", kind: definition.kind, shape: "box" }
119
- : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind))), _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
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
+ const category = definition.addCategory ?? "Studios";
117
+ (groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
118
+ return groups;
119
+ }, new Map())].map(([category, defs]) => (_jsxs("div", { children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: category }), defs.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
120
+ ? { tool: "path", kind: definition.kind }
121
+ : definition.target === "volume"
122
+ ? { 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
120
124
  ? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
121
125
  : "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"] }) })] }));
122
126
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useRef, useState } from "react";
3
3
  import { AXIS_COLORS, FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
4
4
  /** Pixels of pointer drag per `step` of value change when scrubbing an axis chip. */
5
5
  const DRAG_PX_PER_STEP = 4;
@@ -15,14 +15,12 @@ function formatValue(value, precision) {
15
15
  * values on change and re-normalizes its text on blur. Invalid text never commits.
16
16
  */
17
17
  export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, precision = 3, disabled = false, mixed = false, }) {
18
- const [text, setText] = useState(() => (mixed ? "—" : formatValue(value, precision)));
18
+ // `text` only exists while the input has focus; otherwise the shown value derives from props.
19
+ const [text, setText] = useState("");
19
20
  const [editing, setEditing] = useState(false);
20
21
  const inputRef = useRef(null);
21
22
  const dragRef = useRef(null);
22
- useEffect(() => {
23
- if (!editing)
24
- setText(mixed ? "—" : formatValue(value, precision));
25
- }, [value, precision, editing, mixed]);
23
+ const formatted = mixed ? "—" : formatValue(value, precision);
26
24
  const commitText = (raw) => {
27
25
  if (raw === "—" || raw.trim().length === 0)
28
26
  return;
@@ -69,7 +67,10 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
69
67
  }
70
68
  };
71
69
  const colors = axis !== undefined ? AXIS_COLORS[axis] : null;
72
- return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: text, disabled: disabled, "aria-label": label, onFocus: () => setEditing(true), onDoubleClick: (event) => {
70
+ return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: editing ? text : formatted, disabled: disabled, "aria-label": label, onFocus: () => {
71
+ setText(formatted);
72
+ setEditing(true);
73
+ }, onDoubleClick: (event) => {
73
74
  event.stopPropagation();
74
75
  event.currentTarget.select();
75
76
  }, onChange: (event) => {
@@ -78,7 +79,6 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
78
79
  }, onBlur: (event) => {
79
80
  setEditing(false);
80
81
  commitText(event.target.value);
81
- setText(formatValue(Number.isFinite(Number(event.target.value)) ? Number(event.target.value) : value, precision));
82
82
  }, onKeyDown: (event) => {
83
83
  if (event.key === "Enter")
84
84
  event.currentTarget.blur();