@jgengine/editor 0.19.0 → 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 (71) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +10 -28
  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 +54 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/MaterialAssetEditor.d.ts +13 -0
  14. package/dist/MaterialAssetEditor.js +109 -0
  15. package/dist/MaterialPreview.d.ts +16 -0
  16. package/dist/MaterialPreview.js +41 -0
  17. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  18. package/dist/MaterialsWorkspacePanel.js +78 -4
  19. package/dist/RuntimePlayBridge.js +1 -6
  20. package/dist/SelectionGizmo.js +23 -14
  21. package/dist/SimulationInspector.d.ts +9 -0
  22. package/dist/SimulationInspector.js +47 -0
  23. package/dist/TerrainSculpt.js +3 -1
  24. package/dist/agent/toolBridge.js +2 -0
  25. package/dist/authoringPlacement.d.ts +21 -0
  26. package/dist/authoringPlacement.js +34 -0
  27. package/dist/handlers/context.d.ts +1 -0
  28. package/dist/handlers/document.d.ts +1 -1
  29. package/dist/handlers/document.js +29 -1
  30. package/dist/handlers/hierarchy.d.ts +1 -1
  31. package/dist/handlers/hierarchy.js +4 -0
  32. package/dist/handlers/runtime.js +20 -17
  33. package/dist/handlers/terrain.js +52 -12
  34. package/dist/index.d.ts +2 -1
  35. package/dist/index.js +1 -0
  36. package/dist/materialControls.d.ts +32 -0
  37. package/dist/materialControls.js +79 -0
  38. package/dist/mcp/fieldSpec.d.ts +1 -1
  39. package/dist/mcp/fieldSpec.js +11 -2
  40. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  41. package/dist/mcp/importOptionalGameModule.js +13 -0
  42. package/dist/mcp/loadGameCatalogs.js +5 -12
  43. package/dist/mcp/rpcRequest.d.ts +3 -3
  44. package/dist/mcp/rpcRequest.js +10 -4
  45. package/dist/mcp/tools.js +10 -1
  46. package/dist/modelAnimationAuthoring.d.ts +27 -7
  47. package/dist/modelAnimationAuthoring.js +69 -72
  48. package/dist/session.d.ts +62 -0
  49. package/dist/session.js +56 -7
  50. package/dist/shell/AnimationGraphPanel.js +18 -10
  51. package/dist/shell/PlayModeBar.d.ts +2 -1
  52. package/dist/shell/PlayModeBar.js +2 -2
  53. package/dist/shell/SceneToolbar.d.ts +2 -1
  54. package/dist/shell/SceneToolbar.js +5 -4
  55. package/dist/shell/TopAppBar.d.ts +2 -1
  56. package/dist/shell/TopAppBar.js +2 -2
  57. package/dist/shell/animGraphPreview.d.ts +2 -0
  58. package/dist/shell/animGraphPreview.js +5 -2
  59. package/dist/shell/clipPreview.d.ts +1 -0
  60. package/dist/useDocumentSave.d.ts +20 -0
  61. package/dist/useDocumentSave.js +33 -0
  62. package/package.json +6 -5
  63. package/skills/ce-handoff/SKILL.md +1 -1
  64. package/skills/jgengine/SKILL.md +22 -2
  65. package/skills/jgengine/api.md +28 -17
  66. package/skills/jgengine/recipes/minimal-game.md +7 -2
  67. package/skills/jgengine-editor/SKILL.md +9 -1
  68. package/skills/jgengine-editor/api.md +102 -16
  69. package/skills/jgengine-editor/capabilities.md +40 -0
  70. package/skills/jgengine-editor/reference.md +149 -1
  71. package/skills/jgengine-editor/references/live-sync.md +15 -0
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { importCreatorDocument, validateCreatorDocument } from "@jgengine/core/editor/creatorStorage";
4
+ import { EditorApp } from "./EditorApp.js";
5
+ /** Production creator catalog and embedded editor, reached through the game's menu.
6
+ * @capability player-creator create, edit, save, playtest and reopen bounded player scenes through the production editor
7
+ */
8
+ export function CreatorApp({ gameId, config, onExit }) {
9
+ const [documents, setDocuments] = useState([]);
10
+ const [selected, setSelected] = useState(null);
11
+ const [name, setName] = useState("");
12
+ const [error, setError] = useState(null);
13
+ const [busy, setBusy] = useState(false);
14
+ const revision = useRef(null);
15
+ const pending = useRef(false);
16
+ const generation = useRef(0);
17
+ const refresh = useCallback(async () => {
18
+ try {
19
+ const values = await config.storage.list();
20
+ if (values.length > config.policy.maxDocuments)
21
+ throw new Error("Creator catalog exceeds document budget");
22
+ setDocuments(values);
23
+ }
24
+ catch (failure) {
25
+ setError(failure instanceof Error ? failure.message : String(failure));
26
+ }
27
+ }, [config]);
28
+ useEffect(() => { void refresh(); return () => { generation.current += 1; }; }, [refresh]);
29
+ const validate = useCallback((document) => { validateCreatorDocument(document, config.policy); }, [config]);
30
+ const playable = useMemo(() => selected === null ? null : config.createPlayable(selected.document), [selected, config]);
31
+ const save = useCallback(async (json) => {
32
+ if (selected === null || pending.current)
33
+ return { ok: false, error: "A save is already in progress" };
34
+ pending.current = true;
35
+ const currentGeneration = generation.current;
36
+ try {
37
+ const document = importCreatorDocument(json, config.policy);
38
+ const next = await config.storage.save({ ...selected, document }, revision.current);
39
+ if (generation.current === currentGeneration) {
40
+ revision.current = next.revision;
41
+ setError(null);
42
+ }
43
+ return { ok: true, path: next.name };
44
+ }
45
+ catch (failure) {
46
+ return { ok: false, error: failure instanceof Error ? failure.message : String(failure) };
47
+ }
48
+ finally {
49
+ pending.current = false;
50
+ }
51
+ }, [selected, config]);
52
+ const run = async (action) => {
53
+ if (busy)
54
+ return;
55
+ setBusy(true);
56
+ setError(null);
57
+ try {
58
+ await action();
59
+ }
60
+ catch (failure) {
61
+ setError(failure instanceof Error ? failure.message : String(failure));
62
+ }
63
+ finally {
64
+ setBusy(false);
65
+ }
66
+ };
67
+ const create = () => run(async () => {
68
+ if (documents.length >= config.policy.maxDocuments)
69
+ throw new Error("Creator catalog is full");
70
+ const document = validateCreatorDocument(config.initialDocument(), config.policy);
71
+ const value = await config.storage.save({ version: 1, id: config.createId?.() ?? crypto.randomUUID(), name: name.trim(), revision: 0, document }, null);
72
+ revision.current = value.revision;
73
+ setSelected(value);
74
+ });
75
+ const importFile = (file) => run(async () => {
76
+ if (documents.length >= config.policy.maxDocuments)
77
+ throw new Error("Creator catalog is full");
78
+ if (file.size > config.policy.maxBytes)
79
+ throw new Error("Scene exceeds byte budget");
80
+ const document = importCreatorDocument(await file.text(), config.policy);
81
+ const value = await config.storage.save({ version: 1, id: config.createId?.() ?? crypto.randomUUID(), name: name.trim(), revision: 0, document }, null);
82
+ revision.current = value.revision;
83
+ setSelected(value);
84
+ });
85
+ const reopen = (id) => run(async () => {
86
+ const value = await config.storage.load(id);
87
+ if (value === null)
88
+ throw new Error("Scene no longer exists");
89
+ validate(value.document);
90
+ revision.current = value.revision;
91
+ setSelected(value);
92
+ });
93
+ const returnToCatalog = () => {
94
+ generation.current += 1;
95
+ setSelected(null);
96
+ void refresh();
97
+ };
98
+ if (selected !== null && playable !== null) {
99
+ return _jsx(EditorApp, { gameId: `${gameId}:${selected.id}`, playable: playable, layers: selected.document, catalogs: playable.editorCatalogs, save: save, createPlaytest: config.createPlayable, validateDocument: validate, playerCreator: true, allowedAssets: config.policy.allowedAssets, allowedKinds: config.policy.allowedKinds, maxImportBytes: config.policy.maxBytes, creatorPolicy: config.policy, onExitEditor: returnToCatalog });
100
+ }
101
+ return (_jsxs("section", { "aria-label": "Scene creator", style: { padding: 24, color: "#eef2ff", background: "#111827", minHeight: "100%", fontFamily: "sans-serif" }, children: [_jsx("h1", { children: "Create and edit" }), _jsx("button", { type: "button", onClick: onExit, children: "Return to game" }), _jsxs("form", { onSubmit: (event) => { event.preventDefault(); void create(); }, style: { marginBlock: 24 }, children: [_jsxs("label", { children: ["Scene name ", _jsx("input", { value: name, maxLength: 80, onChange: (event) => setName(event.target.value) })] }), " ", _jsx("button", { type: "submit", disabled: busy || name.trim().length === 0 || documents.length >= config.policy.maxDocuments, children: "Create scene" })] }), _jsxs("label", { children: ["Import scene JSON ", _jsx("input", { type: "file", accept: "application/json,.json", "aria-label": "Import scene JSON", disabled: busy || name.trim().length === 0 || documents.length >= config.policy.maxDocuments, onChange: (event) => {
102
+ const file = event.target.files?.[0];
103
+ if (file !== undefined)
104
+ void importFile(file);
105
+ event.target.value = "";
106
+ } })] }), _jsxs("p", { children: [documents.length, " / ", config.policy.maxDocuments, " saved scenes"] }), error === null ? null : _jsx("p", { role: "alert", children: error }), _jsx("ul", { children: documents.map((value) => _jsxs("li", { children: [value.name, " ", _jsxs("button", { type: "button", disabled: busy, onClick: () => void reopen(value.id), children: ["Edit ", value.name] })] }, value.id)) })] }));
107
+ }
package/dist/DebugDraw.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Html } from "@react-three/drei";
3
3
  import { memo, useEffect, useMemo } from "react";
