@jgengine/editor 0.19.0 → 0.22.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 (73) hide show
  1. package/CHANGELOG.md +364 -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 +12 -59
  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/uiStore.d.ts +1 -10
  61. package/dist/uiStore.js +1 -13
  62. package/dist/useDocumentSave.d.ts +20 -0
  63. package/dist/useDocumentSave.js +33 -0
  64. package/package.json +6 -5
  65. package/skills/ce-handoff/SKILL.md +1 -1
  66. package/skills/jgengine/SKILL.md +23 -2
  67. package/skills/jgengine/api.md +28 -17
  68. package/skills/jgengine/recipes/minimal-game.md +7 -2
  69. package/skills/jgengine-editor/SKILL.md +9 -1
  70. package/skills/jgengine-editor/api.md +111 -16
  71. package/skills/jgengine-editor/capabilities.md +40 -0
  72. package/skills/jgengine-editor/reference.md +149 -1
  73. package/skills/jgengine-editor/references/live-sync.md +15 -0
@@ -1,4 +1,4 @@
1
- import { parseAnimGraph } from "@jgengine/core/anim/animGraph";
1
+ import { readAuthoredAnimation } from "@jgengine/core/world/authoredAnimation";
2
2
  import { animGraphFromConfig } from "@jgengine/core/anim/locomotionGraph";
3
3
  import { resolveAnimationConfig, rolesFromClips } from "@jgengine/core/game/clipRoles";
4
4
  /**
@@ -24,51 +24,14 @@ export const LOCOMOTION_NUMBERS = ["walkSpeed", "runSpeed", "fadeSec"];
24
24
  * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
25
25
  */
26
26
  export const ONE_SHOT_EVENTS = ["attack", "hit", "death", "jump", "interact", "cheer"];
27
- function isRecord(value) {
28
- return typeof value === "object" && value !== null && !Array.isArray(value);
29
- }
30
- /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
27
+ /** Reads a whole valid override, retaining authored fields; malformed values return undefined. */
31
28
  export function readAnimationSetting(meta) {
32
- const value = meta?.["animation"];
33
- if (value === "auto" || value === "none")
34
- return value;
35
- if (!isRecord(value))
36
- return undefined;
37
- const config = {};
38
- if (typeof value["clip"] === "string")
39
- config.clip = value["clip"];
40
- if (typeof value["loop"] === "boolean")
41
- config.loop = value["loop"];
42
- if (typeof value["timeScale"] === "number")
43
- config.timeScale = value["timeScale"];
44
- if (isRecord(value["states"])) {
45
- const raw = value["states"];
46
- const states = {};
47
- for (const role of LOCOMOTION_ROLES)
48
- if (typeof raw[role] === "string")
49
- states[role] = raw[role];
50
- for (const key of LOCOMOTION_NUMBERS)
51
- if (typeof raw[key] === "number")
52
- states[key] = raw[key];
53
- if (Object.keys(states).length > 0)
54
- config.states = states;
55
- }
56
- if (isRecord(value["oneShots"])) {
57
- const raw = value["oneShots"];
58
- const oneShots = {};
59
- for (const [event, clip] of Object.entries(raw)) {
60
- if (typeof clip === "string")
61
- oneShots[event] = clip;
62
- else if (Array.isArray(clip) && typeof clip[0] === "string")
63
- oneShots[event] = clip[0];
64
- }
65
- if (Object.keys(oneShots).length > 0)
66
- config.oneShots = oneShots;
67
- }
68
- const graph = parseAnimGraph(value["graph"]);
69
- if (graph !== undefined)
70
- config.graph = graph;
71
- return config;
29
+ return readAnimationSettingResult({ markers: [{ id: "animation", meta }] }, "animation").setting;
30
+ }
31
+ /** Validates a placement's animation with its saved-document repair locations. @internal */
32
+ export function readAnimationSettingResult(document, markerId) {
33
+ const result = readAuthoredAnimation(document, markerId);
34
+ return { setting: result.animation, diagnostics: result.diagnostics };
72
35
  }
73
36
  /** The authoring mode a stored setting maps to. */
