@jgengine/editor 0.18.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +24 -26
  4. package/dist/CreatorApp.d.ts +12 -0
  5. package/dist/CreatorApp.js +107 -0
  6. package/dist/DebugDraw.js +3 -1
  7. package/dist/EditorApp.d.ts +14 -2
  8. package/dist/EditorApp.js +29 -14
  9. package/dist/EditorChrome.d.ts +9 -1
  10. package/dist/EditorChrome.js +61 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/LightingPanel.js +5 -1
  14. package/dist/MaterialAssetEditor.d.ts +13 -0
  15. package/dist/MaterialAssetEditor.js +109 -0
  16. package/dist/MaterialPreview.d.ts +16 -0
  17. package/dist/MaterialPreview.js +41 -0
  18. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  19. package/dist/MaterialsWorkspacePanel.js +78 -4
  20. package/dist/RuntimePlayBridge.js +1 -6
  21. package/dist/SelectionGizmo.js +23 -14
  22. package/dist/SimulationInspector.d.ts +9 -0
  23. package/dist/SimulationInspector.js +47 -0
  24. package/dist/TerrainSculpt.js +3 -1
  25. package/dist/agent/toolBridge.js +2 -0
  26. package/dist/authoringPlacement.d.ts +21 -0
  27. package/dist/authoringPlacement.js +34 -0
  28. package/dist/handlers/context.d.ts +1 -0
  29. package/dist/handlers/document.d.ts +1 -1
  30. package/dist/handlers/document.js +31 -1
  31. package/dist/handlers/hierarchy.d.ts +1 -1
  32. package/dist/handlers/hierarchy.js +4 -0
  33. package/dist/handlers/navmesh.d.ts +2 -0
  34. package/dist/handlers/navmesh.js +16 -4
  35. package/dist/handlers/runtime.js +20 -17
  36. package/dist/handlers/terrain.js +52 -12
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.js +1 -0
  39. package/dist/materialControls.d.ts +32 -0
  40. package/dist/materialControls.js +79 -0
  41. package/dist/mcp/fieldSpec.d.ts +1 -1
  42. package/dist/mcp/fieldSpec.js +13 -4
  43. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  44. package/dist/mcp/importOptionalGameModule.js +13 -0
  45. package/dist/mcp/loadGameCatalogs.js +5 -12
  46. package/dist/mcp/rpcRequest.d.ts +3 -3
  47. package/dist/mcp/rpcRequest.js +10 -4
  48. package/dist/mcp/tools.js +13 -4
  49. package/dist/modelAnimationAuthoring.d.ts +49 -6
  50. package/dist/modelAnimationAuthoring.js +118 -64
  51. package/dist/session.d.ts +64 -0
  52. package/dist/session.js +58 -7
  53. package/dist/shell/AnimationGraphPanel.d.ts +16 -0
  54. package/dist/shell/AnimationGraphPanel.js +180 -0
  55. package/dist/shell/AnimationPanel.d.ts +3 -1
  56. package/dist/shell/AnimationPanel.js +9 -7
  57. package/dist/shell/PlayModeBar.d.ts +2 -1
  58. package/dist/shell/PlayModeBar.js +2 -2
  59. package/dist/shell/SceneToolbar.d.ts +2 -1
  60. package/dist/shell/SceneToolbar.js +5 -4
  61. package/dist/shell/TopAppBar.d.ts +2 -1
  62. package/dist/shell/TopAppBar.js +2 -2
  63. package/dist/shell/WorkspaceRail.js +1 -1
  64. package/dist/shell/animGraphPreview.d.ts +58 -0
  65. package/dist/shell/animGraphPreview.js +95 -0
  66. package/dist/shell/clipPreview.d.ts +9 -0
  67. package/dist/useDocumentSave.d.ts +20 -0
  68. package/dist/useDocumentSave.js +33 -0
  69. package/package.json +6 -5
  70. package/skills/ce-handoff/SKILL.md +2 -2
  71. package/skills/jgengine/SKILL.md +28 -6
  72. package/skills/jgengine/api.md +139 -99
  73. package/skills/jgengine/capabilities.md +28 -0
  74. package/skills/jgengine/recipes/minimal-game.md +10 -2
  75. package/skills/jgengine-editor/SKILL.md +12 -2
  76. package/skills/jgengine-editor/api.md +120 -16
  77. package/skills/jgengine-editor/capabilities.md +40 -0
  78. package/skills/jgengine-editor/reference.md +161 -3
  79. package/skills/jgengine-editor/references/live-sync.md +15 -0
  80. package/skills/level-design/SKILL.md +1 -1
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Canvas, useLoader } from "@react-three/fiber";
3
+ import { Component, Suspense, useEffect, useState } from "react";
4
+ import * as THREE from "three";
5
+ import { inspectModelMaterialSlots } from "@jgengine/shell/render/materialAsset";
6
+ import { sharedGltfLoader } from "@jgengine/shell/render/modelLoad";
7
+ import { EntityModel } from "@jgengine/shell/render/SceneModels";
8
+ import { DEFAULT_SUN_ELEVATION_DEG, sunDirectionFromBearing } from "@jgengine/shell/environment/daylightCycle";
9
+ import { MaterialAssetSurface } from "@jgengine/shell/render/materialAsset";
10
+ import { EnvironmentLighting } from "@jgengine/shell/render/EnvironmentLighting";
11
+ import { INPUT_CLS } from "./shell/theme.js";
12
+ function SlotProbe({ model, onSlots }) {
13
+ const gltf = useLoader(sharedGltfLoader, model.url);
14
+ useEffect(() => { onSlots?.(inspectModelMaterialSlots(gltf.scene)); }, [gltf.scene, onSlots]);
15
+ return null;
16
+ }
17
+ function PreviewReady({ onError }) {
18
+ useEffect(() => { onError?.(null); }, [onError]);
19
+ return null;
20
+ }
21
+ class PreviewBoundary extends Component {
22
+ state = { failed: false };
23
+ static getDerivedStateFromError() { return { failed: true }; }
24
+ componentDidCatch(error) { this.props.onError?.(error instanceof Error ? error.message : String(error)); }
25
+ render() { return this.state.failed ? null : this.props.children; }
26
+ }
27
+ /**
28
+ * Material preview lighting uses an isolated renderer; document settings never modify the game.
29
+ * @internal Mounted by the material workspace.
30
+ */
31
+ export function MaterialPreview({ asset, mode, environment, model, onSlots, onError }) {
32
+ const [view, setView] = useState("front");
33
+ const [orientation, setOrientation] = useState(0);
34
+ const game = mode === "game";
35
+ const sky = game ? environment?.zenithColor ?? "#87b5e0" : "#eeeeee";
36
+ const ground = game ? environment?.horizonColor ?? "#3d4a38" : "#666666";
37
+ const sceneLight = sunDirectionFromBearing(environment?.sunAzimuth ?? 0, environment?.sunElevation ?? DEFAULT_SUN_ELEVATION_DEG).map((axis) => axis * 5);
38
+ const light = game ? sceneLight : view === "back" ? [0, 1, -4] : view === "grazing" ? [4, 0.5, 0.2] : [2, 3, 4];
39
+ const flat = asset?.family === "hair" || asset?.family === "fabric";
40
+ return _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-1 text-[10px]", children: [!game ? _jsxs("select", { "aria-label": "Material lighting view", value: view, onChange: (event) => setView(event.target.value), className: INPUT_CLS, children: [_jsx("option", { value: "front", children: "Front light" }), _jsx("option", { value: "grazing", children: "Grazing light" }), _jsx("option", { value: "back", children: "Back light" })] }) : _jsx("span", { children: "Document sun direction" }), _jsxs("label", { children: [model ? "Model yaw" : "Surface orientation", _jsx("input", { "aria-label": "Preview orientation", style: { width: 100 }, type: "range", min: 0, max: 360, value: orientation, onChange: (event) => setOrientation(Number(event.target.value)) })] })] }), _jsx("div", { style: { height: 210 }, "data-jg-material-preview": "", children: _jsxs(Canvas, { dpr: 1, camera: { position: [0, 0.35, 3.6], fov: 38 }, shadows: true, gl: { antialias: true, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 1, outputColorSpace: THREE.SRGBColorSpace }, children: [_jsx("color", { attach: "background", args: ["#24272c"] }), _jsx("ambientLight", { intensity: game ? environment?.ambientIntensity ?? 0.2 : 0.12 }), _jsx("directionalLight", { position: light, intensity: game ? environment?.sunIntensity ?? 3 : 3, castShadow: true, "shadow-bias": -0.0004, "shadow-normalBias": 0.02 }), _jsx(PreviewBoundary, { onError: onError, children: _jsxs(Suspense, { fallback: null, children: [_jsx(EnvironmentLighting, { ...(game && environment?.source !== undefined ? { source: environment.source } : {}), intensity: 0.65, skyColor: sky, groundColor: ground, sunDirection: light }), model ? _jsxs("group", { "rotation-y": orientation * Math.PI / 180, "position-y": -0.85, children: [_jsx(SlotProbe, { model: model, onSlots: onSlots }), _jsx(EntityModel, { model: model })] }) : asset ? _jsxs("mesh", { rotation: [0, 0, orientation * Math.PI / 180], castShadow: true, receiveShadow: true, children: [flat ? _jsx("planeGeometry", { args: [2.1, 1.7, 24, 20] }) : _jsx("sphereGeometry", { args: [0.8, 64, 32] }), _jsx(MaterialAssetSurface, { asset: asset })] }) : null, _jsxs("mesh", { position: [0, -0.95, 0], rotation: [-Math.PI / 2, 0, 0], receiveShadow: true, children: [_jsx("planeGeometry", { args: [7, 7] }), _jsx("meshStandardMaterial", { color: "#393b3f", roughness: 0.95 })] }), _jsx(PreviewReady, { onError: onError })] }) }, JSON.stringify([asset, model]))] }, `${view}:${asset?.id ?? model?.url ?? "empty"}`) }), _jsxs("p", { className: "text-[9px] text-neutral-500", children: [model ? "Selected model displays its saved slot assignments. Choosing another library asset changes controls; assign it to a slot to change this model." : "Parameter sample; imported values require a selected model.", " ", game ? "Document environment and fixed sun; game lighting may add other lights or motion." : "Neutral front, grazing or back light.", " ACES, exposure 1. Geometry, tangents and UVs determine the response."] })] });
41
+ }
@@ -1,12 +1,24 @@
1
- import type { EditorSession } from "@jgengine/core/editor/index";
1
+ import { type ReactNode } from "react";
2
+ import type { EditorDocument, EditorSession } from "@jgengine/core/editor/index";
3
+ import { type MaterialAsset, type MaterialAssignment } from "@jgengine/core/material/materialAsset";
2
4
  import type { EditorHostApi } from "./session.js";