4
4
  import * as THREE from "three";
5
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
5
6
  const DEFAULT_COLORS = {
6
7
  player_spawn: "#22d3ee",
7
8
  boss: "#f43f5e",
@@ -103,10 +104,11 @@ const MarkerMesh = memo(function MarkerMesh({ marker, selected, hovered, onSelec
103
104
  const scale = selected ? 1.28 : hovered ? 1.12 : 1;
104
105
  const baseY = groundHeightAt !== undefined ? groundHeightAt(marker.position.x, marker.position.z) : marker.position.y;
105
106
  const fill = emphasisColor(selected, hovered, color);
107
+ const modelBacked = markerCatalogId(marker) !== null;
106
108
  return (_jsxs("group", { position: [marker.position.x, baseY + 1.2, marker.position.z], "rotation-y": marker.rotationY ?? 0, scale: scale, userData: { jgEditorId: marker.id }, onClick: (event) => {
107
109
  event.stopPropagation();
108
110
  onSelect(marker.id);
109
- }, children: [_jsx("mesh", { geometry: sharedSphere, children: _jsx("meshBasicMaterial", { color: fill, transparent: true, opacity: selected ? 1 : hovered ? 0.9 : 1 }) }), selected || hovered ? (_jsx("mesh", { geometry: sharedSphere, scale: 1.18, children: _jsx("meshBasicMaterial", { color: fill, transparent: true, opacity: selected ? 0.28 : 0.18, depthWrite: false }) })) : null, _jsx("mesh", { position: [0, 1.35, 0], geometry: sharedCone, children: _jsx("meshBasicMaterial", { color: selected || hovered ? fill : color }) })] }));
111
+ }, children: [_jsx("mesh", { geometry: sharedSphere, children: _jsx("meshBasicMaterial", { color: fill, transparent: true, opacity: selected ? 1 : hovered ? 0.9 : 1, wireframe: modelBacked, depthWrite: !modelBacked }) }), selected || hovered ? (_jsx("mesh", { geometry: sharedSphere, scale: 1.18, children: _jsx("meshBasicMaterial", { color: fill, transparent: true, opacity: selected ? 0.28 : 0.18, wireframe: modelBacked, depthWrite: false }) })) : null, _jsx("mesh", { position: [0, 1.35, 0], geometry: sharedCone, children: _jsx("meshBasicMaterial", { color: selected || hovered ? fill : color }) })] }));
110
112
  });