74
37
  export function animationMode(setting) {
@@ -83,14 +46,6 @@ export function animationMode(setting) {
83
46
  function asConfig(setting) {
84
47
  return setting !== undefined && setting !== "auto" && setting !== "none" ? setting : {};
85
48
  }
86
- function normalizeConfig(config) {
87
- const next = { ...config };
88
- if (next.states !== undefined && Object.keys(next.states).length === 0)
89
- delete next.states;
90
- if (next.oneShots !== undefined && Object.keys(next.oneShots).length === 0)
91
- delete next.oneShots;
92
- return next;
93
- }
94
49
  /**
95
50
  * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
96
51
  * a working config, then overrides) when there is no existing object config; the other modes are the
@@ -105,7 +60,7 @@ export function setAnimationMode(setting, mode, clips = []) {
105
60
  return "none";
106
61
  if (setting !== undefined && setting !== "auto" && setting !== "none")
107
62
  return setting;
108
- return normalizeConfig(defaultCustomConfig(clips));
63
+ return defaultCustomConfig(clips);
109
64
  }
110
65
  /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
111
66
  export function defaultCustomConfig(clips) {
@@ -119,9 +74,9 @@ export function defaultCustomConfig(clips) {
119
74
  states.run = roles.run[0];
120
75
  const oneShots = {};
121
76
  for (const event of ONE_SHOT_EVENTS) {
122
- const variant = roles[event]?.[0];
123
- if (variant !== undefined)
124
- oneShots[event] = variant;
77
+ const variants = roles[event];
78
+ if (variants !== undefined && variants.length > 0)
79
+ oneShots[event] = variants.length === 1 ? variants[0] : [...variants];
125
80
  }
126
81
  const config = {};
127
82
  if (Object.keys(states).length > 0)
@@ -131,34 +86,72 @@ export function defaultCustomConfig(clips) {
131
86
  return config;
132
87
  }
133
88
  /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
134
- export function setLocomotionClip(setting, role, clipName) {
89
+ export function setLocomotionClip(setting, role, clipName, clips = []) {
135
90
  const config = asConfig(setting);
136
- const states = { ...config.states };
91
+ const states = { ...(config.states ?? (clipName === null ? undefined : defaultCustomConfig(clips).states)) };
137
92
  if (clipName === null)
138
93
  delete states[role];
139
94
  else
140
95
  states[role] = clipName;
141
- return normalizeConfig({ ...config, states });
96
+ return { ...config, ...(config.states !== undefined || Object.keys(states).length > 0 ? { states } : {}) };
142
97
  }
143
98
  /** Sets or clears (null) a numeric locomotion tuning. */
144
- export function setLocomotionNumber(setting, key, value) {
99
+ export function setLocomotionNumber(setting, key, value, clips = []) {
145
100
  const config = asConfig(setting);
146
- const states = { ...config.states };
101
+ const states = { ...(config.states ?? (value === null || !Number.isFinite(value) ? undefined : defaultCustomConfig(clips).states)) };
147
102
  if (value === null || !Number.isFinite(value))
148
103
  delete states[key];
149
104
  else
150
105
  states[key] = value;
151
- return normalizeConfig({ ...config, states });
106
+ return { ...config, ...(config.states !== undefined || Object.keys(states).length > 0 ? { states } : {}) };
152
107
  }
153
- /** Binds or clears (null) a one-shot event to a clip. */
108
+ /** Binds or clears (null) a one-shot event's clip or variant list. */
154
109
  export function setOneShotClip(setting, event, clipName) {
155
110
  const config = asConfig(setting);
156
111
  const oneShots = { ...config.oneShots };
157
- if (clipName === null)
112
+ if (clipName === null || (Array.isArray(clipName) && clipName.length === 0))
158
113
  delete oneShots[event];
159
114
  else
160
- oneShots[event] = clipName;
161
- return normalizeConfig({ ...config, oneShots });
115
+ oneShots[event] = typeof clipName === "string" ? clipName : clipName.length === 1 ? clipName[0] : [...clipName];
116
+ const next = { ...config, oneShots };
117
+ if (Object.keys(oneShots).length === 0)
118
+ delete next.oneShots;
119
+ return next;
120
+ }
121
+ /** Sets a single playback clip, explicitly replacing speed-driven states and a stored graph. @internal */
122
+ export function setPlaybackClip(setting, clip) {
123
+ if (clip === null) {
124
+ const { clip: _clip, ...config } = asConfig(setting);
125
+ return config;
126
+ }
127
+ const { states: _states, graph: _graph, auto: _auto, ...config } = asConfig(setting);
128
+ return { ...config, clip };
129
+ }
130
+ /** Changes a playback scalar while preserving role mappings, variants and the stored graph. @internal */
131
+ export function setPlaybackNumber(setting, key, value) {
132
+ const config = { ...asConfig(setting) };
133
+ if (value === null || !Number.isFinite(value))
134
+ delete config[key];
135
+ else
136
+ config[key] = key === "time" ? Math.max(0, value) : value;
137
+ return config;
138
+ }
139
+ /** Chooses the playback clock without replacing mappings, graph, or other authored fields. @internal */
140
+ export function setPlaybackClock(setting, clock) {
141
+ return { ...(setting === "auto" ? { auto: true } : asConfig(setting)), clock };
142
+ }
143
+ /** Requests loaded-rig role inference, or removes that request, retaining explicit overrides whole. @internal */
144
+ export function setAutomaticRoles(setting, derive) {
145
+ const config = { ...asConfig(setting) };
146
+ if (derive)
147
+ config.auto = true;
148
+ else
149
+ delete config.auto;
150
+ return config;
151
+ }
152
+ /** Changes a playback toggle without replacing the remaining authored settings. @internal */
153
+ export function setPlaybackBoolean(setting, key, value) {
154
+ return { ...asConfig(setting), [key]: value };
162
155
  }
163
156
  /**
164
157
  * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
@@ -175,14 +168,19 @@ export function animationMetaPatch(setting) {
175
168
  export function effectiveAnimGraph(setting, clips) {
176
169
  if (setting === "none")
177
170
  return null;
171
+ if (setting !== undefined && setting !== "auto" && setting.auto === true) {
172
+ const resolved = resolveAnimationConfig(setting, clips);
173
+ const graph = resolved === undefined ? undefined : animGraphFromConfig(resolved);
174
+ return graph === undefined ? null : { graph, source: setting.graph !== undefined ? "authored" : setting.states !== undefined ? "locomotion" : "auto" };
175
+ }
178
176
  if (setting !== undefined && setting !== "auto") {
179
177
  if (setting.graph !== undefined)
180
178
  return { graph: setting.graph, source: "authored" };
181
179
  const states = setting.states;
182
- if (states?.idle === undefined)
180
+ if (states?.idle === undefined || states.idle.trim().length === 0)
183
181
  return null;
184
182
  const graph = animGraphFromConfig({
185
- states: { ...states, idle: states.idle, walk: states.walk ?? states.idle },
183
+ states: { ...states, idle: states.idle, walk: states.walk === undefined || states.walk.trim().length === 0 ? states.idle : states.walk },
186
184
  ...(setting.oneShots === undefined ? {} : { oneShots: setting.oneShots }),
187
185
  });
188
186
  return graph === undefined ? null : { graph, source: "locomotion" };
@@ -193,15 +191,14 @@ export function effectiveAnimGraph(setting, clips) {
193
191
  }
194
192
  /** Stores `graph` on the placement, keeping its other fields; the stored graph wins at play time. */
195
193
  export function storeAnimGraph(setting, graph) {
196
- return normalizeConfig({ ...asConfig(setting), graph });
194
+ return { ...asConfig(setting), graph };
197
195
  }
198
196
  /** Removes a stored graph so `states`/`oneShots` (or clip roles) drive the placement again. */
199
197
  export function clearAnimGraph(setting) {
200
198
  if (setting === undefined || setting === "auto" || setting === "none")
201
199
  return setting;
202
200
  const { graph: _graph, ...rest } = setting;
203
- const next = normalizeConfig(rest);
204
- return Object.keys(next).length === 0 ? undefined : next;
201
+ return Object.keys(rest).length === 0 ? undefined : rest;
205
202
  }
206
203
  /**
207
204
  * Sets one transition's crossfade seconds in `graph` and stores the result, so editing a derived
package/dist/session.d.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import type { EditorSimulation } from "@jgengine/core/editor/simulation";
2
+ import type { MaterialAsset, MaterialSelector } from "@jgengine/core/material/materialAsset";
3
+ import type { StaticPrefabBake } from "@jgengine/core/editor/staticPrefab";
1
4
  import { type DocumentLiveSync, type DocumentPatch, type EditorCatalogDefinition, type EditorCommand, type EditorDocument, type EditorGridPaletteEntry, type EditorKindVisibility, type EditorLayersInput, type EditorSession, type EditorSessionState, type RuntimeEntityState, type RuntimePlayControl } from "@jgengine/core/editor/index";
2
5
  import type { TerraformMode, TerrainMaterialLayer } from "@jgengine/core/world/terraform";
3
6
  import type { ParamSchema } from "@jgengine/core/scene/sceneKinds";
4
7
  import type { TerrainField } from "@jgengine/core/world/terrain";
8
+ import type { ModelMaterialSlotInfo } from "@jgengine/shell/render/materialAsset";
5
9
  /** How the editor hosts the game: frozen placement view, roamable world, the real game, or HUD-layout authoring. */
6
10
  export type EditorRunMode = "edit" | "walk" | "play" | "hud";
7
11
  /** The accepted run modes, in canonical order — the runtime guard behind {@link EditorRunMode}. @internal */
@@ -152,6 +156,11 @@ export type EditorBridgeRequest = {
152
156
  height?: number;
153
157
  } | {
154
158
  method: "scene_summary";
159
+ } | {
160
+ method: "get_simulation";
161
+ } | {
162
+ method: "set_simulation";
163
+ simulation: EditorSimulation | null;
155
164
  } | {
156
165
  method: "export_document";
157
166
  } | {
@@ -294,6 +303,10 @@ export type EditorBridgeRequest = {
294
303
  } | {
295
304
  method: "delete_prefab";
296
305
  prefabId: string;
306
+ } | {
307
+ method: "set_prefab_static_bake";
308
+ prefabId: string;
309
+ bake: StaticPrefabBake | null;
297
310
  } | {
298
311
  method: "list_collections";
299
312
  } | {
@@ -340,6 +353,26 @@ export type EditorBridgeRequest = {
340
353
  color?: string;
341
354
  label?: string;
342
355
  meta?: Record<string, unknown>;
356
+ } | {
357
+ method: "list_material_assets";
358
+ } | {
359
+ method: "list_material_slots";
360
+ id: string;
361
+ } | {
362
+ method: "upsert_material_asset";
363
+ asset: MaterialAsset;
364
+ coalesce?: string;
365
+ } | {
366
+ method: "remove_material_asset";
367
+ id: string;
368
+ } | {
369
+ method: "assign_material_asset";
370
+ ids: string[];
371
+ materialId: string;
372
+ selector: MaterialSelector;
373
+ } | {
374
+ method: "clear_material_assets";
375
+ ids: string[];
343
376
  } | {
344
377
  method: "assign_material";
345
378
  ids: string[];
@@ -498,6 +531,25 @@ export interface EditorAssetInfo {
498
531
  kind: string;
499
532
  url?: string;
500
533
  }
534
+ /** Canonical imported slot names and mesh prerequisites reported by the loaded model. */
535
+ export type EditorMaterialSlotInfo = Readonly<Omit<ModelMaterialSlotInfo, "uvSets">> & {
536
+ readonly uvSets: readonly number[];
537
+ };
538
+ /** A live model-source observation; loading and unavailable reports never contain fabricated slots. */
539
+ export type EditorMaterialSlotReport = {
540
+ status: "ready";
541
+ slots: readonly EditorMaterialSlotInfo[];
542
+ } | {
543
+ status: "loading";
544
+ } | {
545
+ status: "unavailable";
546
+ reason: string;
547
+ };
548
+ /** A marker's current imported-model inventory, including an explicit availability state. */
549
+ export type EditorMaterialSlotInventory = EditorMaterialSlotReport & {
550
+ id: string;
551
+ sourceUrl?: string;
552
+ };
501
553
  /** Rolling frame-rate sample published by the in-canvas PerfProbe. */
502
554
  export interface EditorPerfSample {
503
555
  /**
@@ -575,6 +627,10 @@ export interface EditorHostApi {
575
627
  subscribeFocus(listener: (target: EditorFocusTarget | null) => void): () => void;
576
628
  getAssets(): readonly EditorAssetInfo[];
577
629
  setAssets(assets: readonly EditorAssetInfo[]): void;
630
+ /** Read observed slot data for the marker's current model URL; headless or unloaded sources are unavailable. */
631
+ getMaterialSlots(id: string): EditorMaterialSlotInventory;
632
+ /** Publish a loaded-model observation only while its marker still resolves to this source URL. */
633
+ reportMaterialSlots(id: string, sourceUrl: string, report: EditorMaterialSlotReport): boolean;
578
634
  getCatalogDefinitions(): readonly EditorCatalogDefinition[];
579
635
  getPerf(): EditorPerfSample | null;
580
636
  setPerf(sample: EditorPerfSample): void;
@@ -586,6 +642,8 @@ export interface EditorHostApi {
586
642
  getTerrainSampler(): TerrainField | null;
587
643
  setTerrainSampler(field: TerrainField | null): void;
588
644
  getMode(): EditorRunMode;
645
+ /** Immutable authored snapshot captured on entering an isolated playtest. */
646
+ getPlayDocument(): EditorDocument | null;
589
647
  setMode(mode: EditorRunMode): void;
590
648
  subscribeMode(listener: (mode: EditorRunMode) => void): () => void;
591
649
  /** Play-mode pause/step gate consumed by the runtime publisher. */
@@ -611,6 +669,10 @@ export declare function createEditorHost(options: {
611
669
  y: number;
612
670
  z: number;
613
671
  } | null) => void;
672
+ validateDocument?: (document: EditorDocument) => void;
673
+ isolatedPlaytest?: boolean;
674
+ publishGlobal?: boolean;
675
+ persistAssetUrls?: boolean;
614
676
  }): {
615
677
  session: EditorSession;
616
678
  api: EditorHostApi;
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
1
+ import { createDocumentLiveSync, cloneEditorDocument, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
2
2
  import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKinds";
3
3
  import { EDITOR_RPC_HANDLERS } from "./handlers/index.js";
4
4
  import { warmNavBake } from "./handlers/navmesh.js";
@@ -26,9 +26,16 @@ export function createEditorHost(options) {
26
26
  warmNavBake();
27
27
  const catalogDefinitions = options.catalogs ?? [];
28
28
  const document = seedEditorCatalogs(normalizeEditorLayers(options.layers), catalogDefinitions);
29
- const session = createEditorSession(document);
29
+ let mode = "edit";
30
+ const session = createEditorSession(document, 100, (candidate) => {
31
+ if (options.isolatedPlaytest && mode === "play")
32
+ throw new Error("Return to editor before changing the authored scene");
33
+ options.validateDocument?.(candidate);
34
+ });
30
35
  const liveSync = createDocumentLiveSync(document);
31
- const uninstallLiveSync = installDocumentLiveSync(liveSync);
36
+ let uninstallLiveSync = installDocumentLiveSync(liveSync);
37
+ let playDocument = null;
38
+ let playSync = null;
32
39
  let lastMirroredDocument = session.getState().document;
33
40
  const unsubscribeSessionMirror = session.subscribe((state) => {
34
41
  if (state.document === lastMirroredDocument)
@@ -39,9 +46,17 @@ export function createEditorHost(options) {
39
46
  let visibility = {};
40
47
  let focusTarget = null;
41
48
  let assets = [...(options.assets ?? [])];
49
+ const materialSlotReports = new Map();
50
+ const materialSource = (id) => {
51
+ const marker = session.getState().document.markers.find((item) => item.id === id);
52
+ if (!marker)
53
+ return { reason: `No authored model marker ${id}.` };
54
+ const catalogId = marker.catalogId ?? marker.meta?.catalogId;
55
+ const sourceUrl = assets.find((asset) => asset.id === catalogId)?.url;
56
+ return sourceUrl ? { sourceUrl } : { reason: `Marker ${id} has no URL-backed catalog model.` };
57
+ };
42
58
  let perf = null;
43
59
  let terrainSampler = null;
44
- let mode = "edit";
45
60
  let playControl = createRuntimePlayControl(false);
46
61
  const visibilityListeners = new Set();
47
62
  const focusListeners = new Set();
@@ -72,7 +87,7 @@ export function createEditorHost(options) {
72
87
  const api = {
73
88
  gameId: options.gameId,
74
89
  getSession: () => session,
75
- getLiveSync: () => liveSync,
90
+ getLiveSync: () => playSync ?? liveSync,
76
91
  getVisibility: () => visibility,
77
92
  setVisibility(next) {
78
93
  visibility = { ...next };
@@ -101,6 +116,31 @@ export function createEditorHost(options) {
101
116
  getAssets: () => assets,
102
117
  setAssets(next) {
103
118
  assets = [...next];
119
+ for (const [id, inventory] of materialSlotReports) {
120
+ if (materialSource(id).sourceUrl !== inventory.sourceUrl)
121
+ materialSlotReports.delete(id);
122
+ }
123
+ },
124
+ getMaterialSlots(id) {
125
+ const source = materialSource(id);
126
+ const inventory = materialSlotReports.get(id);
127
+ if (source.sourceUrl === undefined || inventory?.sourceUrl !== source.sourceUrl) {
128
+ materialSlotReports.delete(id);
129
+ return { id, ...source, status: "unavailable", reason: source.reason ?? "This model has no loaded slot inventory. Select it and open the Materials workspace." };
130
+ }
131
+ return structuredClone(inventory);
132
+ },
133
+ reportMaterialSlots(id, sourceUrl, report) {
134
+ if (materialSource(id).sourceUrl !== sourceUrl)
135
+ return false;
136
+ const previous = materialSlotReports.get(id);
137
+ if (previous?.sourceUrl === sourceUrl && previous.status !== "loading" && report.status === "loading")
138
+ return true;
139
+ materialSlotReports.delete(id);
140
+ materialSlotReports.set(id, structuredClone({ ...report, id, sourceUrl }));
141
+ if (materialSlotReports.size > 64)
142
+ materialSlotReports.delete(materialSlotReports.keys().next().value);
143
+ return true;
104
144
  },
105
145
  getCatalogDefinitions: () => resolveCatalogDefinitions(session.getState().document, catalogDefinitions),
106
146
  getPerf: () => perf,
@@ -112,9 +152,16 @@ export function createEditorHost(options) {
112
152
  terrainSampler = field;
113
153
  },
114
154
  getMode: () => mode,
155
+ getPlayDocument: () => playDocument === null ? null : cloneEditorDocument(playDocument),
115
156
  setMode(next) {
116
157
  if (next === mode)
117
158
  return;
159
+ if (options.isolatedPlaytest) {
160
+ uninstallLiveSync();
161
+ playDocument = next === "play" ? cloneEditorDocument(session.getState().document) : null;
162
+ playSync = playDocument === null ? null : createDocumentLiveSync(playDocument);
163
+ uninstallLiveSync = installDocumentLiveSync(playSync ?? liveSync);
164
+ }
118
165
  mode = next;
119
166
  if (next === "play") {
120
167
  playControl = createRuntimePlayControl(false);
@@ -157,7 +204,8 @@ export function createEditorHost(options) {
157
204
  const ctx = {
158
205
  api,
159
206
  session,
160
- liveSync,
207
+ liveSync: playSync ?? liveSync,
208
+ persistAssetUrls: options.persistAssetUrls,
161
209
  gameId: options.gameId,
162
210
  catalogDefinitions: defs,
163
211
  catalogById: new Map(defs.map((definition) => [definition.id, definition])),
@@ -181,8 +229,9 @@ export function createEditorHost(options) {
181
229
  }
182
230
  },
183
231
  };
184
- const uninstallHost = installEditorHost(api);
232
+ const uninstallHost = options.publishGlobal === false ? () => { } : installEditorHost(api);
185
233
  const dispose = () => {
234
+ materialSlotReports.clear();
186
235
  unsubscribeSessionMirror();
187
236
  uninstallLiveSync();
188
237
  uninstallHost();
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
- import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSetting, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
4
+ import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSettingResult, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
5
5
  import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
6
6
  import { graphParamControls, graphTriggers, MAX_PREVIEW_SECONDS, recordTrigger, simulateGraphPreview, } from "./animGraphPreview.js";
7
7
  import { initialClipPreviewState } from "./clipPreview.js";
@@ -38,6 +38,8 @@ export function AnimationGraphPanel({ session, ui, rigged, }) {
38
38
  const marker = markers.find((entry) => entry.id === id);
39
39
  if (marker === undefined)
40
40
  continue;
41
+ if (readAnimationSettingResult({ markers }, marker.id).diagnostics.length > 0)
42
+ return { marker, asset: undefined };
41
43
  const catalogId = markerCatalogId(marker);
42
44
  const asset = catalogId === null ? undefined : rigged.find((entry) => entry.id === catalogId);
43
45
  if (asset?.url !== undefined && asset.clips !== undefined)
@@ -51,9 +53,11 @@ export function AnimationGraphPanel({ session, ui, rigged, }) {
51
53
  return _jsx(GraphInspector, { session: session, ui: ui, marker: target.marker, asset: target.asset }, target.marker.id);
52
54
  }
53
55
  function GraphInspector({ session, ui, marker, asset, }) {
54
- const clips = asset.clips ?? [];
55
- const setting = readAnimationSetting(marker.meta);
56
- const effective = useMemo(() => effectiveAnimGraph(setting, clips), [JSON.stringify(setting), clips]);
56
+ const clips = asset?.clips ?? [];
57
+ const document = useStoreSelector(session, (state) => state.document);
58
+ const { setting, diagnostics } = readAnimationSettingResult(document, marker.id);
59
+ const invalid = diagnostics.length > 0;
60
+ const effective = useMemo(() => invalid || asset === undefined ? null : effectiveAnimGraph(setting, clips), [JSON.stringify(setting), invalid, asset, clips]);
57
61
  const graph = effective?.graph ?? null;
58
62
  const controls = useMemo(() => (graph === null ? [] : graphParamControls(graph)), [graph]);
59
63
  const triggerNames = useMemo(() => (graph === null ? [] : graphTriggers(graph)), [graph]);
@@ -61,6 +65,8 @@ function GraphInspector({ session, ui, marker, asset, }) {
61
65
  const [playing, setPlaying] = useState(false);
62
66
  const [params, setParams] = useState({});
63
67
  const [triggers, setTriggers] = useState([]);
68
+ useEffect(() => { if (invalid)
69
+ setPlaying(false); }, [invalid]);
64
70
  const durations = useStoreSelector(ui, (state) => state.clipPreview?.clipDurations);
65
71
  const frame = useMemo(() => {
66
72
  if (graph === null)
@@ -71,7 +77,7 @@ function GraphInspector({ session, ui, marker, asset, }) {
71
77
  return simulateGraphPreview({ graph, durations: durations ?? {}, time, params: merged, triggers });
72
78
  }, [graph, controls, params, triggers, time, durations]);
73
79
  useEffect(() => {
74
- if (asset.url === undefined)
80
+ if (asset?.url === undefined)
75
81
  return;
76
82
  const current = ui.getState().clipPreview;
77
83
  const sameAsset = current?.source.assetId === asset.id;
@@ -87,18 +93,18 @@ function GraphInspector({ session, ui, marker, asset, }) {
87
93
  if (ui.getState().clipPreview?.source.assetId === asset.id)
88
94
  ui.patch({ clipPreview: null });
89
95
  };
90
- }, [asset.id, asset.url, asset.label, clips, ui]);
96
+ }, [asset?.id, asset?.url, asset?.label, clips, ui]);
91
97
  useEffect(() => {
92
98
  const current = ui.getState().clipPreview;
93
- if (current === null || current.source.assetId !== asset.id)
99
+ if (asset === undefined || current === null || current.source.assetId !== asset.id)
94
100
  return;
95
101
  if (graph === null || frame === null) {
96
102
  if (current.graphPose !== undefined)
97
103
  ui.patch({ clipPreview: { ...current, graphPose: undefined } });
98
104
  return;
99
105
  }
100
- ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips } } });
101
- }, [graph, frame, asset.id, ui, durations]);
106
+ ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips, ...(frame.rootMotion === true ? { rootMotion: true } : {}) } } });
107
+ }, [graph, frame, asset?.id, ui, durations]);
102
108
  const timeRef = useRef(time);
103
109
  timeRef.current = time;
104
110
  useEffect(() => {
@@ -121,7 +127,9 @@ function GraphInspector({ session, ui, marker, asset, }) {
121
127
  return () => cancelAnimationFrame(handle);
122
128
  }, [playing]);
123
129
  const commit = (next) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...animationMetaPatch(next) } } }, { coalesce: `animation:${marker.id}` });
124
- if (graph === null || effective === null || frame === null) {
130
+ if (invalid)
131
+ return (_jsxs("div", { className: "space-y-2 p-3 text-[11px]", children: [_jsxs("div", { role: "alert", "aria-label": "Animation diagnostics", className: "space-y-1 text-amber-200", children: [_jsx("p", { children: "The saved animation override is invalid. Its graph cannot be previewed or edited until repaired or replaced." }), diagnostics.map((entry) => _jsxs("div", { children: [_jsxs("div", { className: "break-all", children: [entry.path, ": ", entry.message] }), _jsx("div", { children: entry.repair })] }, `${entry.path}:${entry.message}`))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(undefined), children: "Remove invalid animation override" }), _jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit("auto"), children: "Replace invalid override with Auto" })] }), _jsx("p", { className: "text-neutral-500", children: "Replacement discards the invalid override and can be undone. Repair its saved fields through the existing marker metadata API to retain the intended graph." })] }));
132
+ if (graph === null || effective === null || frame === null || asset === undefined) {
125
133
  return (_jsx(EmptyState, { icon: "film", title: "No graph", badge: marker.label ?? marker.id, description: "This placement plays no graph: its animation is off, a single clip, or the rig has no idle clip. Pick Custom or Auto in the inspector's Animation section." }));
126
134
  }
127
135
  const weights = new Map(frame.clips.map((clip) => [`${clip.layer}:${clip.clip}`, clip.weight]));
@@ -12,8 +12,9 @@ export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
12
12
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
13
13
  * @internal
14
14
  */
15
- export declare function PlayModeBar({ gameId, api, onExit, }: {
15
+ export declare function PlayModeBar({ gameId, api, onExit, exitLabel, }: {
16
16
  gameId: string;
17
17
  api: EditorHostApi;
18
18
  onExit: () => void;
19
+ exitLabel?: string;
19
20
  }): import("react").JSX.Element;
@@ -19,7 +19,7 @@ export function usePlayControl(api) {
19
19
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
20
20
  * @internal
21
21
  */
22
- export function PlayModeBar({ gameId, api, onExit, }) {
22
+ export function PlayModeBar({ gameId, api, onExit, exitLabel = "Editor", }) {
23
23
  const play = usePlayControl(api);
24
24
  const pause = () => {
25
25
  api.handle({ method: "runtime_pause" });
@@ -32,5 +32,5 @@ export function PlayModeBar({ gameId, api, onExit, }) {
32
32
  };
33
33
  return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, role: "banner", "aria-label": "Play mode", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-emerald-500/30 to-cyan-500/25 text-[11px] font-bold tracking-wide text-emerald-200 ring-1 ring-inset ring-emerald-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Play mode" })] }), _jsxs("span", { className: `ml-1 hidden items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:flex ${play.paused
34
34
  ? "bg-amber-500/15 text-amber-200 ring-1 ring-inset ring-amber-400/25"
35
- : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), "Editor"] })] })] }));
35
+ : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), exitLabel] })] })] }));
36
36
  }
