@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,79 @@
1
+ import { materialCapabilitiesForAsset } from "@jgengine/core/material/materialAsset";
2
+ /** Prepare an explicit artist edit by retaining declarations and adding its shader requirements. @internal */
3
+ export function prepareMaterialAuthoringEdit(asset) {
4
+ return { ...asset, capabilities: [...new Set([...asset.capabilities, ...materialCapabilitiesForAsset(asset)])] };
5
+ }
6
+ const color = { key: "color", label: "Base color", kind: "color", fallback: "#ffffff" };
7
+ const roughness = { key: "roughness", label: "Roughness", kind: "number", fallback: 1, min: 0, max: 1, step: 0.01 };
8
+ const metalness = { key: "metalness", label: "Metalness", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 };
9
+ const anisotropy = { key: "anisotropy", label: "Directional highlight", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 };
10
+ const rotation = { key: "anisotropyRotation", label: "Highlight direction (radians)", kind: "number", fallback: 0, min: -Math.PI, max: Math.PI, step: 0.01 };
11
+ const sheen = { key: "sheen", label: "Grazing sheen", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 };
12
+ const sheenRoughness = { key: "sheenRoughness", label: "Sheen roughness", kind: "number", fallback: 1, min: 0.07, max: 1, step: 0.01 };
13
+ const coat = { key: "clearcoat", label: "Coating", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 };
14
+ const coatRoughness = { key: "clearcoatRoughness", label: "Coating roughness", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 };
15
+ const ior = { key: "ior", label: "Index of refraction", kind: "number", fallback: 1.5, min: 1, max: 2.333, step: 0.01 };
16
+ const specular = { key: "specularIntensity", label: "Specular intensity", kind: "number", fallback: 1, min: 0, max: 1, step: 0.01 };
17
+ const transmission = { key: "transmission", label: "Transmission", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01, hint: "Light through a surface. Keep opacity at 1 for physical transmission." };
18
+ const thickness = { key: "thickness", label: "Thickness", kind: "number", fallback: 0, min: 0, step: 0.01 };
19
+ const opacity = { key: "opacity", label: "Opacity", kind: "number", fallback: 1, min: 0, max: 1, step: 0.01 };
20
+ const cutoff = { key: "alphaCutoff", label: "Mask cutoff", kind: "number", fallback: 0.5, min: 0, max: 1, step: 0.01 };
21
+ const base = { label: "Base", hint: "Surface color multiplies the base-color map.", controls: [color] };
22
+ const highlights = { label: "Highlights", hint: "Microsurface roughness controls the width of reflections.", controls: [roughness] };
23
+ const detail = { label: "Detail", hint: "Assign normal and roughness maps below; their UVs and scale belong to each texture.", controls: [] };
24
+ const coverage = { label: "Coverage", hint: "Alpha masking cuts holes; blending changes coverage. Neither is physical transmission.", controls: [opacity, cutoff] };
25
+ /**
26
+ * Returns six primary groups suited to the material family without hiding the physical model.
27
+ * @capability editor-material-controls Group physical controls by surface family while retaining advanced fields.
28
+ */
29
+ export function materialControlGroups(family) {
30
+ switch (family) {
31
+ case "fabric": return [base, highlights,
32
+ { label: "Fibre sheen", hint: "Sheen approximates grazing fuzz; it does not add fibres to the silhouette.", controls: [sheen, sheenRoughness] },
33
+ { label: "Weave direction", hint: "Directional silk highlights need valid UVs and normals. Tangents improve authored orientation.", controls: [anisotropy, rotation] }, detail, coverage];
34
+ case "hair": return [base, highlights,
35
+ { label: "Strand highlights", hint: "A directional physical lobe approximates card or strand highlights; this is not a dedicated hair scattering model.", controls: [anisotropy, rotation] },
36
+ { label: "Backlighting", hint: "The hair-card adapter adds an approximate light response from behind. It does not provide hair multiple scattering.", controls: [] }, detail, coverage];
37
+ case "glass": return [base, highlights,
38
+ { label: "Transmission", hint: "Use opaque coverage with transmission. Refraction needs content behind the glass.", controls: [transmission, ior] },
39
+ { label: "Volume", hint: "Thickness and attenuation approximate the distance travelled through the medium.", controls: [thickness, { key: "attenuationColor", label: "Attenuation color", kind: "color", fallback: "#ffffff" }, { key: "attenuationDistance", label: "Attenuation distance", kind: "number", fallback: 1, min: 0.001, step: 0.01 }] }, detail, coverage];
40
+ case "metal": return [base, highlights,
41
+ { label: "Metal response", hint: "Metalness blends conductor and dielectric response.", controls: [metalness] },
42
+ { label: "Brush direction", hint: "Directional highlights require UVs and normals; geometry and a direction map define the brushing.", controls: [anisotropy, rotation] }, detail,
43
+ { label: "Coating", hint: "A separate clear surface lobe overlays the substrate.", controls: [coat, coatRoughness] }];
44
+ default: return [base, highlights,
45
+ { label: "Surface response", hint: family === "skin" ? "Standard surface response approximates skin; subsurface scattering is not provided." : "Dielectric specular response and metallic coverage.", controls: [metalness, specular, ior] },
46
+ { label: "Coating", hint: "Clearcoat adds a surface lobe; geometry remains unchanged.", controls: [coat, coatRoughness] }, detail, coverage];
47
+ }
48
+ }
49
+ /** Complete supported physical scalar/color fields, independent of the six primary groups. */
50
+ export const advancedMaterialControls = [
51
+ color, roughness, metalness, specular, ior,
52
+ { key: "specularColor", label: "Specular color", kind: "color", fallback: "#ffffff" },
53
+ sheen, sheenRoughness, { key: "sheenColor", label: "Sheen color", kind: "color", fallback: "#000000" },
54
+ anisotropy, rotation, coat, coatRoughness, transmission, thickness,
55
+ { key: "attenuationColor", label: "Attenuation color", kind: "color", fallback: "#ffffff" },
56
+ { key: "attenuationDistance", label: "Attenuation distance", kind: "number", fallback: 1, min: 0.001, step: 0.01 },
57
+ { key: "iridescence", label: "Iridescence", kind: "number", fallback: 0, min: 0, max: 1, step: 0.01 },
58
+ { key: "iridescenceIOR", label: "Film index of refraction", kind: "number", fallback: 1.3, min: 1, step: 0.01 },
59
+ { key: "emissive", label: "Emissive color", kind: "color", fallback: "#000000" },
60
+ { key: "emissiveIntensity", label: "Emissive intensity", kind: "number", fallback: 1, min: 0, step: 0.1 }, opacity, cutoff,
61
+ ];
62
+ /**
63
+ * Reports geometry prerequisites and renderer approximations to authors.
64
+ * @capability editor-material-controls Explain geometry prerequisites and approximations for a material asset.
65
+ */
66
+ export function materialAuthoringNotes(asset) {
67
+ const notes = [];
68
+ if (asset.family === "fabric")
69
+ notes.push("Material controls do not bend cloth, add collision, or create fibre geometry.");
70
+ if (asset.family === "hair")
71
+ notes.push("Cards or strand geometry must be authored separately. This approximation supplies coverage and directional highlights, not a groom, collision, motion, or a hair scattering model.");
72
+ if (asset.family === "skin")
73
+ notes.push("Skin uses physical surface shading. Subsurface scattering is not implemented.");
74
+ if ((asset.surface.anisotropy ?? 0) > 0)
75
+ notes.push("Directional highlights need valid mesh UVs and normals; authored tangents or a direction map control orientation.");
76
+ if ((asset.surface.transmission ?? 0) > 0)
77
+ notes.push("Transmission adds a renderer pass. Use opacity 1; inspect front, grazing, and backlit views.");
78
+ return notes;
79
+ }
@@ -18,7 +18,7 @@ export interface RpcFieldSpec {
18
18
  * `EditorHostApi.handle` (whose per-method guards return honest `ok:false` diagnostics for
19
19
  * missing/unknown targets). Type-checking presented fields is what keeps a fuzzed value — a string
20
20
  * where a number belongs, a scalar where an object belongs — from reaching a live session uncoerced.
21
- * Unknown extra fields are ignored so a superset payload stays forward-compatible.
21
+ * Undeclared top-level fields are rejected by the decoder for every method.
22
22
  *
23
23
  * The same table generates the MCP tool `inputSchema`s (see `mcp/tools.ts`), so `required`/`oneOf`/
24
24
  * `of` here also shape what agents are told a tool accepts. The `Record<EditorBridgeRequest["method"],
@@ -18,7 +18,7 @@ const AXES = ["xz", "xy"];
18
18
  * `EditorHostApi.handle` (whose per-method guards return honest `ok:false` diagnostics for
19
19
  * missing/unknown targets). Type-checking presented fields is what keeps a fuzzed value — a string
20
20
  * where a number belongs, a scalar where an object belongs — from reaching a live session uncoerced.
21
- * Unknown extra fields are ignored so a superset payload stays forward-compatible.
21
+ * Undeclared top-level fields are rejected by the decoder for every method.
22
22
  *
23
23
  * The same table generates the MCP tool `inputSchema`s (see `mcp/tools.ts`), so `required`/`oneOf`/
24
24
  * `of` here also shape what agents are told a tool accepts. The `Record<EditorBridgeRequest["method"],
@@ -55,6 +55,8 @@ export const RPC_FIELD_SCHEMAS = {
55
55
  camera_goto: [s("id"), n("x"), n("y"), n("z"), n("distance"), n("pitch"), n("yaw"), n("height")],
56
56
  camera_frame: [n("distance"), n("pitch"), n("yaw"), n("height")],
57
57
  scene_summary: [],
58
+ get_simulation: [],
59
+ set_simulation: [req(val("simulation"))],
58
60
  export_document: [],
59
61
  import_document: [req(s("json"))],
60
62
  dispatch: [req(o("command"))],
@@ -81,6 +83,7 @@ export const RPC_FIELD_SCHEMAS = {
81
83
  set_parent: [req(sa("ids")), req(sn("parentId"))],
82
84
  hierarchy: [],
83
85
  list_prefabs: [],
86
+ set_prefab_static_bake: [req(s("prefabId")), req({ name: "bake", kind: "object", nullable: true, of: [req(s("assetId")), oa("collisionBoxes", [req(val("min")), req(val("max"))]), oa("clearances", [req(s("id")), req(val("min")), req(val("max"))])] })],
84
87
  create_prefab: [req(s("id")), req(s("name")), req(sa("ids"))],
85
88
  insert_prefab: [req(s("prefabId")), n("x"), n("y"), n("z")],
86
89
  detach_prefab_instance: [req(s("instanceId"))],
@@ -96,6 +99,12 @@ export const RPC_FIELD_SCHEMAS = {
96
99
  select_collection: [req(s("id"))],
97
100
  set_object_flags: [req(sa("ids")), b("locked"), b("hidden")],
98
101
  batch_set_properties: [req(sa("ids")), s("color"), s("label"), o("meta")],
102
+ list_material_assets: [],
103
+ list_material_slots: [req(s("id"))],
104
+ upsert_material_asset: [req(o("asset")), s("coalesce")],
105
+ remove_material_asset: [req(s("id"))],
106
+ assign_material_asset: [req(sa("ids")), req(s("materialId")), req(o("selector"))],
107
+ clear_material_assets: [req(sa("ids"))],
99
108
  assign_material: [req(sa("ids")), req(s("materialId"))],
100
109
  list_grids: [],
101
110
  get_grid_cell: [req(s("id")), req(n("col")), req(n("row"))],
@@ -107,7 +116,7 @@ export const RPC_FIELD_SCHEMAS = {
107
116
  flood_fill_grid: [req(s("id")), req(n("col")), req(n("row")), req(s("value"))],
108
117
  resize_grid_layer: [req(s("id")), req(n("cols")), req(n("rows"))],
109
118
  import_grid: [req(s("id")), req(s("kind")), req(s("format", ["ascii", "csv"])), req(s("text")), s("empty"), n("cellSize"), v3("origin"), o("glyphMap"), oa("palette")],
110
- push_document_patch: [req(o("patch")), b("force")],
119
+ push_document_patch: [{ ...req(o("patch")), of: [req(s("type", ["snapshot", "commands"])), req(n("baseRevision")), oa("commands"), o("document"), n("revision")] }, b("force")],
111
120
  pull_document_patches: [n("sinceRevision")],
112
121
  document_revision: [b("includeDocument")],
113
122
  push_runtime_delta: [n("at"), oa("entities"), sa("removeIds"), o("tunables")],
@@ -0,0 +1,2 @@
1
+ /** @internal Missing optional files are valid; existing modules must load successfully. */
2
+ export declare function importOptionalGameModule(path: URL): Promise<Record<string, unknown> | undefined>;
@@ -0,0 +1,13 @@
1
+ import { access } from "node:fs/promises";
2
+ /** @internal Missing optional files are valid; existing modules must load successfully. */
3
+ export async function importOptionalGameModule(path) {
4
+ try {
5
+ await access(path);
6
+ }
7
+ catch (error) {
8
+ if (error.code === "ENOENT")
9
+ return undefined;
10
+ throw error;
11
+ }
12
+ return import(path.href);
13
+ }
@@ -1,12 +1,4 @@
1
- async function importOptionalEditorCatalogsModule(gameId) {
2
- try {
3
- const path = new URL(`../../../../Games/${gameId}/src/editorCatalogs.ts`, import.meta.url);
4
- return (await import(path.href));
5
- }
6
- catch {
7
- return undefined;
8
- }
9
- }
1
+ import { importOptionalGameModule } from "./importOptionalGameModule.js";
10
2
  function isPlainObject(value) {
11
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
4
  }
@@ -72,11 +64,12 @@ export function decodeGameCatalogs(resolved) {
72
64
  * @internal
73
65
  */
74
66
  export async function loadGameCatalogs(gameId) {
75
- const mod = await importOptionalEditorCatalogsModule(gameId);
76
- if (mod?.editorCatalogs === undefined)
77
- return { ok: true, catalogs: [] };
78
67
  let resolved;
79
68
  try {
69
+ const path = new URL(`../../../../Games/${gameId}/src/editorCatalogs.ts`, import.meta.url);
70
+ const mod = await importOptionalGameModule(path);
71
+ if (mod?.editorCatalogs === undefined)
72
+ return { ok: true, catalogs: [] };
80
73
  const value = mod.editorCatalogs;
81
74
  resolved = typeof value === "function" ? value() : value;
82
75
  }
@@ -18,8 +18,8 @@ export type DecodeRpcRequestResult = {
18
18
  * call) before it reaches `EditorHostApi.handle`: confirms it is a plain object carrying a known
19
19
  * `method`, then type-checks every field the method understands against {@link RPC_FIELD_SCHEMAS}.
20
20
  * A garbled method, or a field whose value is the wrong type (a string where a number belongs, a
21
- * scalar where an object belongs), is rejected here with a path-specific diagnostic instead of
22
- * flowing into a live session on a blind cast. Missing fields and unknown extra fields are left for
23
- * `handle` to interpret so the boundary stays forward-compatible.
21
+ * scalar where an object belongs), or an undeclared field is rejected here with a path-specific
22
+ * diagnostic instead of flowing into a live session on a blind cast. Missing fields are left for
23
+ * `handle` to interpret.
24
24
  */
25
25
  export declare function decodeEditorBridgeRequest(raw: unknown): DecodeRpcRequestResult;
@@ -60,9 +60,9 @@ function validateField(value, spec, errors) {
60
60
  * call) before it reaches `EditorHostApi.handle`: confirms it is a plain object carrying a known
61
61
  * `method`, then type-checks every field the method understands against {@link RPC_FIELD_SCHEMAS}.
62
62
  * A garbled method, or a field whose value is the wrong type (a string where a number belongs, a
63
- * scalar where an object belongs), is rejected here with a path-specific diagnostic instead of
64
- * flowing into a live session on a blind cast. Missing fields and unknown extra fields are left for
65
- * `handle` to interpret so the boundary stays forward-compatible.
63
+ * scalar where an object belongs), or an undeclared field is rejected here with a path-specific
64
+ * diagnostic instead of flowing into a live session on a blind cast. Missing fields are left for
65
+ * `handle` to interpret.
66
66
  */
67
67
  export function decodeEditorBridgeRequest(raw) {
68
68
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
@@ -77,7 +77,13 @@ export function decodeEditorBridgeRequest(raw) {
77
77
  return { ok: false, errors: [{ path: "$.method", message: `unknown method "${method}"` }] };
78
78
  }
79
79
  const errors = [];
80
- for (const spec of RPC_FIELD_SCHEMAS[method]) {
80
+ const specs = RPC_FIELD_SCHEMAS[method];
81
+ const allowed = new Set(["method", ...specs.map((spec) => spec.name)]);
82
+ for (const field of Object.keys(record)) {
83
+ if (!allowed.has(field))
84
+ errors.push({ path: `$.${field}`, message: `unknown field "${field}" for method "${method}"` });
85
+ }
86
+ for (const spec of specs) {
81
87
  validateField(record[spec.name], spec, errors);
82
88
  }
83
89
  if (errors.length > 0)
package/dist/mcp/tools.js CHANGED
@@ -31,10 +31,12 @@ const TOOL_DESCRIPTIONS = [
31
31
  { name: "select", description: "Select editor object ids." },
32
32
  { name: "camera_goto", description: "Focus the editor camera on an id or world x/z. Optional distance/pitch/yaw/height reposition the orbit camera (pitch is degrees above the horizon; 90 = straight-down aerial) so an aerial composes in one call." },
33
33
  { name: "camera_frame", description: "Frame the whole editor document. Optional distance/pitch/yaw/height compose an aerial; when pitch is set without distance the region is auto-fit into view so the camera never buries in terrain." },
34
+ { name: "get_simulation", description: "Read authored weather profiles/schedules, wind zones, named emitters, fire areas and flock habitats. Distances are metres, time is game seconds, wind is XZ metres/second." },
35
+ { name: "set_simulation", description: "Replace validated authored simulation inputs atomically as one undo step. Pass null to clear. Runtime heat, particles and accumulation never overwrite authored inputs." },
34
36
  { name: "scene_summary", description: "Compact summary of the editor document and bounds." },
35
37
  { name: "export_document", description: "Export the editor document as JSON text." },
36
38
  { name: "import_document", description: "Replace the session document from JSON text." },
37
- { name: "push_document_patch", description: "Apply a versioned document patch (snapshot or commands) over the live-sync bus. Document is authoritative; force skips baseRevision checks." },
39
+ { name: "push_document_patch", description: "Apply a versioned document patch (snapshot or commands). Command batches validate atomically, publish once, and form one undo step; failure reports commands[index] without applying any edits. Same-kind add commands upsert stable ids. History commands are rejected. Force skips baseRevision checks." },
38
40
  { name: "pull_document_patches", description: "Pull document patches after a known revision (live-sync stream for a running game)." },
39
41
  { name: "document_revision", description: "Current live-sync document revision; optionally include the full document." },
40
42
  { name: "push_runtime_delta", description: "Publish ephemeral runtime state (entities/tunables) on the reverse channel — does not mutate the document." },
@@ -74,6 +76,7 @@ const TOOL_DESCRIPTIONS = [
74
76
  { name: "set_parent", description: "Parent objects under another (or null to unparent); cycles are refused. Moving a parent moves its subtree." },
75
77
  { name: "hierarchy", description: "The scene's parent/child tree: root ids and each root's direct children." },
76
78
  { name: "list_prefabs", description: "List the document's reusable prefab stamps (id, name, fragment content)." },
79
+ { name: "set_prefab_static_bake", description: "Author static prefab export settings (assetId, local collisionBoxes, named clearance boxes); null clears settings. One undo step. Export verified source bytes offline through assets/staticPrefabBake." },
77
80
  { name: "create_prefab", description: "Make a reusable prefab from selected ids, centered on their own bounds." },
78
81
  { name: "insert_prefab", description: "Insert a fresh, tagged instance of a prefab at the camera focus or given x/y/z." },
79
82
  { name: "detach_prefab_instance", description: "Strip the prefab link from every object in an instance, keeping its content." },
@@ -89,6 +92,12 @@ const TOOL_DESCRIPTIONS = [
89
92
  { name: "select_collection", description: "Restore the current selection to a collection's member ids." },
90
93
  { name: "set_object_flags", description: "Set per-object locked (blocks transform/delete) and/or hidden (viewport hide) flags on placeables by id. false clears the field." },
91
94
  { name: "batch_set_properties", description: "Patch color/label/meta across every listed id in one dispatch, regardless of kind." },
95
+ { name: "list_material_assets", description: "List reusable authored material assets and per-marker named slot assignments." },
96
+ { name: "list_material_slots", description: "Read a marker's canonical loaded mesh/material slot names, indices, material type, UV sets and tangents. Select the marker and open Materials to load it; loading/unavailable states return explicit status rather than empty ready data." },
97
+ { name: "upsert_material_asset", description: "Validate and save a reusable physical material asset as one undo step; omitted surface fields preserve imported appearance." },
98
+ { name: "remove_material_asset", description: "Remove an unreferenced material asset; assigned materials fail with a diagnostic." },
99
+ { name: "assign_material_asset", description: "Assign a material asset to exact mesh/material-slot names or slot index on selected markers. Untouched imported slots remain intact." },
100
+ { name: "clear_material_assets", description: "Clear selected markers authored slot assignments and restore imported materials." },
92
101
  { name: "assign_material", description: "Stamp meta.materialId on every listed object — the drag-drop material assignment primitive." },
93
102
  { name: "list_grids", description: "List the scene document's grid/tile layers (id, kind, bounds, cell count, palette ids)." },
94
103
  { name: "get_grid_cell", description: "Eyedrop: read the value id at col,row of a grid layer (empty value for unset/out-of-bounds cells)." },
@@ -1,4 +1,5 @@
1
- import { type AnimGraph } from "@jgengine/core/anim/animGraph";
1
+ import type { AnimGraph } from "@jgengine/core/anim/animGraph";
2
+ import { type AuthoredAnimationDiagnostic, type AuthoredAnimationDocumentLike } from "@jgengine/core/world/authoredAnimation";
2
3
  /**
3
4
  * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
4
5
  * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
@@ -39,11 +40,15 @@ export interface AuthoredAnimationStates {
39
40
  }
40
41
  /** Structurally compatible with `ModelAnimationConfig`, but every field optional for authoring. */
41
42
  export interface AuthoredAnimationConfig {
43
+ auto?: true;
44
+ clock?: "real" | "game";
42
45
  clip?: string;
43
46
  loop?: boolean;
44
47
  timeScale?: number;
48
+ paused?: boolean;
49
+ time?: number;
45
50
  states?: AuthoredAnimationStates;
46
- oneShots?: Record<string, string>;
51
+ oneShots?: Record<string, string | readonly string[]>;
47
52
  /** A stored animation graph; takes over from `states`/`oneShots` at play time. */
48
53
  graph?: AnimGraph;
49
54
  }
@@ -51,8 +56,13 @@ export interface AuthoredAnimationConfig {
51
56
  export type AnimationSetting = AuthoredAnimationConfig | "auto" | "none";
52
57
  /** Which authoring mode a stored setting represents. `default` = no override key. */
53
58
  export type AnimationMode = "default" | "auto" | "none" | "custom";
54
- /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
59
+ /** Reads a whole valid override, retaining authored fields; malformed values return undefined. */
55
60
  export declare function readAnimationSetting(meta: Record<string, unknown> | undefined): AnimationSetting | undefined;
61
+ /** Validates a placement's animation with its saved-document repair locations. @internal */
62
+ export declare function readAnimationSettingResult(document: AuthoredAnimationDocumentLike, markerId: string): {
63
+ setting: AnimationSetting | undefined;
64
+ diagnostics: readonly AuthoredAnimationDiagnostic[];
65
+ };
56
66
  /** The authoring mode a stored setting maps to. */
57
67
  export declare function animationMode(setting: AnimationSetting | undefined): AnimationMode;
58
68
  /**
@@ -64,11 +74,21 @@ export declare function setAnimationMode(setting: AnimationSetting | undefined,
64
74
  /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
65
75
  export declare function defaultCustomConfig(clips: readonly string[]): AuthoredAnimationConfig;
66
76
  /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
67
- export declare function setLocomotionClip(setting: AnimationSetting | undefined, role: LocomotionRole, clipName: string | null): AuthoredAnimationConfig;
77
+ export declare function setLocomotionClip(setting: AnimationSetting | undefined, role: LocomotionRole, clipName: string | null, clips?: readonly string[]): AuthoredAnimationConfig;
68
78
  /** Sets or clears (null) a numeric locomotion tuning. */
69
- export declare function setLocomotionNumber(setting: AnimationSetting | undefined, key: LocomotionNumber, value: number | null): AuthoredAnimationConfig;
70
- /** Binds or clears (null) a one-shot event to a clip. */
71
- export declare function setOneShotClip(setting: AnimationSetting | undefined, event: string, clipName: string | null): AuthoredAnimationConfig;
79
+ export declare function setLocomotionNumber(setting: AnimationSetting | undefined, key: LocomotionNumber, value: number | null, clips?: readonly string[]): AuthoredAnimationConfig;
80
+ /** Binds or clears (null) a one-shot event's clip or variant list. */
81
+ export declare function setOneShotClip(setting: AnimationSetting | undefined, event: string, clipName: string | readonly string[] | null): AuthoredAnimationConfig;
82
+ /** Sets a single playback clip, explicitly replacing speed-driven states and a stored graph. @internal */
83
+ export declare function setPlaybackClip(setting: AnimationSetting | undefined, clip: string | null): AuthoredAnimationConfig;
84
+ /** Changes a playback scalar while preserving role mappings, variants and the stored graph. @internal */
85
+ export declare function setPlaybackNumber(setting: AnimationSetting | undefined, key: "time" | "timeScale", value: number | null): AuthoredAnimationConfig;
86
+ /** Chooses the playback clock without replacing mappings, graph, or other authored fields. @internal */
87
+ export declare function setPlaybackClock(setting: AnimationSetting | undefined, clock: "real" | "game"): AuthoredAnimationConfig;
88
+ /** Requests loaded-rig role inference, or removes that request, retaining explicit overrides whole. @internal */
89
+ export declare function setAutomaticRoles(setting: AnimationSetting | undefined, derive: boolean): AuthoredAnimationConfig;
90
+ /** Changes a playback toggle without replacing the remaining authored settings. @internal */
91
+ export declare function setPlaybackBoolean(setting: AnimationSetting | undefined, key: "paused" | "loop", value: boolean): AuthoredAnimationConfig;
72
92
  /**
73
93
  * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
74
94
  * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
@@ -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