111
113
  /** Shape-true wireframe — ground ring plus vertical profile for spheres, cylinders, and boxes. */
112
114
  const VolumeShapeLines = memo(function VolumeShapeLines({ volume, selected, hovered, onSelect, }) {
@@ -1,8 +1,9 @@
1
1
  import { type ComponentType } from "react";
2
- import type { EditorCatalogDefinition, EditorLayersInput } from "@jgengine/core/editor/index";
2
+ import type { EditorCatalogDefinition, EditorDocument, EditorLayersInput } from "@jgengine/core/editor/index";
3
3
  import type { PlayableGame } from "@jgengine/shell/registry";
4
4
  import { type EditorNetworkPresenceInput } from "./networkSnapshot.js";
5
5
  import { type EditorRunMode } from "./session.js";
6
+ import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
6
7
  /** Props for mounting the scene editor over a playable game. */
7
8
  export interface EditorAppProps {
8
9
  gameId: string;
@@ -29,6 +30,17 @@ export interface EditorAppProps {
29
30
  * standalone editor, which has no game to return to.
30
31
  */
31
32
  onExitEditor?: () => void;
33
+ /** Build a fresh playable from a captured document for each isolated playtest. */
34
+ createPlaytest?: (document: EditorDocument) => PlayableGame;
35
+ /** Reject document edits before they enter history, live sync, or saves. */
36
+ validateDocument?: (document: EditorDocument) => void;
37
+ /** Disable developer host publication and recovery drafts for a player creator. */
38
+ playerCreator?: boolean;
39
+ /** Catalog entries the player creator may place. */
40
+ allowedAssets?: readonly string[];
41
+ allowedKinds?: readonly string[];
42
+ maxImportBytes?: number;
43
+ creatorPolicy?: CreatorPolicy;
32
44
  }
33
45
  /** Persists an exported document JSON; resolves with where it landed or why it failed. */
34
46
  export type EditorSaveFn = (json: string) => Promise<{
@@ -37,4 +49,4 @@ export type EditorSaveFn = (json: string) => Promise<{
37
49
  error?: string;
38
50
  }>;
39
51
  /** Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. */
40
- export declare function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }: EditorAppProps): import("react").JSX.Element;
52
+ export declare function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor, createPlaytest, validateDocument, playerCreator, allowedAssets, allowedKinds, maxImportBytes, creatorPolicy }: EditorAppProps): import("react").JSX.Element;
package/dist/EditorApp.js CHANGED
@@ -27,6 +27,8 @@ import { createEditorHost } from "./session.js";
27
27
  import { createEditorUiStore, } from "./uiStore.js";
28
28
  import { useF2Chord } from "./useF2Chord.js";
29
29
  import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
30
+ import { EditorDocumentSaveContext, useDocumentSave } from "./useDocumentSave.js";
31
+ import { PlayModeBar } from "./shell/PlayModeBar.js";
30
32
  function endpointSaver(gameId) {
31
33
  const endpoint = getSaveEndpoint();
32
34
  if (endpoint === null)
@@ -245,7 +247,7 @@ function resolveEditorCamera(document) {
245
247
  };
246
248
  }
247
249
  /** Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. */
248
- export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }) {
250
+ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor, createPlaytest, validateDocument, playerCreator, allowedAssets, allowedKinds, maxImportBytes, creatorPolicy }) {
249
251
  const resolvedModeChip = modeChip === undefined ? EditorModeChip : modeChip;
250
252
  const saveFn = useMemo(() => save ?? endpointSaver(gameId), [save, gameId]);
251
253
  const networkSnapshot = useMemo(() => buildEditorNetworkSnapshot({
@@ -263,13 +265,13 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
263
265
  const readout = readoutStoreRef.current;
264
266
  const catalogAssets = useMemo(() => {
265
267
  try {
266
- const ids = playable.game.assets.ids();
268
+ const ids = playable.game.assets.ids().filter((id) => allowedAssets === undefined || allowedAssets.includes(id));
267
269
  return assetsFromCatalog(ids, (id) => playable.game.assets.resolve(id));
268
270
  }
269
271
  catch {
270
272
  return [];
271
273
  }
272
- }, [playable]);
274
+ }, [playable, allowedAssets]);
273
275
  const resolvedLayers = layers ?? playable.editorLayers;
274
276
  const host = useMemo(() => {
275
277
  const created = createEditorHost({
@@ -277,18 +279,25 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
277
279
  layers: resolvedLayers,
278
280
  catalogs,
279
281
  assets: catalogAssets,
282
+ validateDocument,
283
+ isolatedPlaytest: createPlaytest !== undefined,
284
+ publishGlobal: playerCreator !== true,
285
+ persistAssetUrls: playerCreator !== true,
280
286
  });
281
287
  return {
282
288
  ...created,
283
289
  baselineJson: created.session.exportJson(true),
284
290
  baselineDocument: created.session.getState().document,
285
291
  };
286
- }, [gameId, resolvedLayers, catalogs, catalogAssets]);
292
+ }, [gameId, resolvedLayers, catalogs, catalogAssets, createPlaytest, validateDocument, playerCreator]);
287
293
  useEffect(() => host.dispose, [host]);