@@ -8,7 +8,7 @@ export declare const ADD_VOLUME_ENTRIES: readonly {
8
8
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
9
9
  * snapping, viewport overlays, framing, projection, and the Add menu.
10
10
  */
11
- export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }: {
11
+ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }: {
12
12
  tool: EditorTool;
13
13
  gizmoMode: GizmoMode;
14
14
  gizmoSpace: GizmoSpace;
@@ -40,4 +40,5 @@ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot,
40
40
  onStartPlacement: (tool: PlacementTool) => void;
41
41
  onOpenAssistant: () => void;
42
42
  assistantOpen: boolean;
43
+ allowedKinds?: readonly string[];
43
44
  }): import("react").JSX.Element;
@@ -70,7 +70,7 @@ const PIVOT_LABEL = {
70
70
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
71
71
  * snapping, viewport overlays, framing, projection, and the Add menu.
72
72
  */
73
- export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }) {
73
+ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }) {
74
74
  const [addOpen, setAddOpen] = useState(false);
75
75
  const [snapOpen, setSnapOpen] = useState(false);
76
76
  const [pivotOpen, setPivotOpen] = useState(false);
@@ -79,7 +79,8 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
79
79
  lastSnapRef.current = snapMode;
80
80
  // Recomputed every render (opening the menu rerenders): studios may register after the chrome
81
81
  // mounts (game modules load lazily), and a once-on-mount snapshot would silently hide them.
82
- const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined);
82
+ const allowed = (kind) => allowedKinds === undefined || allowedKinds.includes(kind);
83
+ const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined && allowed(definition.kind));
83
84
  const pick = (placement) => {
84
85
  setAddOpen(false);
85
86
  onStartPlacement(placement);
@@ -112,7 +113,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
112
113
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
113
114
  : "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
114
115
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
115
- : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
+ : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.filter(allowed).map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.filter((entry) => ("kind" in entry.tool && allowed(entry.tool.kind))).map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
117
  const category = definition.addCategory ?? "Studios";
117
118
  (groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
118
119
  return groups;
@@ -120,7 +121,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
120
121
  ? { tool: "path", kind: definition.kind }
121
122
  : definition.target === "volume"
122
123
  ? { tool: "volume", kind: definition.kind, shape: "box" }
123
- : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
+ : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", disabled: !allowed("route"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", disabled: !allowed("road"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
125
  ? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
125
126
  : "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
126
127
  }