5
+ /** Read the selected model's first usable assignment for workspace initialization. @internal */
6
+ export declare function initialMaterialAssignment(document: Pick<EditorDocument, "markers" | "materialAssets">, selection: readonly string[]): MaterialAssignment | null;
3
7
  /**
4
8
  * Materials workspace home panel: browse every placeable's real `meta.materialId` from the live
5
9
  * document, filter by assignment/palette id/text, select into the hierarchy, and assign/clear via
6
10
  * the existing `assign_material` / `batch_set_properties` RPC seams. No fake thumbnails.
7
11
  * @internal — mounted by `EditorChrome` when the materials workspace is active.
8
12
  */
9
- export declare function MaterialsWorkspacePanel({ session, api, }: {
13
+ export declare function MaterialsWorkspacePanel({ session, api, preview, onSave, materialSlots, previewError, }: {
10
14
  session: EditorSession;
11
15
  api: EditorHostApi;
16
+ preview?: (asset: MaterialAsset | undefined, mode: "neutral" | "game") => ReactNode;
17
+ onSave?: () => void;
18
+ materialSlots?: readonly {
19
+ mesh: string;
20
+ slot: string;
21
+ slotIndex: number;
22
+ }[];
23
+ previewError?: string | null;
12
24
  }): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from "react";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { createMaterialTemplate, parseMaterialAssignments } from "@jgengine/core/material/materialAsset";
3
4
  import { clearMaterialAssignmentPatch } from "./authoredComponentMeta.js";
5
+ import { MaterialAssetEditor } from "./MaterialAssetEditor.js";
4
6
  import { filterMaterialAssignments, listMaterialAssignments, summarizeMaterialUsage, } from "./materialAssignments.js";
5
7
  import { TERRAIN_MATERIALS } from "./uiStore.js";
6
8
  import { FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
@@ -17,17 +19,69 @@ function materialColor(materialId) {
17
19
  function materialLabel(materialId) {
18
20
  return TERRAIN_MATERIALS.find((material) => material.id === materialId)?.label ?? materialId;
19
21
  }
22
+ /** Read the selected model's first usable assignment for workspace initialization. @internal */
23
+ export function initialMaterialAssignment(document, selection) {
24
+ const marker = document.markers.find((item) => selection.includes(item.id));
25
+ const value = marker?.meta?.materialAssignments;
26
+ if (!Array.isArray(value))
27
+ return null;
28
+ for (const item of value) {
29
+ try {
30
+ const assignment = parseMaterialAssignments([item])[0];
31
+ if (document.materialAssets?.some((asset) => asset.id === assignment.materialId))
32
+ return assignment;
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ }
38
+ return null;
39
+ }
20
40
  /**
21
41
  * Materials workspace home panel: browse every placeable's real `meta.materialId` from the live
22
42
  * document, filter by assignment/palette id/text, select into the hierarchy, and assign/clear via
23
43
  * the existing `assign_material` / `batch_set_properties` RPC seams. No fake thumbnails.
24
44
  * @internal — mounted by `EditorChrome` when the materials workspace is active.
25
45
  */
26
- export function MaterialsWorkspacePanel({ session, api, }) {
46
+ export function MaterialsWorkspacePanel({ session, api, preview, onSave, materialSlots, previewError, }) {
27
47
  const document = useStoreSelector(session, (state) => state.document);
28
48
  const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
29
49
  const [query, setQuery] = useState("");
30
50
  const [filter, setFilter] = useState("all");
51
+ const [tab, setTab] = useState("assets");
52
+ const [initialAssignment] = useState(() => { const state = session.getState(); return initialMaterialAssignment(state.document, state.selection); });
53
+ const [activeAssetId, setActiveAssetId] = useState(initialAssignment?.materialId ?? null);
54
+ const [template, setTemplate] = useState("wool");
55
+ const [newId, setNewId] = useState("");
56
+ const [mesh, setMesh] = useState(initialAssignment?.selector.mesh ?? "");
57
+ const [slot, setSlot] = useState(initialAssignment?.selector.slot ?? "");
58
+ const [slotIndex, setSlotIndex] = useState(initialAssignment?.selector.slotIndex === undefined ? "" : String(initialAssignment.selector.slotIndex));
59
+ const [error, setError] = useState(null);
60
+ const selectionKey = selection.join("\0");
61
+ useEffect(() => {
62
+ const state = session.getState();
63
+ const assignment = initialMaterialAssignment(state.document, state.selection);
64
+ if (assignment)
65
+ setActiveAssetId(assignment.materialId);
66
+ setMesh(assignment?.selector.mesh ?? "");
67
+ setSlot(assignment?.selector.slot ?? "");
68
+ setSlotIndex(assignment?.selector.slotIndex === undefined ? "" : String(assignment.selector.slotIndex));
69
+ }, [selectionKey, session]);
70
+ const assets = document.materialAssets ?? [];
71
+ const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? assets[0];
72
+ const assignmentErrors = [];
73
+ const selectedAssignments = document.markers.filter((marker) => selection.includes(marker.id)).flatMap((marker) => {
74
+ const value = marker.meta?.materialAssignments;
75
+ if (!Array.isArray(value))
76
+ return [];
77
+ try {
78
+ return parseMaterialAssignments(value).map((assignment) => ({ ...assignment, objectId: marker.id, objectName: marker.label ?? marker.id }));
79
+ }
80
+ catch (failure) {
81
+ assignmentErrors.push(`${marker.id}: ${failure instanceof Error ? failure.message : String(failure)}`);
82
+ return [];
83
+ }
84
+ });
31
85
  const rows = useMemo(() => listMaterialAssignments(document), [document]);
32
86
  const filtered = useMemo(() => filterMaterialAssignments(rows, query, filter), [rows, query, filter]);
33
87
  const usage = useMemo(() => summarizeMaterialUsage(rows), [rows]);
@@ -59,7 +113,27 @@ export function MaterialsWorkspacePanel({ session, api, }) {
59
113
  return filter.materialId === candidate.materialId;
60
114
  return filter === candidate;
61
115
  };
62
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsx("div", { className: MICRO_LABEL, children: "Materials" }), _jsx("p", { className: "text-[10px] leading-snug text-neutral-500", children: "Document material assignments \u2014 select a row to inspect, assign from the palette, or clear." }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Filter objects\u2026", "aria-label": "Filter material assignments", className: `h-7 w-full px-2 ${INPUT_CLS}` }), _jsx("div", { className: "flex flex-wrap gap-1", role: "group", "aria-label": "Assignment filters", children: [
116
+ const tabs = _jsx("div", { className: "flex shrink-0 gap-1 border-b border-white/[0.06] p-2", role: "tablist", "aria-label": "Material workspace views", children: ["assets", "assignments"].map((view) => _jsx("button", { type: "button", role: "tab", "aria-selected": tab === view, className: `${CHIP} flex-1 ${tab === view ? CHIP_ACTIVE : CHIP_IDLE}`, onClick: () => setTab(view), children: view === "assets" ? "Surface assets" : "Legacy assignments" }, view)) });
117
+ if (tab === "assets")
118
+ return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "min-h-0 flex-1 overflow-auto", children: [_jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Reusable material asset" }), _jsx("select", { "aria-label": "Choose material asset", value: activeAsset?.id ?? "", className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => setActiveAssetId(event.target.value), children: assets.length ? assets.map((asset) => _jsx("option", { value: asset.id, children: asset.name }, asset.id)) : _jsx("option", { value: "", children: "No authored materials" }) })] }), activeAsset ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "remove_material_asset", id: activeAsset.id }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Remove unused asset" }) : null, _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Create from an editable starting point" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("select", { "aria-label": "Material starting point", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: template, onChange: (event) => setTemplate(event.target.value), children: ["wool", "cotton", "silk", "brushed-metal", "glass", "coated-plastic", "stone", "hair-cards"].map((entry) => _jsx("option", { value: entry, children: entry }, entry)) }), _jsx("input", { "aria-label": "New material stable ID", value: newId, onChange: (event) => setNewId(event.target.value), placeholder: "game/my-original-surface", className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("button", { type: "button", disabled: !newId.trim() || assets.some((asset) => asset.id === newId.trim()), className: `${CHIP} ${CHIP_IDLE}`, onClick: () => { const asset = createMaterialTemplate(template, newId.trim()); const result = api.handle({ method: "upsert_material_asset", asset }); if (!result.ok)
119
+ setError(result.error ?? "Material operation failed.");
120
+ else {
121
+ setActiveAssetId(asset.id);
122
+ setNewId("");
123
+ setError(null);
124
+ } }, children: "Create asset" }), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Starting points contain authored response parameters; your game supplies original textures, geometry and art direction." })] })] }), activeAsset ? _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Assign to selected material slot" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("p", { className: "text-[10px] text-neutral-500", children: "Choose a loaded material slot. Assignments preserve other slots on the model." }), materialSlots?.length ? _jsxs("select", { "aria-label": "Choose imported material slot", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: String(materialSlots.findIndex((entry) => entry.mesh === mesh && entry.slot === slot && String(entry.slotIndex) === slotIndex)), onChange: (event) => { const entry = materialSlots[Number(event.target.value)]; if (!entry)
125
+ return; setMesh(entry.mesh); setSlot(entry.slot); setSlotIndex(String(entry.slotIndex)); }, children: [_jsx("option", { value: "-1", children: "Choose a slot\u2026" }), materialSlots.map((entry, index) => _jsxs("option", { value: String(index), children: [entry.mesh || "Unnamed mesh", " / ", entry.slot || `slot ${entry.slotIndex}`] }, `${entry.mesh}:${entry.slotIndex}:${index}`))] }) : _jsx("p", { className: "text-[10px] text-neutral-500", children: "Select a model to inspect its imported slots, or enter an exact selector below." }), _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[10px] text-neutral-400 ${FOCUS_RING}`, children: "Advanced exact selector" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("input", { "aria-label": "Assignment mesh name", placeholder: "Mesh name (optional)", value: mesh, onChange: (event) => setMesh(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("input", { "aria-label": "Assignment material slot name", placeholder: "Material slot name (optional)", value: slot, onChange: (event) => setSlot(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("input", { "aria-label": "Assignment material slot index", type: "number", min: "0", step: "1", placeholder: "Slot index (optional)", value: slotIndex, onChange: (event) => setSlotIndex(event.target.value), className: `w-full px-2 py-1 ${INPUT_CLS}` })] })] }), _jsxs("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, disabled: selection.length === 0 || (!mesh.trim() && !slot.trim() && !slotIndex), onClick: () => {
126
+ const selector = {};
127
+ if (mesh.trim())
128
+ selector.mesh = mesh.trim();
129
+ if (slot.trim())
130
+ selector.slot = slot.trim();
131
+ if (slotIndex)
132
+ selector.slotIndex = Number(slotIndex);
133
+ const result = api.handle({ method: "assign_material_asset", ids: [...selection], materialId: activeAsset.id, selector });
134
+ setError(result.ok ? null : result.error ?? "Material operation failed.");
135
+ }, children: ["Assign to selection (", selection.length, ")"] }), selectedAssignments.map((assignment, index) => _jsxs("button", { type: "button", className: `${CHIP} ${CHIP_IDLE} block w-full text-left`, onClick: () => { setActiveAssetId(assignment.materialId); setMesh(assignment.selector.mesh ?? ""); setSlot(assignment.selector.slot ?? ""); setSlotIndex(assignment.selector.slotIndex === undefined ? "" : String(assignment.selector.slotIndex)); }, children: [assignment.objectName, ": ", assignment.selector.mesh ?? "any mesh", " / ", assignment.selector.slot ?? `slot ${assignment.selector.slotIndex ?? "any"}`, " \u2192 ", assignment.materialId] }, `${assignment.objectId}:${index}`)), selectedAssignments.length ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "clear_material_assets", ids: [...selection] }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Restore selected objects' imported materials" }) : null] })] }) : null, _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: () => api.handle({ method: "undo" }), children: "Undo" }), _jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: () => api.handle({ method: "redo" }), children: "Redo" }), onSave ? _jsx("button", { type: "button", className: `${CHIP} ${CHIP_IDLE}`, onClick: onSave, children: "Save scene" }) : null] }), error ? _jsx("p", { role: "alert", className: "text-[10px] text-rose-300", children: error }) : null, previewError ? _jsxs("p", { role: "alert", className: "text-[10px] text-rose-300", children: ["Preview: ", previewError] }) : null, assignmentErrors.map((message) => _jsx("p", { role: "alert", className: "text-[10px] text-rose-300", children: message }, message))] }), activeAsset ? _jsx(MaterialAssetEditor, { asset: activeAsset, preview: preview, onChange: (asset, coalesce) => { const result = api.handle({ method: "upsert_material_asset", asset, coalesce }); setError(result.ok ? null : result.error ?? "Material operation failed."); } }, activeAsset.id) : _jsxs(_Fragment, { children: [_jsx("div", { className: "p-2", children: preview?.(undefined, "neutral") }), _jsx(EmptyState, { icon: "sphere", title: "No material assets", description: "Create an editable starting point, then assign it to a named mesh or material slot. Existing imported materials remain intact." })] })] })] });
136
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsx("div", { className: MICRO_LABEL, children: "Materials" }), _jsx("p", { className: "text-[10px] leading-snug text-neutral-500", children: "Document material assignments \u2014 select a row to inspect, assign from the palette, or clear." }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Filter objects\u2026", "aria-label": "Filter material assignments", className: `h-7 w-full px-2 ${INPUT_CLS}` }), _jsx("div", { className: "flex flex-wrap gap-1", role: "group", "aria-label": "Assignment filters", children: [
63
137
  { id: "all", label: `All (${rows.length})` },
64
138
  { id: "assigned", label: `Assigned (${assignedCount})` },
65
139
  { id: "unassigned", label: `None (${rows.length - assignedCount})` },
@@ -65,15 +65,10 @@ export function RuntimePlayPublisher({ api }) {
65
65
  api.setPlayControl({ paused: true, pendingSteps: play.pendingSteps - 1 });
66
66
  return;
67
67
  }
68
+ // Freeze before the next simulation frame; positive priorities would take over canvas rendering.
68
69
  if (!ctx.time.isPaused())
69
70
  ctx.time.pause();
70
71
  }, -1);
71
- useFrame(() => {
72
- const play = api.getPlayControl();
73
- if (play.paused && play.pendingSteps === 0 && !ctx.time.isPaused()) {
74
- ctx.time.pause();
75
- }
76
- }, 1);
77
72
  return null;
78
73
  }
79
74
  /**
@@ -4,6 +4,7 @@ import { memo, useEffect, useMemo } from "react";
4
4
  import * as THREE from "three";
5
5
  import { findEditorMarker, findEditorNote, findEditorPath, findEditorVolume, } from "@jgengine/core/editor/index";
6
6
  import { TransformGizmo, } from "@jgengine/shell/structures/TransformGizmo";
7
+ import { placementGround, pathSegmentDistance, selectionGizmoMode, snapPlacement } from "./authoringPlacement.js";
7
8
  import { isPointerClick } from "./viewportContextMenu.js";
8
9
  import { resolvePivotPosition } from "./gizmoPivot.js";
9
10
  import { newPlacementId, } from "./uiStore.js";
@@ -108,13 +109,17 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
108
109
  const planeHit = new THREE.Vector3();
109
110
  let down = null;
110
111
  const session = () => api.getSession();
111
- const screenDistance = (point, lift, clickX, clickY, rect) => {
112
+ const screenPoint = (point, lift, rect) => {
112
113
  projected.set(point.x, point.y + lift, point.z).project(camera);
113
114
  if (projected.z < -1 || projected.z > 1)
114
115
  return null;
115
116
  const px = (projected.x * 0.5 + 0.5) * rect.width;
116
117
  const py = (-projected.y * 0.5 + 0.5) * rect.height;
117
- return Math.hypot(px - clickX, py - clickY);
118
+ return { x: px, y: py };
119
+ };
120
+ const screenDistance = (point, lift, clickX, clickY, rect) => {
121
+ const projectedPoint = screenPoint(point, lift, rect);
122
+ return projectedPoint === null ? null : Math.hypot(projectedPoint.x - clickX, projectedPoint.y - clickY);
118
123
  };
119
124
  const groundPoint = (clickX, clickY, rect) => {
120
125
  ndc.set((clickX / rect.width) * 2 - 1, -(clickY / rect.height) * 2 + 1);
@@ -135,6 +140,11 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
135
140
  const placement = ui.getState().placement;
136
141
  if (placement === null)
137
142
  return;
143
+ const ground = placementGround(session().getState().document.terrain, api.getTerrainSampler());
144
+ const snapped = snapPlacement(point, ui.getState().snapMode, ui.getState().gridSize, (x, z) => ground?.sampleHeight(x, z) ?? 0);
145
+ if (snapped === null)
146
+ return;
147
+ point = snapped;
138
148
  if (placement.tool === "path") {
139
149
  ui.pushDraftPoint(point);
140
150
  return;
@@ -201,11 +211,17 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
201
211
  if (visibility[path.kind] === false || path.hidden === true)
202
212
  continue;
203
213
  let best = null;
214
+ let previous = null;
204
215
  for (const point of path.points) {
205
- const distance = screenDistance(point, 0.8, clickX, clickY, rect);
206
- if (distance !== null && distance < SCREEN_PICK_RADIUS_PX && (best === null || distance < best)) {
207
- best = distance;
216
+ const projectedPoint = screenPoint(point, 0.8, rect);
217
+ if (projectedPoint !== null) {
218
+ const distance = previous === null
219
+ ? Math.hypot(projectedPoint.x - clickX, projectedPoint.y - clickY)
220
+ : pathSegmentDistance({ x: clickX, y: clickY }, previous, projectedPoint);
221
+ if (distance < SCREEN_PICK_RADIUS_PX && (best === null || distance < best))
222
+ best = distance;
208
223
  }
224
+ previous = projectedPoint;
209
225
  }
210
226
  if (best !== null)
211
227
  candidates.push({ id: path.id, distance: best });
@@ -410,7 +426,7 @@ export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
410
426
  });
411
427
  function resolveTarget(session, selection, pathPoint, pivot) {
412
428
  const { document } = session.getState();
413
- if (pathPoint !== null) {
429
+ if (pathPoint !== null && selection.length === 1 && selection[0] === pathPoint.pathId) {
414
430
  const path = findEditorPath(document, pathPoint.pathId);
415
431
  const point = path?.points[pathPoint.index];
416
432
  if (point !== undefined)
@@ -436,13 +452,6 @@ function resolveTarget(session, selection, pathPoint, pivot) {
436
452
  // Multi-select always translates as a group from the pivot; rotation/scale stay primary-kind limited.
437
453
  return { position: pivotPos, lift: primary.lift, rotationY: primary.rotationY, kind: primary.kind };
438
454
  }
439
- function effectiveMode(mode, kind) {
440
- if (kind === "marker")
441
- return mode === "scale" ? "translate" : mode;
442
- if (kind === "volume")
443
- return mode === "rotate" ? "translate" : mode;
444
- return "translate";
445
- }
446
455
  /**
447
456
  * Drag-to-transform gizmo bound to the current selection, dispatching editor commands on
448
457
  * release. Mounts the shared shell `TransformGizmo` and maps release poses onto editor
@@ -453,7 +462,7 @@ export const SelectionGizmo = memo(function SelectionGizmo({ session, ui, ground
453
462
  const state = session.getState();
454
463
  const selectedId = state.selection[0];
455
464
  const target = useMemo(() => resolveTarget(session, state.selection, uiState.pathPoint, uiState.gizmoPivot), [state, uiState.pathPoint, uiState.gizmoPivot, session]);
456
- const mode = target === null ? "translate" : effectiveMode(uiState.gizmoMode, target.kind);
465
+ const mode = target === null ? "translate" : selectionGizmoMode(uiState.gizmoMode, target.kind, target.kind === "pathPoint" ? 1 : state.selection.length);
457
466
  if (target === null)
458
467
  return null;
459
468
  const onRelease = (pose) => {
@@ -0,0 +1,9 @@
1
+ import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
2
+ import type { EditorSession } from "@jgengine/core/editor/commands";
3
+ /** Production authoring controls share the same validated command and undo history as RPC.
4
+ * @capability simulation-inspector author weather schedules, effects, fire, wind zones, forces and habitats with validated undoable controls
5
+ */
6
+ export declare function SimulationInspector({ session, creatorPolicy }: {
7
+ session: EditorSession;
8
+ creatorPolicy?: CreatorPolicy;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useStoreSelector } from "./useStoreSelector.js";
4
+ const modes = ["clear", "rain", "snow", "mixed", "dust"];
5
+ function NumberInput({ label, value, onChange, min = 0, step = 0.1 }) {
6
+ return _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { children: label }), _jsx("input", { "aria-label": label, className: "w-24 rounded border border-neutral-700 bg-neutral-900 p-1", type: "number", min: min, step: step, value: value, onChange: (event) => onChange(Number(event.target.value)) })] });
7
+ }
8
+ /** Production authoring controls share the same validated command and undo history as RPC.
9
+ * @capability simulation-inspector author weather schedules, effects, fire, wind zones, forces and habitats with validated undoable controls
10
+ */
11
+ export function SimulationInspector({ session, creatorPolicy }) {
12
+ const simulation = useStoreSelector(session, (state) => state.document.simulation);
13
+ const [error, setError] = useState(null);
14
+ const [advanced, setAdvanced] = useState(null);
15
+ const [species, setSpecies] = useState("");
16
+ const weather = simulation?.weather ?? {};
17
+ const allowWeather = creatorPolicy === undefined || (creatorPolicy.maxSimulationParticles ?? 0) >= 5556;
18
+ const allowEmitters = creatorPolicy === undefined || (creatorPolicy.maxSimulationParticles ?? 0) > 0;
19
+ const allowFire = creatorPolicy === undefined || (creatorPolicy.maxFireCells ?? 0) > 0;
20
+ const allowFlocks = creatorPolicy === undefined || (creatorPolicy.maxPopulation ?? 0) > 0;
21
+ const allowForces = creatorPolicy === undefined || (creatorPolicy.maxForceFields ?? 0) > 0;
22
+ function update(next) {
23
+ try {
24
+ session.dispatch({ type: "setSimulation", simulation: next });
25
+ setError(null);
26
+ }
27
+ catch (reason) {
28
+ setError(reason instanceof Error ? reason.message : String(reason));
29
+ }
30
+ }
31
+ function setWeather(next) { update({ ...simulation, weather: next }); }
32
+ function id(prefix, rows) {
33
+ let index = 1;
34
+ while (rows.some((row) => row.id === `${prefix}-${index}`))
35
+ index += 1;
36
+ return `${prefix}-${index}`;
37
+ }
38
+ return _jsxs("section", { "aria-label": "Simulation authoring", className: "space-y-4 p-3 text-xs text-neutral-300", children: [_jsx("h3", { className: "text-sm font-semibold text-neutral-100", children: "Weather & simulation" }), _jsx("p", { children: "Metres \u00B7 game seconds \u00B7 wind on world X/Z. Save stores authored inputs; play state saves separately." }), error !== null ? _jsx("p", { role: "alert", className: "text-red-300", children: error }) : null, _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Ambient weather" }), _jsxs("label", { className: "flex justify-between", children: ["Mode", _jsx("select", { "aria-label": "Weather mode", value: weather.ambient?.mode ?? "clear", onChange: (event) => setWeather({ ...weather, ambient: { ...weather.ambient, mode: event.target.value, intensity: weather.ambient?.intensity ?? 1 } }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) })] }), _jsx(NumberInput, { label: "Weather intensity", value: weather.ambient?.intensity ?? 0, onChange: (intensity) => setWeather({ ...weather, ambient: { mode: "clear", ...weather.ambient, intensity } }) }), _jsx(NumberInput, { label: "Temperature offset \u00B0C", min: -100, value: weather.ambient?.temperatureOffset ?? 0, onChange: (temperatureOffset) => setWeather({ ...weather, ambient: { mode: "clear", intensity: 0, ...weather.ambient, temperatureOffset } }) }), _jsx(NumberInput, { label: "Wind speed m/s", value: weather.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, wind: { ...weather.wind, speed } }) }), _jsx(NumberInput, { label: "Wind X direction", min: -1, value: weather.wind?.direction?.[0] ?? 1, onChange: (x) => setWeather({ ...weather, wind: { ...weather.wind, direction: [x, weather.wind?.direction?.[1] ?? 0] } }) }), _jsx(NumberInput, { label: "Wind Z direction", min: -1, value: weather.wind?.direction?.[1] ?? 0, onChange: (z) => setWeather({ ...weather, wind: { ...weather.wind, direction: [weather.wind?.direction?.[0] ?? 1, z] } }) })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Named profiles" }), (weather.profiles ?? []).map((profile, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("label", { children: ["Profile id", _jsx("input", { "aria-label": `Profile ${index + 1} id`, value: profile.id, onChange: (event) => {
39
+ const nextId = event.target.value;
40
+ setWeather({ ...weather, initialProfileId: weather.initialProfileId === profile.id ? nextId : weather.initialProfileId, profiles: weather.profiles.map((row, i) => i === index ? { ...row, id: nextId } : row), schedule: weather.schedule?.map((row) => row.profileId === profile.id ? { ...row, profileId: nextId } : row) });
41
+ } })] }), _jsx("select", { "aria-label": `Profile ${profile.id} mode`, value: profile.mode, onChange: (event) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, mode: event.target.value } : row) }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) }), _jsx(NumberInput, { label: `Profile ${profile.id} intensity`, value: profile.intensity, onChange: (intensity) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, intensity } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: weather.profiles.filter((_, i) => i !== index), initialProfileId: weather.initialProfileId === profile.id ? undefined : weather.initialProfileId, schedule: weather.schedule?.filter((row) => row.profileId !== profile.id) }), children: "Remove profile" })] }, profile.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: [...(weather.profiles ?? []), { id: id("profile", weather.profiles ?? []), mode: "rain", intensity: 0.5 }] }), children: "Add weather profile" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Schedule" }), (weather.schedule ?? []).map((transition, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("select", { "aria-label": `Transition ${index + 1} profile`, value: transition.profileId, onChange: (event) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, profileId: event.target.value } : row) }), children: weather.profiles?.map((profile) => _jsx("option", { children: profile.id }, profile.id)) }), _jsx(NumberInput, { label: `Transition ${index + 1} at seconds`, value: transition.atSeconds, onChange: (atSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, atSeconds } : row) }) }), _jsx(NumberInput, { label: `Transition ${index + 1} duration seconds`, value: transition.transitionSeconds ?? 0, onChange: (transitionSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, transitionSeconds } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, schedule: weather.schedule.filter((_, i) => i !== index) }), children: "Remove transition" })] }, index)), _jsx("button", { type: "button", disabled: !weather.profiles?.length, onClick: () => setWeather({ ...weather, schedule: [...(weather.schedule ?? []), { atSeconds: (weather.schedule?.at(-1)?.atSeconds ?? 0) + 10, profileId: weather.profiles[0].id, transitionSeconds: 2 }] }), children: "Add transition" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Local wind zones" }), (weather.zones ?? []).map((zone, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: zone.id }), _jsx(NumberInput, { label: `${zone.id} X metres`, min: -1e6, value: zone.center[0], onChange: (x) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [x, row.center[1]] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} Z metres`, min: -1e6, value: zone.center[1], onChange: (z) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [row.center[0], z] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} radius metres`, value: zone.radius, onChange: (radius) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radius } : row) }) }), _jsx(NumberInput, { label: `${zone.id} speed m/s`, value: zone.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, wind: { ...row.wind, speed } } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: zone.radial ?? false, onChange: (event) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radial: event.target.checked } : row) }) }), " Radial wind"] }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: weather.zones.filter((_, i) => i !== index) }), children: "Remove wind zone" })] }, zone.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: [...(weather.zones ?? []), { id: id("wind", weather.zones ?? []), center: [0, 0], radius: 10, falloff: 5, wind: { direction: [1, 0], speed: 3 } }] }), children: "Add wind zone" })] }), _jsxs("fieldset", { hidden: !allowForces, className: "space-y-2", children: [_jsx("legend", { children: "Spatial forces" }), (simulation?.forces ?? []).map((field, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [[0, 1, 2].map((axis) => _jsx(NumberInput, { label: `Force ${index + 1} ${["X", "Y", "Z"][axis]} metres`, min: -1e6, value: field.center[axis], onChange: (value) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, center: row.center.map((entry, j) => j === axis ? value : entry) } : row) }) }, axis)), field.shape.kind === "sphere" ? _jsx(NumberInput, { label: `Force ${index + 1} radius metres`, value: field.shape.radius, onChange: (radius) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, shape: { kind: "sphere", radius } } : row) }) }) : _jsx("span", { children: "Box bounds are available in advanced editing." }), _jsx(NumberInput, { label: `Force ${index + 1} attraction m/s²`, min: -1e6, value: field.strength, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, strength } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} vortex m/s²`, min: -1e6, value: field.vortex?.strength ?? 0, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], ...row.vortex, strength } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} lift m/s²`, min: -1e6, value: field.vortex?.lift ?? 0, onChange: (lift) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], strength: 0, ...row.vortex, lift } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} falloff`, value: field.attenuation ?? 1, onChange: (attenuation) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, attenuation } : row) }) }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: simulation.forces.filter((_, i) => i !== index) }), children: "Remove force" })] }, index)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: [...(simulation?.forces ?? []), { center: [0, 0, 0], shape: { kind: "sphere", radius: 10 }, strength: 0, attenuation: 1, vortex: { axis: [0, 1, 0], strength: 2, lift: 1 }, mask: 1 }] }), children: "Add spatial force" })] }), _jsxs("fieldset", { hidden: !allowEmitters, className: "space-y-2", children: [_jsx("legend", { children: "Named effects" }), (simulation?.emitters ?? []).map((emitter, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: emitter.id }), _jsx("select", { "aria-label": `${emitter.id} appearance`, value: emitter.options?.render?.shape ?? "point", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, render: { shape: event.target.value } } } : row) }), children: ["point", "streak", "flake", "flame", "smoke", "ribbon", "ripple"].map((shape) => _jsx("option", { children: shape }, shape)) }), _jsx(NumberInput, { label: `${emitter.id} particles/second`, value: emitter.config.rate ?? 0, onChange: (rate) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, rate } } : row) }) }), _jsx(NumberInput, { label: `${emitter.id} particle capacity`, step: 1, value: emitter.config.max ?? 512, onChange: (max) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, max } } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${emitter.id} ${axis.toUpperCase()} metres`, min: -1e6, value: emitter.position[axis], onChange: (value) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: emitter.options?.active !== false, onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, active: event.target.checked } } : row) }) }), " Spawning"] }), _jsxs("label", { children: ["Fire binding", _jsxs("select", { "aria-label": `${emitter.id} fire area`, value: emitter.fireAreaId ?? "", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, fireAreaId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Independent" }), simulation?.fires?.map((area) => _jsx("option", { children: area.id }, area.id))] })] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: simulation.emitters.filter((_, i) => i !== index) }), children: "Remove emitter" })] }, emitter.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: [...(simulation?.emitters ?? []), { id: id("emitter", simulation?.emitters ?? []), position: { x: 0, y: 1, z: 0 }, config: { rate: 8, max: 64, lifetime: { min: 1, max: 2 }, seed: "authored" }, options: { render: { shape: "smoke" } } }] }), children: "Add named emitter" })] }), _jsxs("fieldset", { hidden: !allowFire, className: "space-y-2", children: [_jsx("legend", { children: "Fire areas" }), (simulation?.fires ?? []).map((area, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: area.id }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${area.id} ${axis.toUpperCase()} metres`, min: -1e6, value: area.position[axis], onChange: (value) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsx(NumberInput, { label: `${area.id} cell size metres`, value: area.config.cellSize, onChange: (cellSize) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, cellSize } } : row) }) }), _jsx(NumberInput, { label: `${area.id} fuel consumed/second`, value: area.config.burnRate ?? 0.25, onChange: (burnRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, burnRate } } : row) }) }), _jsx(NumberInput, { label: `${area.id} rain cooling heat/second`, value: area.extinguishRate ?? 0, onChange: (extinguishRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, extinguishRate } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: (area.ignitions?.length ?? 0) > 0, onChange: (event) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, ignitions: event.target.checked ? [{ col: 0, row: 0 }] : [] } : row) }) }), " Ignite first cell on play"] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: simulation.fires.filter((_, i) => i !== index), emitters: simulation?.emitters?.map((emitter) => emitter.fireAreaId === area.id ? { ...emitter, fireAreaId: undefined } : emitter) }), children: "Remove fire area" })] }, area.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: [...(simulation?.fires ?? []), { id: id("fire", simulation?.fires ?? []), position: { x: 0, y: 0, z: 0 }, config: { cols: 4, rows: 4, cellSize: 1 }, ignitions: [] }] }), children: "Add fire area" })] }), _jsxs("fieldset", { hidden: !allowFlocks, className: "space-y-2", children: [_jsx("legend", { children: "Flock habitats" }), (simulation?.habitats ?? []).map((habitat, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("span", { children: [habitat.id, " \u00B7 ", habitat.species] }), _jsxs("select", { "aria-label": `${habitat.id} role`, value: habitat.role, onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, role: event.target.value } : row) }), children: [_jsx("option", { children: "cosmetic" }), _jsx("option", { children: "gameplay" })] }), _jsx(NumberInput, { label: `${habitat.id} bird count`, step: 1, value: habitat.count, onChange: (count) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, count } : row) }) }), _jsx(NumberInput, { label: `${habitat.id} radius metres`, value: habitat.radius, onChange: (radius) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, radius } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${habitat.id} ${axis.toUpperCase()} metres`, min: -1e6, value: habitat.position[axis], onChange: (value) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("select", { "aria-label": `${habitat.id} route`, value: habitat.routeId ?? "", onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, routeId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Stay in habitat" }), session.getState().document.paths.filter((path) => path.points.length > 1).map((path) => _jsx("option", { value: path.id, children: path.label ?? path.id }, path.id))] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, habitats: simulation.habitats.filter((_, i) => i !== index) }), children: "Remove habitat" })] }, habitat.id)), _jsxs("label", { children: ["Game species", _jsx("input", { "aria-label": "Game species", value: species, onChange: (event) => setSpecies(event.target.value) })] }), _jsx("button", { type: "button", disabled: !species.trim(), onClick: () => update({ ...simulation, habitats: [...(simulation?.habitats ?? []), { id: id("habitat", simulation?.habitats ?? []), species: species.trim(), position: { x: 0, y: 4, z: 0 }, radius: 8, count: 8, seed: "authored", role: "cosmetic", steering: { maxSpeed: 4, separationRadius: 1, neighborRadius: 5 } }] }), children: "Add flock habitat" })] }), _jsx("button", { type: "button", onClick: () => setAdvanced(JSON.stringify(simulation ?? {}, null, 2)), children: "Edit complete simulation data" }), advanced !== null ? _jsxs("div", { className: "space-y-2", children: [_jsx("textarea", { "aria-label": "Simulation JSON", className: "h-60 w-full bg-neutral-900 p-2 font-mono", value: advanced, onChange: (event) => setAdvanced(event.target.value) }), _jsx("button", { type: "button", onClick: () => { try {
42
+ update(JSON.parse(advanced));
43
+ }
44
+ catch (reason) {
45
+ setError(String(reason));
46
+ } }, children: "Apply simulation data" }), _jsx("button", { type: "button", onClick: () => setAdvanced(null), children: "Close data editor" })] }) : null] });
47
+ }
@@ -172,6 +172,7 @@ function unionRect(a, b) {
172
172
  }
173
173
  /** Memoized so cursor-hover state updates in the parent never reconcile the mesh subtree. */
174
174
  const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revision, pendingRef, paletteAt, heightRange, contourStrength, }) {
175
+ const invalidate = useThree((state) => state.invalidate);
175
176
  const geometry = useMemo(() => {
176
177
  const width = Math.max(1, bounds.maxX - bounds.minX);
177
178
  const depth = Math.max(1, bounds.maxZ - bounds.minZ);
@@ -188,8 +189,9 @@ const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revisio
188
189
  pendingRef.current = null;
189
190
  const region = pending !== null && pending.kind === "region" ? pending.region : null;
190
191
  displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength);
192
+ invalidate();
191
193
  // eslint-disable-next-line react-hooks/exhaustive-deps
192
- }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
194
+ }, [geometry, terrain, revision, paletteAt, heightRange, contourStrength, invalidate]);
193
195
  return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
194
196
  });
195
197
  function selectSculptUi(state) {
@@ -10,6 +10,8 @@ const READ_ONLY_METHODS = new Set([
10
10
  "camera_goto",
11
11
  "camera_frame",
12
12
  "scene_summary",
13
+ "get_simulation",
14
+ "set_simulation",
13
15
  "export_document",
14
16
  "list_assets",
15
17
  "perf_report",
@@ -0,0 +1,21 @@
1
+ import type { EditorVec3 } from "@jgengine/core/editor/index";
2
+ import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ import { type TerraformSnapshot } from "@jgengine/core/world/terraform";
4
+ import type { GizmoMode, SnapMode } from "./uiStore.js";
5
+ /** Compose authored sculpt offsets over the same base field as the terrain preview. @internal */
6
+ export declare function placementGround(terrain: TerraformSnapshot | undefined, base: TerrainField | null): TerrainField | null;
7
+ /** Snap XZ and resample the shared ground field; reject a nonfinite surface height. @internal */
8
+ export declare function snapPlacement(point: EditorVec3, mode: SnapMode, gridSize: number, groundHeightAt: (x: number, z: number) => number): EditorVec3 | null;
9
+ /** Distance from a pointer to a projected path segment, including either endpoint. @internal */
10
+ export declare function pathSegmentDistance(pointer: {
11
+ x: number;
12
+ y: number;
13
+ }, a: {
14
+ x: number;
15
+ y: number;
16
+ }, b: {
17
+ x: number;
18
+ y: number;
19
+ }): number;
20
+ /** Group transforms use the translation command for every selected object. @internal */
21
+ export declare function selectionGizmoMode(mode: GizmoMode, kind: string, count: number): GizmoMode;
@@ -0,0 +1,34 @@
1
+ import { editableTerrainFromSnapshot } from "@jgengine/core/world/terraform";
2
+ /** Compose authored sculpt offsets over the same base field as the terrain preview. @internal */
3
+ export function placementGround(terrain, base) {
4
+ return terrain === undefined ? base : editableTerrainFromSnapshot(terrain, base ?? undefined);
5
+ }
6
+ /** Snap XZ and resample the shared ground field; reject a nonfinite surface height. @internal */
7
+ export function snapPlacement(point, mode, gridSize, groundHeightAt) {
8
+ if (mode !== "grid" || !Number.isFinite(gridSize) || gridSize <= 0)
9
+ return point;
10
+ const x = Math.round(point.x / gridSize) * gridSize;
11
+ const z = Math.round(point.z / gridSize) * gridSize;
12
+ if (!Number.isFinite(x) || !Number.isFinite(z))
13
+ return null;
14
+ const y = groundHeightAt(x, z);
15
+ return Number.isFinite(y) ? { x, y, z } : null;
16
+ }
17
+ /** Distance from a pointer to a projected path segment, including either endpoint. @internal */
18
+ export function pathSegmentDistance(pointer, a, b) {
19
+ const dx = b.x - a.x;
20
+ const dy = b.y - a.y;
21
+ const lengthSquared = dx * dx + dy * dy;
22
+ const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((pointer.x - a.x) * dx + (pointer.y - a.y) * dy) / lengthSquared));
23
+ return Math.hypot(pointer.x - a.x - t * dx, pointer.y - a.y - t * dy);
24
+ }
25
+ /** Group transforms use the translation command for every selected object. @internal */
26
+ export function selectionGizmoMode(mode, kind, count) {
27
+ if (count > 1)
28
+ return "translate";
29
+ if (kind === "marker")
30
+ return mode === "scale" ? "translate" : mode;
31
+ if (kind === "volume")
32
+ return mode === "rotate" ? "translate" : mode;
33
+ return "translate";
34
+ }
@@ -16,6 +16,7 @@ export interface HandlerContext {
16
16
  api: EditorHostApi;
17
17
  session: EditorSession;
18
18
  liveSync: DocumentLiveSync;
19
+ persistAssetUrls?: boolean;
19
20
  gameId: string;
20
21
  catalogDefinitions: readonly EditorCatalogDefinition[];
21
22
  catalogById: ReadonlyMap<string, EditorCatalogDefinition>;
@@ -1,3 +1,3 @@
1
1
  import type { HandlerTable } from "./context.js";
2
2
  /** Document, selection, camera, mode, asset placement, and status verbs. */
3
- export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "add_path" | "add_marker" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "assign_material" | "set_object_flags">;
3
+ export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "add_path" | "add_marker" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "get_simulation" | "set_simulation" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "list_material_assets" | "list_material_slots" | "upsert_material_asset" | "remove_material_asset" | "assign_material_asset" | "clear_material_assets" | "assign_material" | "set_object_flags">;
@@ -1,3 +1,4 @@
1
+ import { parseEditorMaterialAsset } from "@jgengine/core/editor/materialAuthoring";
1
2
  import { editorDocumentBounds, listEditorKinds, summarizeEditorSession, } from "@jgengine/core/editor/index";
2
3
  import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
3
4
  import { findSchemaPreset, getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
@@ -182,6 +183,7 @@ export const documentHandlers = {
182
183
  ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
183
184
  ...(request.color === undefined ? {} : { color: request.color }),
184
185
  ...(request.label === undefined ? {} : { label: request.label }),
186
+ ...(request.catalogId === undefined ? {} : { catalogId: request.catalogId }),
185
187
  ...(request.meta === undefined ? {} : { meta: request.meta }),
186
188
  };
187
189
  // addMarker re-ids on a document-global collision, so read the created id back from the selection.
@@ -207,6 +209,7 @@ export const documentHandlers = {
207
209
  ...(request.color === undefined ? {} : { color: request.color }),
208
210
  ...(request.label === undefined ? {} : { label: request.label }),
209
211
  ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
212
+ ...(request.catalogId === undefined ? {} : { catalogId: request.catalogId }),
210
213
  ...(merged === undefined ? {} : { meta: merged }),
211
214
  },
212
215
  });
@@ -312,6 +315,11 @@ export const documentHandlers = {
312
315
  ctx.api.setFocusTarget(target);
313
316
  return { ok: true, result: { target, bounds } };
314
317
  },
318
+ get_simulation: (ctx) => ({ ok: true, result: { simulation: structuredClone(ctx.session.getState().document.simulation ?? {}) } }),
319
+ set_simulation: (ctx, request) => {
320
+ ctx.session.dispatch({ type: "setSimulation", simulation: request.simulation === null ? undefined : request.simulation });
321
+ return { ok: true, result: { simulation: structuredClone(ctx.session.getState().document.simulation ?? {}) } };
322
+ },
315
323
  scene_summary: (ctx) => ({
316
324
  ok: true,
317
325
  result: {
@@ -359,7 +367,7 @@ export const documentHandlers = {
359
367
  kind: request.kind,
360
368
  knownKind: asset?.kind,
361
369
  knownLabel: asset?.label,
362
- knownUrl: asset?.url,
370
+ knownUrl: ctx.persistAssetUrls === false ? undefined : asset?.url,
363
371
  });
364
372
  const placedMarker = toEditorMarker(placed);
365
373
  // URL-backed catalog models need a first-class catalogId so AuthoredObjects places the mesh;
@@ -409,6 +417,28 @@ export const documentHandlers = {
409
417
  });
410
418
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
411
419
  },
420
+ list_material_assets: (ctx) => ({ ok: true, result: { assets: ctx.session.getState().document.materialAssets ?? [], assignments: ctx.session.getState().document.markers.filter((marker) => marker.meta?.materialAssignments !== undefined).map((marker) => ({ id: marker.id, assignments: marker.meta.materialAssignments })) } }),
421
+ list_material_slots: (ctx, request) => {
422
+ const inventory = ctx.api.getMaterialSlots(request.id);
423
+ return inventory.status === "ready" ? { ok: true, result: inventory } : { ok: false, result: inventory, error: inventory.status === "loading" ? "Material slots are still loading." : inventory.reason };
424
+ },
425
+ upsert_material_asset: (ctx, request) => {
426
+ const asset = parseEditorMaterialAsset(request.asset);
427
+ ctx.session.dispatch({ type: "upsertMaterialAsset", asset }, request.coalesce === undefined ? undefined : { coalesce: request.coalesce });
428
+ return { ok: true, result: { asset } };
429
+ },
430
+ remove_material_asset: (ctx, request) => {
431
+ const result = ctx.session.transaction([{ type: "removeMaterialAsset", id: request.id }]);
432
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
433
+ },
434
+ assign_material_asset: (ctx, request) => {
435
+ const result = ctx.session.transaction([{ type: "assignMaterialAsset", ids: request.ids, materialId: request.materialId, selector: request.selector }]);
436
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
437
+ },
438
+ clear_material_assets: (ctx, request) => {
439
+ const result = ctx.session.transaction([{ type: "clearMaterialAssets", ids: request.ids }]);
440
+ return result.ok ? { ok: true, result: summarizeEditorSession(result.state) } : { ok: false, error: result.error };
441
+ },
412
442
  assign_material: (ctx, request) => {
413
443
  if (!documentHasAnyId(ctx.session.getState().document, request.ids)) {
414
444
  return { ok: false, error: "assign_material matched no objects" };
@@ -1,3 +1,3 @@
1
1
  import type { HandlerTable } from "./context.js";
2
2
  /** Parent/child hierarchy and prefab library verbs. */
3
- export declare const hierarchyHandlers: Pick<HandlerTable, "set_parent" | "hierarchy" | "list_prefabs" | "create_prefab" | "insert_prefab" | "detach_prefab_instance" | "delete_prefab">;
3
+ export declare const hierarchyHandlers: Pick<HandlerTable, "set_parent" | "hierarchy" | "list_prefabs" | "create_prefab" | "insert_prefab" | "detach_prefab_instance" | "delete_prefab" | "set_prefab_static_bake">;
@@ -45,6 +45,10 @@ export const hierarchyHandlers = {
45
45
  ctx.session.dispatch({ type: "detachPrefabInstance", instanceId: request.instanceId });
46
46
  return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
47
47
  },
48
+ set_prefab_static_bake: (ctx, request) => {
49
+ const result = ctx.session.transaction([{ type: "setPrefabStaticBake", prefabId: request.prefabId, bake: request.bake }]);
50
+ return result.ok ? { ok: true, result: findEditorPrefab(ctx.session.getState().document, request.prefabId) } : { ok: false, error: result.error };
51
+ },
48
52
  delete_prefab: (ctx, request) => {
49
53
  if (findEditorPrefab(ctx.session.getState().document, request.prefabId) === undefined) {
50
54
  return { ok: false, error: `prefab not found: ${request.prefabId}` };