294
+ const documentSave = useDocumentSave(host.session, saveFn);
288
295
  const modeStore = useMemo(() => ({ getState: host.api.getMode, subscribe: host.api.subscribeMode }), [host]);
289
296
  const mode = useStoreSelector(modeStore, (m) => m);
290
297
  const [pendingDraft, setPendingDraft] = useState(null);
291
298
  useEffect(() => {
299
+ if (playerCreator)
300
+ return;
292
301
  const draft = readDraft(gameId);
293
302
  if (draft !== null && draft !== host.baselineJson)
294
303
  setPendingDraft(draft);
@@ -306,7 +315,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
306
315
  clearTimeout(timer);
307
316
  unsubscribe();
308
317
  };
309
- }, [gameId, host]);
318
+ }, [gameId, host, playerCreator]);
310
319
  const restoreDraft = () => {
311
320
  if (pendingDraft === null)
312
321
  return;
@@ -385,6 +394,10 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
385
394
  }, [gameId]);
386
395
  const initialCamera = useMemo(() => resolveEditorCamera(host.session.getState().document), [host]);
387
396
  const cameraProjection = useStoreSelector(ui, (state) => state.cameraProjection);
397
+ const playtestPlayable = useMemo(() => {
398
+ const snapshot = host.api.getPlayDocument();
399
+ return mode === "play" && createPlaytest !== undefined && snapshot !== null ? createPlaytest(snapshot) : playable;
400
+ }, [mode, host, createPlaytest, playable]);
388
401
  const editorPlayable = useMemo(() => {
389
402
  const frozenLoop = {
390
403
  onInit: playable.loop.onInit,
@@ -444,15 +457,16 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
444
457
  };
445
458
  }
446
459
  if (mode === "play") {
447
- const BaseUI = playable.GameUI;
448
- const BaseOverlay = playable.WorldOverlay;
460
+ const playtest = playtestPlayable;
461
+ const BaseUI = playtest.GameUI;
462
+ const BaseOverlay = playtest.WorldOverlay;
449
463
  const useBuiltinPlayChrome = resolvedModeChip === EditorModeChip;
450
464
  return {
451
- ...playable,
465
+ ...playtest,
452
466
  GameUI: function EditorPlayUi() {
453
467
  const onExit = useCallback(() => host.api.setMode("edit"), []);
454
468
  useF2Chord("KeyE", onExit);
455
- return (_jsxs(_Fragment, { children: [BaseUI !== undefined ? _jsx(BaseUI, {}) : null, useBuiltinPlayChrome ? (_jsx(RuntimePlayInspectorChrome, { gameId: gameId, api: host.api, onExit: onExit })) : (_jsx(EditorModeChipHost, { api: host.api, mode: "play", chip: resolvedModeChip }))] }));
469
+ return (_jsxs(_Fragment, { children: [BaseUI !== undefined ? _jsx(BaseUI, {}) : null, playerCreator ? (_jsx(PlayModeBar, { gameId: gameId, api: host.api, onExit: onExit, exitLabel: "Return to editor" })) : useBuiltinPlayChrome ? (_jsx(RuntimePlayInspectorChrome, { gameId: gameId, api: host.api, onExit: onExit })) : (_jsx(EditorModeChipHost, { api: host.api, mode: "play", chip: resolvedModeChip }))] }));
456
470
  },
457
471
  WorldOverlay: function EditorPlayOverlay({ ctx }) {
458
472
  return (_jsxs(_Fragment, { children: [BaseOverlay !== undefined ? _jsx(BaseOverlay, { ctx: ctx }) : null, _jsx(PerfProbe, { api: host.api }), _jsx(RuntimePlayPublisher, { api: host.api })] }));
@@ -472,8 +486,9 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
472
486
  loop: frozenLoop,
473
487
  };
474
488
  }
475
- const WorldOverlay = function EditorOverlay() {
476
- return _jsx(EditorWorldOverlay, { api: host.api, ui: ui, world: playable.game.world, readout: readout });
489
+ const BaseOverlay = playable.WorldOverlay;
490
+ const WorldOverlay = function EditorOverlay({ ctx }) {
491
+ return _jsxs(_Fragment, { children: [BaseOverlay !== undefined ? _jsx(BaseOverlay, { ctx: ctx }) : null, _jsx(EditorWorldOverlay, { api: host.api, ui: ui, world: playable.game.world, readout: readout })] });
477
492
  };
478
493
  const registerImportedAsset = (id, url) => {
479
494
  try {
@@ -484,7 +499,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
484
499
  }
485
500
  };
486
501
  const GameUI = function EditorUi() {
487
- return (_jsx(EditorChrome, { gameId: gameId, session: host.api.getSession(), api: host.api, assets: catalogAssets, ui: ui, baselineDocument: host.baselineDocument, save: saveFn, networkSnapshot: networkSnapshot, onRegisterAsset: registerImportedAsset, onExitEditor: onExitEditor }));
502
+ return (_jsx(EditorChrome, { gameId: gameId, session: host.api.getSession(), api: host.api, assets: catalogAssets, ui: ui, baselineDocument: host.baselineDocument, save: saveFn, networkSnapshot: networkSnapshot, onRegisterAsset: registerImportedAsset, onExitEditor: onExitEditor, allowedKinds: allowedKinds, exitLabel: playerCreator ? "Return to saved scenes" : undefined, maxImportBytes: maxImportBytes, draftAutosave: playerCreator !== true, creatorPolicy: creatorPolicy, ...(playerCreator ? { importAsset: async () => { throw new Error("Choose an asset from the approved catalog"); } } : {}) }));
488
503
  };
489
504
  return {
490
505
  ...playable,
@@ -496,10 +511,10 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
496
511
  // WorldOverlay above is the editor layers, not PandoraViewmodel.
497
512
  camera: inspectionCamera,
498
513
  };
499
- }, [playable, host, gameId, initialCamera, cameraProjection, catalogAssets, ui, readout, mode, saveFn, resolvedModeChip, networkSnapshot, onExitEditor]);
514
+ }, [playable, host, gameId, initialCamera, cameraProjection, catalogAssets, ui, readout, mode, saveFn, resolvedModeChip, networkSnapshot, onExitEditor, playtestPlayable, allowedKinds, playerCreator, maxImportBytes, creatorPolicy]);
500
515
  const showElevation = useStoreSelector(ui, (s) => s.showElevation);
501
516
  const onPanelCommit = useCallback((id, panel) => {
502
517
  host.session.dispatch({ type: "setUiPanel", id, patch: panel });
503
518
  }, [host]);
504
- return (_jsxs("div", { className: "relative h-full w-full bg-neutral-950", "data-jg-editor": "1", "data-jg-editor-game": gameId, children: [_jsx(HudLayoutPersistProvider, { onPanelCommit: onPanelCommit, children: _jsx(GamePlayerShell, { playable: editorPlayable }) }), mode === "edit" && showElevation ? _jsx(TerrainReadoutHud, { readout: readout }) : null, pendingDraft !== null && mode === "edit" ? (_jsxs("div", { className: "absolute left-1/2 top-14 z-[60] flex -translate-x-1/2 items-center gap-3 rounded-xl border border-amber-400/30 bg-amber-950/90 px-4 py-2 text-xs text-amber-100 shadow-2xl shadow-black/50 backdrop-blur-md", children: [_jsx("span", { children: "Unsaved edits from a previous session found." }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-amber-500 to-amber-600 px-2.5 py-1 font-semibold text-white shadow-md shadow-amber-950/50 transition-colors hover:from-amber-400 hover:to-amber-500", onClick: restoreDraft, children: "Restore" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.07] px-2.5 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15", onClick: discardDraft, children: "Discard" })] })) : null] }));
519
+ return (_jsxs("div", { className: "relative h-full w-full bg-neutral-950", "data-jg-editor": "1", "data-jg-editor-game": gameId, children: [_jsx(HudLayoutPersistProvider, { onPanelCommit: onPanelCommit, children: _jsx(EditorDocumentSaveContext.Provider, { value: documentSave, children: _jsx(GamePlayerShell, { playable: editorPlayable }, mode) }) }), mode === "edit" && showElevation ? _jsx(TerrainReadoutHud, { readout: readout }) : null, pendingDraft !== null && mode === "edit" ? (_jsxs("div", { className: "absolute left-1/2 top-14 z-[60] flex -translate-x-1/2 items-center gap-3 rounded-xl border border-amber-400/30 bg-amber-950/90 px-4 py-2 text-xs text-amber-100 shadow-2xl shadow-black/50 backdrop-blur-md", children: [_jsx("span", { children: "Unsaved edits from a previous session found." }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-amber-500 to-amber-600 px-2.5 py-1 font-semibold text-white shadow-md shadow-amber-950/50 transition-colors hover:from-amber-400 hover:to-amber-500", onClick: restoreDraft, children: "Restore" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.07] px-2.5 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15", onClick: discardDraft, children: "Discard" })] })) : null] }));
505
520
  }
@@ -4,13 +4,15 @@ import type { EditorHostApi } from "./session.js";
4
4
  import { type AssetImporter } from "./assetImport.js";
5
5
  import { type EditorUiStore } from "./uiStore.js";
6
6
  import type { EditorNetworkSnapshot } from "./networkSnapshot.js";
7
+ import { type EditorDocumentSave } from "./useDocumentSave.js";
8
+ import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
7
9
  /**
8
10
  * The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable
9
11
  * hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI
10
12
  * assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
11
13
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
12
14
  */
13
- export declare function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset, onRegisterAsset, onExitEditor, }: {
15
+ export declare function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset, onRegisterAsset, onExitEditor, allowedKinds, exitLabel, maxImportBytes, documentSave, draftAutosave, creatorPolicy, }: {
14
16
  gameId: string;
15
17
  session: EditorSession;
16
18
  api: EditorHostApi;
@@ -25,6 +27,12 @@ export declare function EditorChrome({ gameId, session, api, assets, ui, baselin
25
27
  }>;
26
28
  /** Closes the editor and returns to the game; set only when summoned over a game. Shows "Exit to game". */
27
29
  onExitEditor?: () => void;
30
+ allowedKinds?: readonly string[];
31
+ exitLabel?: string;
32
+ maxImportBytes?: number;
33
+ documentSave?: EditorDocumentSave;
34
+ draftAutosave?: boolean;
35
+ creatorPolicy?: CreatorPolicy;
28
36
  /**
29
37
  * Network workspace inspection payload (adapter config + optional host presence).
30
38
  * Built by `EditorApp` from the game definition; live presence rows only when the host injects them.
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { editorDocumentSize, editorParentOf, extractEditorFragment, } from "@jgengine/core/editor/index";
4
4
  import { scatterRegionEstimate, SCATTER_PATH_KIND } from "@jgengine/core/world/scatterRegion";
5
5
  import { editorAssetFromImport, mergeEditorAssets, } from "./AssetBrowser.js";
@@ -7,6 +7,9 @@ import { CatalogsPanel } from "./CatalogsPanel.js";
7
7
  import { CollectionsPanel } from "./CollectionsPanel.js";
8
8
  import { EditorContextMenu } from "./EditorContextMenu.js";
9
9
  import { ParentPickerMenu } from "./ParentPickerMenu.js";
10
+ import { modelWithAuthoredMaterials } from "@jgengine/core/editor/materialAuthoring";
11
+ import { modelWithAuthoredNodeVisibility } from "@jgengine/core/editor/modelNodeVisibility";
12
+ import { MaterialPreview } from "./MaterialPreview.js";
10
13
  import { MaterialsWorkspacePanel } from "./MaterialsWorkspacePanel.js";
11
14
  import { PrefabsPanel } from "./PrefabsPanel.js";
12
15
  import { listParentCandidates } from "./parentCandidates.js";
@@ -37,6 +40,7 @@ import { createPerfHistoryStore } from "./shell/perfHistory.js";
37
40
  import { BORDER, FOCUS_RING } from "./shell/theme.js";
38
41
  import { IconButton, Kbd, PanelResizer } from "./shell/ui.js";
39
42
  import { ScriptingPanel } from "./ScriptingPanel.js";
43
+ import { EditorDocumentSaveContext, useDocumentSave } from "./useDocumentSave.js";
40
44
  let clipboardFragment = null;
41
45
  const SHORTCUTS = [
42
46
  { keys: "W / E / R", action: "Move · rotate · scale gizmo" },
@@ -72,34 +76,6 @@ function downloadText(filename, text) {
72
76
  anchor.click();
73
77
  URL.revokeObjectURL(url);
74
78
  }
75
- function useDocumentSave(session, save, onResult) {
76
- const [saveState, setSaveState] = useState("idle");
77
- const [saveError, setSaveError] = useState(null);
78
- const [lastSavedAt, setLastSavedAt] = useState(null);
79
- const savedDocRef = useRef(session.getState().document);
80
- const dirty = session.getState().document !== savedDocRef.current;
81
- const doSave = () => {
82
- if (save === undefined || saveState === "saving")
83
- return;
84
- const document = session.getState().document;
85
- setSaveState("saving");
86
- void save(session.exportJson(true)).then((result) => {
87
- if (result.ok) {
88
- savedDocRef.current = document;
89
- setSaveError(null);
90
- setSaveState("saved");
91
- setLastSavedAt(Date.now());
92
- onResult?.(true, result.path === undefined ? "Scene saved" : `Scene saved to ${result.path}`);
93
- }
94
- else {
95
- setSaveError(result.error ?? "save failed");
96
- setSaveState("error");
97
- onResult?.(false, `Save failed: ${result.error ?? "unknown error"}`);
98
- }
99
- });
100
- };
101
- return { available: save !== undefined, dirty, saveState, saveError, lastSavedAt, doSave };
102
- }
103
79
  const LEFT_PAGES = [
104
80
  { id: "hierarchy", label: "Hierarchy" },
105
81
  { id: "collections", label: "Sets" },
@@ -114,7 +90,7 @@ const ANIMATION_DOCK_HEIGHT = 400;
114
90
  * assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
115
91
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
116
92
  */
117
- export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
93
+ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, allowedKinds, exitLabel, maxImportBytes, documentSave, draftAutosave = true, creatorPolicy, }) {
118
94
  const layoutRef = useRef(null);
119
95
  layoutRef.current ??= createShellLayoutStore(gameId);
120
96
  const layout = layoutRef.current;
@@ -128,6 +104,8 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
128
104
  /** Models added this session via Content Browser Import / drop — merged over the game catalog. */
129
105
  const [importedAssets, setImportedAssets] = useState([]);
130
106
  const [importBusy, setImportBusy] = useState(false);
107
+ const [materialInventory, setMaterialInventory] = useState(null);
108
+ const [materialPreviewFailure, setMaterialPreviewFailure] = useState(null);
131
109
  // Bridge global RPC/agent console emits into the dock console for this chrome instance.
132
110
  useEffect(() => installEditorConsoleSink((severity, source, message) => {
133
111
  consoleStore.log(severity, source, message);
@@ -156,7 +134,44 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
156
134
  const state = session.getState();
157
135
  const uiState = ui.getState();
158
136
  const layoutState = layout.getState();
159
- const docSave = useDocumentSave(session, save, (ok, detail) => consoleStore.log(ok ? "info" : "error", "save", detail));
137
+ const materialMarker = state.document.markers.find((item) => state.selection.includes(item.id));
138
+ const materialSourceUrl = liveAssets.find((item) => item.id === (materialMarker?.catalogId ?? materialMarker?.meta?.catalogId))?.url;
139
+ const materialPreviewConfig = useMemo(() => {
140
+ try {
141
+ return { model: materialSourceUrl && materialMarker ? modelWithAuthoredNodeVisibility(modelWithAuthoredMaterials({ url: materialSourceUrl, targetHeight: 1.8, animation: "none" }, state.document, materialMarker.id), materialMarker) : undefined, error: null };
142
+ }
143
+ catch (failure) {
144
+ return { model: undefined, error: failure instanceof Error ? failure.message : String(failure) };
145
+ }
146
+ }, [materialSourceUrl, materialMarker, state.document]);
147
+ const materialSourceKey = JSON.stringify([materialMarker?.id, materialSourceUrl, materialPreviewConfig]);
148
+ const materialMarkerId = materialMarker?.id;
149
+ useEffect(() => {
150
+ if (layoutState.workspace !== "materials" || !materialMarkerId || !materialSourceUrl)
151
+ return;
152
+ if (api.getMaterialSlots(materialMarkerId).status === "ready")
153
+ return;
154
+ api.reportMaterialSlots(materialMarkerId, materialSourceUrl, materialPreviewConfig.error
155
+ ? { status: "unavailable", reason: materialPreviewConfig.error }
156
+ : { status: "loading" });
157
+ return () => {
158
+ const current = api.getMaterialSlots(materialMarkerId);
159
+ if (current.sourceUrl === materialSourceUrl && current.status === "loading")
160
+ api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "unavailable", reason: "The model preview closed before slot inspection completed." });
161
+ };
162
+ }, [api, materialMarkerId, materialSourceUrl, layoutState.workspace, materialPreviewConfig.error]);
163
+ const reportMaterialSlots = useCallback((slots) => {
164
+ if (materialMarkerId && materialSourceUrl && api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "ready", slots }))
165
+ setMaterialInventory({ source: materialSourceKey, slots });
166
+ }, [api, materialMarkerId, materialSourceUrl, materialSourceKey]);
167
+ const reportMaterialError = useCallback((message) => {
168
+ setMaterialPreviewFailure({ source: materialSourceKey, message });
169
+ if (message && materialMarkerId && materialSourceUrl && api.getMaterialSlots(materialMarkerId).status !== "ready")
170
+ api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "unavailable", reason: message });
171
+ }, [api, materialMarkerId, materialSourceUrl, materialSourceKey]);
172
+ const sharedSave = useContext(EditorDocumentSaveContext) ?? documentSave;
173
+ const localSave = useDocumentSave(session, sharedSave === undefined ? save : undefined, (ok, detail) => consoleStore.log(ok ? "info" : "error", "save", detail));
174
+ const docSave = sharedSave ?? localSave;
160
175
  const docSaveRef = useRef(docSave);
161
176
  docSaveRef.current = docSave;
162
177
  const outlinerGroups = useMemo(() => buildOutlinerGroups(state.document), [state.document]);
@@ -577,6 +592,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
577
592
  }
578
593
  }, [importAsset, importBusy, layout, notify, onRegisterAsset]);
579
594
  const importFile = (file) => {
595
+ if (maxImportBytes !== undefined && file.size > maxImportBytes) {
596
+ notify("Import failed: scene exceeds byte budget", "error");
597
+ return;
598
+ }
580
599
  void file.text().then((text) => {
581
600
  try {
582
601
  session.dispatch({ type: "importJson", json: text });
@@ -681,12 +700,12 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
681
700
  const railActive = uiState.tool === "terrain" ? "terrain" : layoutState.workspace;
682
701
  // Only advertise the exit chord when there is a game to return to.
683
702
  const shortcuts = onExitEditor !== undefined ? [...SHORTCUTS, { keys: "F2+Q", action: "Exit editor to game" }] : SHORTCUTS;
684
- return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { gameId: gameId, onExitToGame: onExitEditor, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
703
+ return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { exitLabel: exitLabel, gameId: gameId, onExitToGame: onExitEditor, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
685
704
  const file = event.target.files?.[0];
686
705
  if (file !== undefined)
687
706
  importFile(file);
688
707
  event.target.value = "";
689
- } }), _jsx(SceneToolbar, { tool: uiState.tool, gizmoMode: uiState.gizmoMode, gizmoSpace: uiState.gizmoSpace, gizmoPivot: uiState.gizmoPivot, snapMode: uiState.snapMode, gridSize: uiState.gridSize, rotationSnapDeg: uiState.rotationSnapDeg, scaleSnap: uiState.scaleSnap, cameraProjection: uiState.cameraProjection, showGrid: uiState.showGrid, showContours: uiState.showContours, showSurfaceGrid: uiState.showSurfaceGrid, showElevation: uiState.showElevation, placementActive: placement !== null, onSetTool: (tool) => ui.setTool(tool), onSetGizmoMode: (mode) => ui.patch({ gizmoMode: mode }), onSetGizmoSpace: (space) => ui.patch({ gizmoSpace: space }), onSetGizmoPivot: (pivot) => ui.patch({ gizmoPivot: pivot }), onSetSnapMode: (mode) => ui.patch({ snapMode: mode }), onSetGridSize: (size) => ui.patch({ gridSize: size }), onSetRotationSnapDeg: (deg) => ui.patch({ rotationSnapDeg: deg }), onSetScaleSnap: (snap) => ui.patch({ scaleSnap: snap }), onSetCameraProjection: (projection) => ui.patch({ cameraProjection: projection }), onToggleGrid: () => ui.patch({ showGrid: !uiState.showGrid }), onToggleContours: () => ui.patch({ showContours: !uiState.showContours }), onToggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), onToggleElevation: () => ui.patch({ showElevation: !uiState.showElevation }), onFrame: () => {
708
+ } }), _jsx(SceneToolbar, { allowedKinds: allowedKinds, tool: uiState.tool, gizmoMode: uiState.gizmoMode, gizmoSpace: uiState.gizmoSpace, gizmoPivot: uiState.gizmoPivot, snapMode: uiState.snapMode, gridSize: uiState.gridSize, rotationSnapDeg: uiState.rotationSnapDeg, scaleSnap: uiState.scaleSnap, cameraProjection: uiState.cameraProjection, showGrid: uiState.showGrid, showContours: uiState.showContours, showSurfaceGrid: uiState.showSurfaceGrid, showElevation: uiState.showElevation, placementActive: placement !== null, onSetTool: (tool) => ui.setTool(tool), onSetGizmoMode: (mode) => ui.patch({ gizmoMode: mode }), onSetGizmoSpace: (space) => ui.patch({ gizmoSpace: space }), onSetGizmoPivot: (pivot) => ui.patch({ gizmoPivot: pivot }), onSetSnapMode: (mode) => ui.patch({ snapMode: mode }), onSetGridSize: (size) => ui.patch({ gridSize: size }), onSetRotationSnapDeg: (deg) => ui.patch({ rotationSnapDeg: deg }), onSetScaleSnap: (snap) => ui.patch({ scaleSnap: snap }), onSetCameraProjection: (projection) => ui.patch({ cameraProjection: projection }), onToggleGrid: () => ui.patch({ showGrid: !uiState.showGrid }), onToggleContours: () => ui.patch({ showContours: !uiState.showContours }), onToggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), onToggleElevation: () => ui.patch({ showElevation: !uiState.showElevation }), onFrame: () => {
690
709
  const selected = session.getState().selection[0];
691
710
  if (selected !== undefined)
692
711
  api.handle({ method: "camera_goto", id: selected });
@@ -708,7 +727,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
708
727
  : null;
709
728
  const selected = layoutState.leftPage === page.id;
710
729
  return (_jsxs("button", { type: "button", role: "tab", "aria-selected": selected, onClick: () => layout.patch({ leftPage: page.id }), className: `flex h-6.5 items-center gap-1 rounded-[5px] px-2 text-[11px] transition-colors ${FOCUS_RING} ${selected ? "bg-white/[0.08] text-neutral-100" : "text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300"}`, children: [page.label, badge !== null && badge > 0 ? _jsx("span", { className: "text-[9px] tabular-nums text-neutral-500", children: badge }) : null] }, page.id));
711
- })), _jsx(IconButton, { icon: "close", label: "Collapse left panel", size: 11, tone: "ghost", className: "ml-auto", onClick: () => layout.patch({ leftOpen: false }) })] }), layoutState.workspace === "multiplayer" ? (_jsx(NetworkWorkspacePanel, { snapshot: networkSnapshot })) : layoutState.workspace === "materials" ? (_jsx(MaterialsWorkspacePanel, { session: session, api: api })) : layoutState.workspace === "scripting" ? (_jsx(ScriptingPanel, { session: session, api: api })) : layoutState.leftPage === "collections" ? (_jsx(CollectionsPanel, { session: session })) : layoutState.leftPage === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : layoutState.leftPage === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsx(HierarchyPanel, { session: session, api: api, onAdd: () => setPaletteQuery("add "), onRowContextMenu: (point, id) => {
730
+ })), _jsx(IconButton, { icon: "close", label: "Collapse left panel", size: 11, tone: "ghost", className: "ml-auto", onClick: () => layout.patch({ leftOpen: false }) })] }), layoutState.workspace === "multiplayer" ? (_jsx(NetworkWorkspacePanel, { snapshot: networkSnapshot })) : layoutState.workspace === "materials" ? (_jsx(MaterialsWorkspacePanel, { session: session, api: api, materialSlots: materialInventory?.source === materialSourceKey ? materialInventory.slots : [], previewError: materialPreviewConfig.error ?? (materialPreviewFailure?.source === materialSourceKey ? materialPreviewFailure.message : null), onSave: docSave.available ? docSave.doSave : undefined, preview: (asset, mode) => materialPreviewConfig.error || (!materialPreviewConfig.model && !asset) ? null : _jsx(MaterialPreview, { asset: asset, mode: mode, model: materialPreviewConfig.model, onSlots: reportMaterialSlots, onError: reportMaterialError, environment: state.document.environment }) })) : layoutState.workspace === "scripting" ? (_jsx(ScriptingPanel, { session: session, api: api })) : layoutState.leftPage === "collections" ? (_jsx(CollectionsPanel, { session: session })) : layoutState.leftPage === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : layoutState.leftPage === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsx(HierarchyPanel, { session: session, api: api, onAdd: () => setPaletteQuery("add "), onRowContextMenu: (point, id) => {
712
731
  const current = session.getState().selection;
713
732
  if (!current.includes(id))
714
733
  session.dispatch({ type: "select", ids: [id] });
@@ -756,5 +775,5 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
756
775
  ["profiler", "Profiler"],
757
776
  ["animation", "Animation"],
758
777
  ["assistant", "AI Assistant"],
759
- ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
778
+ ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { creatorPolicy: creatorPolicy, session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: draftAutosave }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
760
779
  }
@@ -1,3 +1,4 @@
1
+ import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
1
2
  import { type EditorSession } from "@jgengine/core/editor/index";
2
3
  import { type EditorAssetEntry } from "./AssetBrowser.js";
3
4
  import type { EditorHostApi } from "./session.js";
@@ -10,9 +11,10 @@ import type { InspectorTab } from "./shell/layoutStore.js";
10
11
  * `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
11
12
  * @internal — mounted by `EditorChrome` inside the right dock.
12
13
  */
13
- export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }: {
14
+ export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, creatorPolicy, }: {
14
15
  session: EditorSession;
15
16
  ui: EditorUiStore;
17
+ creatorPolicy?: CreatorPolicy;
16
18
  onClose?: () => void;
17
19
  /** Host API for RPC-backed actions (material assignment, camera). */
18
20
  api?: EditorHostApi;