@jgengine/editor 0.10.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 (92) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/dist/AssetBrowser.d.ts +20 -0
  3. package/dist/AssetBrowser.js +49 -0
  4. package/dist/CatalogsPanel.d.ts +10 -0
  5. package/dist/CatalogsPanel.js +41 -0
  6. package/dist/CollectionsPanel.d.ts +10 -0
  7. package/dist/CollectionsPanel.js +35 -0
  8. package/dist/DebugDraw.d.ts +17 -0
  9. package/dist/DebugDraw.js +233 -0
  10. package/dist/EditorApp.d.ts +26 -0
  11. package/dist/EditorApp.js +333 -0
  12. package/dist/EditorCameraDriver.d.ts +5 -0
  13. package/dist/EditorCameraDriver.js +18 -0
  14. package/dist/EditorChrome.d.ts +22 -0
  15. package/dist/EditorChrome.js +559 -0
  16. package/dist/EditorContextMenu.d.ts +11 -0
  17. package/dist/EditorContextMenu.js +12 -0
  18. package/dist/InspectorPanel.d.ts +14 -0
  19. package/dist/InspectorPanel.js +110 -0
  20. package/dist/MaterialDropZone.d.ts +10 -0
  21. package/dist/MaterialDropZone.js +77 -0
  22. package/dist/OutlinerPanel.d.ts +14 -0
  23. package/dist/OutlinerPanel.js +84 -0
  24. package/dist/PerfProbe.d.ts +5 -0
  25. package/dist/PerfProbe.js +34 -0
  26. package/dist/PrefabsPanel.d.ts +13 -0
  27. package/dist/PrefabsPanel.js +42 -0
  28. package/dist/RuntimePlayBridge.d.ts +18 -0
  29. package/dist/RuntimePlayBridge.js +140 -0
  30. package/dist/ScatterPreview.d.ts +11 -0
  31. package/dist/ScatterPreview.js +30 -0
  32. package/dist/SchemaInspector.d.ts +19 -0
  33. package/dist/SchemaInspector.js +78 -0
  34. package/dist/SelectionGizmo.d.ts +25 -0
  35. package/dist/SelectionGizmo.js +455 -0
  36. package/dist/StandaloneEditor.d.ts +44 -0
  37. package/dist/StandaloneEditor.js +92 -0
  38. package/dist/TerrainPanel.d.ts +7 -0
  39. package/dist/TerrainPanel.js +59 -0
  40. package/dist/TerrainSculpt.d.ts +14 -0
  41. package/dist/TerrainSculpt.js +415 -0
  42. package/dist/TriggerInspector.d.ts +13 -0
  43. package/dist/TriggerInspector.js +60 -0
  44. package/dist/agent/AgentPanel.d.ts +13 -0
  45. package/dist/agent/AgentPanel.js +156 -0
  46. package/dist/agent/context.d.ts +27 -0
  47. package/dist/agent/context.js +39 -0
  48. package/dist/agent/endpoint.d.ts +68 -0
  49. package/dist/agent/endpoint.js +186 -0
  50. package/dist/agent/toolBridge.d.ts +35 -0
  51. package/dist/agent/toolBridge.js +93 -0
  52. package/dist/agent/turn.d.ts +71 -0
  53. package/dist/agent/turn.js +113 -0
  54. package/dist/chromeFields.d.ts +15 -0
  55. package/dist/chromeFields.js +13 -0
  56. package/dist/chromeStyles.d.ts +3 -0
  57. package/dist/chromeStyles.js +3 -0
  58. package/dist/index.d.ts +20 -0
  59. package/dist/index.js +19 -0
  60. package/dist/mcp/bridgeServer.d.ts +13 -0
  61. package/dist/mcp/bridgeServer.js +1 -0
  62. package/dist/mcp/loadGameCatalogs.d.ts +24 -0
  63. package/dist/mcp/loadGameCatalogs.js +87 -0
  64. package/dist/mcp/rpcRequest.d.ts +22 -0
  65. package/dist/mcp/rpcRequest.js +97 -0
  66. package/dist/mcp/tools.d.ts +8 -0
  67. package/dist/mcp/tools.js +764 -0
  68. package/dist/outlinerModel.d.ts +76 -0
  69. package/dist/outlinerModel.js +95 -0
  70. package/dist/perfMarks.d.ts +25 -0
  71. package/dist/perfMarks.js +36 -0
  72. package/dist/session.d.ts +400 -0
  73. package/dist/session.js +1032 -0
  74. package/dist/uiStore.d.ts +108 -0
  75. package/dist/uiStore.js +128 -0
  76. package/dist/useF2Chord.d.ts +2 -0
  77. package/dist/useF2Chord.js +32 -0
  78. package/dist/useStoreSelector.d.ts +31 -0
  79. package/dist/useStoreSelector.js +51 -0
  80. package/dist/viewportContextMenu.d.ts +24 -0
  81. package/dist/viewportContextMenu.js +32 -0
  82. package/package.json +58 -0
  83. package/skills/jgengine/SKILL.md +823 -0
  84. package/skills/jgengine/api.md +416 -0
  85. package/skills/jgengine/capabilities.md +21 -0
  86. package/skills/jgengine/reference-cartridge.md +72 -0
  87. package/skills/jgengine/reference-orphans.md +9 -0
  88. package/skills/jgengine/reference-systems.md +60 -0
  89. package/skills/jgengine-editor/SKILL.md +382 -0
  90. package/skills/jgengine-editor/api.md +416 -0
  91. package/skills/jgengine-editor/capabilities.md +29 -0
  92. package/skills/jgengine-editor/reference.md +64 -0
@@ -0,0 +1,12 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @internal Headless chrome-skinned viewport context menu (#866). Parent owns placement and action dispatch.
4
+ */
5
+ export function EditorContextMenu({ x, y, actions, onPick, onClose, }) {
6
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", className: "pointer-events-auto fixed z-[61] min-w-44 overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 py-1 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Editor" }), actions.map((action, index) => (_jsxs("div", { children: [action.separatorBefore === true ? _jsx("div", { className: "my-1 border-t border-white/10" }) : null, _jsx("button", { type: "button", role: "menuitem", disabled: action.disabled === true, onPointerDown: (event) => {
7
+ event.stopPropagation();
8
+ if (action.disabled === true)
9
+ return;
10
+ onPick(action);
11
+ }, className: "flex w-full items-center px-3 py-1.5 text-left text-[13px] text-white/85 hover:bg-cyan-400/20 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent", children: action.label })] }, `${action.id}-${index}`)))] })] }));
12
+ }
@@ -0,0 +1,14 @@
1
+ import { type EditorSession } from "@jgengine/core/editor/index";
2
+ import type { EditorUiStore } from "./uiStore.js";
3
+ /**
4
+ * The right-hand inspector as an isolated, selector-subscribed panel. It reads only the
5
+ * document + selection slices (via `useStoreSelector`) and the ui store's `pathPoint` slice, so
6
+ * UI-only churn (gizmo mode, snapping, active tool) or unrelated document edits outside the
7
+ * selected object's slice no longer rerender it on `EditorChrome`'s own render tick.
8
+ * @internal — mounted by `EditorChrome` as a right-aside panel.
9
+ */
10
+ export declare function InspectorPanel({ session, ui, onClose }: {
11
+ session: EditorSession;
12
+ ui: EditorUiStore;
13
+ onClose: () => void;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,110 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { collectDescendants, editorParentOf, findEditorNote, findEditorPath, } from "@jgengine/core/editor/index";
3
+ import { readVegetationSettings, vegetationFootprint } from "@jgengine/core/world/vegetation";
4
+ import { getSceneKind, isSceneKind, parseParams, } from "@jgengine/core/scene/sceneKinds";
5
+ import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
6
+ import { useGameContext } from "@jgengine/react/provider";
7
+ import { SchemaInspector } from "./SchemaInspector.js";
8
+ import { TriggerInspector } from "./TriggerInspector.js";
9
+ import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
10
+ import { INPUT, MICRO } from "./chromeStyles.js";
11
+ import { NumberField } from "./chromeFields.js";
12
+ function VegetationFields({ volume, onMeta, }) {
13
+ const settings = readVegetationSettings(volume);
14
+ if (settings === null)
15
+ return null;
16
+ const footprint = vegetationFootprint(volume);
17
+ const areaM2 = (footprint.maxX - footprint.minX) * (footprint.maxZ - footprint.minZ);
18
+ const sliderMax = settings.item === "grass" ? 12 : 1;
19
+ const estimated = Math.floor(areaM2 * settings.density);
20
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border border-emerald-400/15 bg-emerald-500/[0.06] p-2.5", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-300", children: "Vegetation" }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "item" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.item, placeholder: "grass", onChange: (event) => onMeta({ item: event.target.value }, "veg:item") })] }), _jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: settings.density.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(settings.density, sliderMax), onChange: (event) => onMeta({ density: Number(event.target.value) }, "veg:density") })] }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "seed" }), _jsx("input", { className: `w-32 ${INPUT}`, value: settings.seed, placeholder: "reroll\u2026", onChange: (event) => onMeta({ seed: event.target.value }, "veg:seed") })] }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
21
+ }
22
+ /** Builds the resolver-facing view of a document object for a registered scene kind's inspector. */
23
+ function markerObject(marker) {
24
+ return { id: marker.id, kind: marker.kind, position: marker.position, ...(marker.rotationY === undefined ? {} : { rotationY: marker.rotationY }), ...(marker.meta === undefined ? {} : { meta: marker.meta }) };
25
+ }
26
+ function volumeObject(volume) {
27
+ return {
28
+ id: volume.id,
29
+ kind: volume.kind,
30
+ center: volume.center,
31
+ ...(volume.halfExtents === undefined ? {} : { halfExtents: volume.halfExtents }),
32
+ ...(volume.radius === undefined ? {} : { radius: volume.radius }),
33
+ ...(volume.meta === undefined ? {} : { meta: volume.meta }),
34
+ };
35
+ }
36
+ function pathObject(path) {
37
+ return { id: path.id, kind: path.kind, points: path.points.map((point) => ({ x: point.x, y: point.y, z: point.z })), ...(path.meta === undefined ? {} : { meta: path.meta }) };
38
+ }
39
+ /** Auto-generated inspector for a registered scene kind's params (schema-driven, no per-kind JSX). */
40
+ function KindInspector({ object, meta, onMeta }) {
41
+ const definition = getSceneKind(object.kind);
42
+ if (definition === undefined)
43
+ return null;
44
+ const note = definition.note?.(object, parseParams(definition.schema, object.meta));
45
+ return (_jsx(SchemaInspector, { schema: definition.schema, label: definition.label, meta: meta, onMeta: onMeta, ...(definition.accent === undefined ? {} : { accent: definition.accent }), ...(note === undefined ? {} : { note }) }));
46
+ }
47
+ /** Auto-generated inspector for a placed generator asset's params (building/bookcase/…). */
48
+ function GeneratorInspector({ meta, onMeta }) {
49
+ const assetId = typeof meta?.["assetId"] === "string" ? meta["assetId"] : undefined;
50
+ const generator = assetId === undefined ? undefined : getAssetGenerator(assetId);
51
+ if (generator === undefined)
52
+ return null;
53
+ return _jsx(SchemaInspector, { schema: generator.schema, label: `${generator.label} (generator)`, accent: "#a78bfa", meta: meta, onMeta: onMeta });
54
+ }
55
+ /**
56
+ * Tags an object as a gameplay spot with a clearance radius: scatter keeps foliage off it and the
57
+ * runtime ground flattens under it (via `clearanceZonesFrom` → `environment({ clearings })`). 0 = untagged.
58
+ */
59
+ function ClearanceField({ meta, onMeta, }) {
60
+ const value = typeof meta?.["clearance"] === "number" ? meta["clearance"] : 0;
61
+ return (_jsx("div", { className: "space-y-1", title: "Radius (m) foliage stays clear of and terrain flattens under; 0 = no clearance", children: _jsx(NumberField, { label: "clearance m", step: 0.5, value: value, onCommit: (next) => onMeta({ clearance: Math.max(0, next) }, "clearance") }) }));
62
+ }
63
+ /** Inspector row to parent the selected object under another (excludes itself and its descendants). */
64
+ function ParentField({ session, id }) {
65
+ const document = session.getState().document;
66
+ const current = editorParentOf(document, id) ?? "";
67
+ const banned = collectDescendants(document, [id]);
68
+ banned.add(id);
69
+ const labelOf = (node) => node.label ?? node.id;
70
+ const candidates = [
71
+ ...document.markers.map((m) => ({ id: m.id, label: labelOf(m) })),
72
+ ...document.volumes.map((v) => ({ id: v.id, label: labelOf(v) })),
73
+ ...document.paths.map((p) => ({ id: p.id, label: labelOf(p) })),
74
+ ...document.annotations.map((n) => ({ id: n.id, label: n.text.slice(0, 30) || n.id })),
75
+ ].filter((entry) => !banned.has(entry.id));
76
+ return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "parent" }), _jsxs("select", { className: `w-40 ${INPUT}`, value: current, onChange: (event) => session.dispatch({ type: "setParent", ids: [id], parentId: event.target.value === "" ? null : event.target.value }), children: [_jsx("option", { value: "", children: "\u2014 none (root) \u2014" }), candidates.map((entry) => (_jsx("option", { value: entry.id, children: entry.label }, entry.id)))] })] }));
77
+ }
78
+ function KindColorFields({ kind, color, onKind, onColor, }) {
79
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "kind" }), _jsx("input", { className: `w-full min-w-0 ${INPUT}`, value: kind, onChange: (event) => {
80
+ const next = event.target.value.trim();
81
+ if (next.length > 0)
82
+ onKind(next);
83
+ } })] }), _jsx("input", { type: "color", className: "h-7 w-9 shrink-0 cursor-pointer rounded-md border border-white/10 bg-black/40", title: "Display color", value: color ?? "#ffffff", onChange: (event) => onColor(event.target.value) }), color !== undefined ? (_jsx("button", { type: "button", className: "shrink-0 rounded-md bg-white/[0.04] px-1.5 py-1 text-neutral-400 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", title: "Reset to kind default color", onClick: () => onColor(undefined), children: "\u21BA" })) : null] }));
84
+ }
85
+ /**
86
+ * The right-hand inspector as an isolated, selector-subscribed panel. It reads only the
87
+ * document + selection slices (via `useStoreSelector`) and the ui store's `pathPoint` slice, so
88
+ * UI-only churn (gizmo mode, snapping, active tool) or unrelated document edits outside the
89
+ * selected object's slice no longer rerender it on `EditorChrome`'s own render tick.
90
+ * @internal — mounted by `EditorChrome` as a right-aside panel.
91
+ */
92
+ export function InspectorPanel({ session, ui, onClose }) {
93
+ const document = useStoreSelector(session, (state) => state.document);
94
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
95
+ const pathPoint = useStoreSelector(ui, (state) => state.pathPoint);
96
+ const ctx = useGameContext();
97
+ const selectedId = selection[0];
98
+ const selectedMarker = document.markers.find((marker) => marker.id === selectedId);
99
+ const selectedVolume = document.volumes.find((volume) => volume.id === selectedId);
100
+ const selectedPath = selectedId === undefined ? undefined : findEditorPath(document, selectedId);
101
+ const selectedNote = selectedId === undefined ? undefined : findEditorNote(document, selectedId);
102
+ const documentMiss = selectedId !== undefined &&
103
+ selectedMarker === undefined &&
104
+ selectedVolume === undefined &&
105
+ selectedPath === undefined &&
106
+ selectedNote === undefined;
107
+ const liveEntity = documentMiss ? ctx.scene.entity.get(selectedId) : null;
108
+ const liveObject = documentMiss && liveEntity === null ? ctx.scene.object.get(selectedId) : null;
109
+ return (_jsx("aside", { className: "pointer-events-auto flex w-72 min-w-56 max-w-[42vw] resize-x flex-col overflow-auto border-l border-white/[0.08] bg-[#0d0f13]/95 p-3 backdrop-blur-md", style: { direction: "rtl" }, children: _jsxs("div", { className: "flex-1", style: { direction: "ltr" }, children: [_jsxs("div", { className: "flex items-center", children: [_jsx("div", { className: MICRO, children: "Inspector" }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: onClose, "aria-label": "Close inspector panel", children: "\u00D7" })] }), selection.length > 1 ? (_jsxs("div", { className: "mt-3 space-y-2", children: [_jsxs("div", { className: "text-cyan-200", children: [selection.length, " objects selected"] }), _jsx("div", { className: "max-h-32 space-y-0.5 overflow-auto text-neutral-500", children: selection.map((id) => _jsx("div", { className: "truncate", children: id }, id)) }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.07] px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15", onClick: () => session.dispatch({ type: "duplicate", ids: selection }), children: "Duplicate" }), _jsx("button", { type: "button", className: "rounded-md bg-rose-500/15 px-2 py-1 text-rose-200 ring-1 ring-inset ring-rose-400/25 transition-colors hover:bg-rose-500/25", onClick: () => session.dispatch({ type: "removeMany", ids: selection }), children: "Delete all" })] })] })) : null, selection.length <= 1 && selectedMarker !== undefined ? (_jsxs("div", { className: "mt-3 space-y-2", children: [_jsx("input", { className: `w-full ${INPUT} font-medium text-cyan-200`, value: selectedMarker.label ?? "", placeholder: selectedMarker.id, onChange: (event) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { label: event.target.value } }, { coalesce: `label:${selectedMarker.id}` }) }), _jsxs("div", { className: "text-neutral-500", children: [selectedMarker.kind, " \u00B7 ", selectedMarker.id] }), _jsx(KindColorFields, { kind: selectedMarker.kind, color: selectedMarker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { kind } }, { coalesce: `kind:${selectedMarker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { color } }, { coalesce: `color:${selectedMarker.id}` }) }), ["x", "y", "z"].map((axis) => (_jsx(NumberField, { label: axis, value: selectedMarker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: selectedMarker.id, position: { ...selectedMarker.position, [axis]: value } }, { coalesce: `pos:${axis}:${selectedMarker.id}` }) }, axis))), _jsx(NumberField, { label: "rot\u00B0", step: 5, value: Math.round(((selectedMarker.rotationY ?? 0) * 180) / Math.PI), onCommit: (value) => session.dispatch({ type: "setTransform", id: selectedMarker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${selectedMarker.id}` }) }), _jsx(ClearanceField, { meta: selectedMarker.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { meta: { ...selectedMarker.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedMarker.id}` }) }), _jsx(TriggerInspector, { target: "marker", meta: selectedMarker.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { meta: { ...selectedMarker.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedMarker.id}` }) }), _jsx(ParentField, { session: session, id: selectedMarker.id }), isSceneKind(selectedMarker.kind) ? (_jsx(KindInspector, { object: markerObject(selectedMarker), meta: selectedMarker.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { meta: { ...selectedMarker.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedMarker.id}` }) })) : null, _jsx(GeneratorInspector, { meta: selectedMarker.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setMarker", id: selectedMarker.id, patch: { meta: { ...selectedMarker.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedMarker.id}` }) }), selectedMarker.meta !== undefined ? _jsx("pre", { className: "max-h-48 overflow-auto rounded-md border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(selectedMarker.meta, null, 2) }) : null] })) : null, selection.length <= 1 && selectedVolume !== undefined ? (_jsxs("div", { className: "mt-3 space-y-2", children: [_jsx("input", { className: `w-full ${INPUT} font-medium text-cyan-200`, value: selectedVolume.label ?? "", placeholder: selectedVolume.id, onChange: (event) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { label: event.target.value } }, { coalesce: `label:${selectedVolume.id}` }) }), _jsxs("div", { className: "text-neutral-500", children: [selectedVolume.kind, " \u00B7 ", selectedVolume.shape] }), _jsx(KindColorFields, { kind: selectedVolume.kind, color: selectedVolume.color, onKind: (kind) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { kind } }, { coalesce: `kind:${selectedVolume.id}` }), onColor: (color) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { color } }, { coalesce: `color:${selectedVolume.id}` }) }), ["x", "y", "z"].map((axis) => (_jsx(NumberField, { label: axis, value: selectedVolume.center[axis], onCommit: (value) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { center: { ...selectedVolume.center, [axis]: value } } }, { coalesce: `center:${axis}:${selectedVolume.id}` }) }, axis))), selectedVolume.shape !== "box" ? (_jsx(NumberField, { label: "radius", value: selectedVolume.radius ?? 5, onCommit: (value) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { radius: Math.max(0.5, value) } }, { coalesce: `radius:${selectedVolume.id}` }) })) : null, selectedVolume.shape === "cylinder" ? (_jsx(NumberField, { label: "height", value: selectedVolume.height ?? 4, onCommit: (value) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { height: Math.max(0.5, value) } }, { coalesce: `height:${selectedVolume.id}` }) })) : null, selectedVolume.shape === "box" ? (["x", "y", "z"].map((axis) => (_jsx(NumberField, { label: `half ${axis}`, value: selectedVolume.halfExtents?.[axis] ?? 5, onCommit: (value) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { halfExtents: { x: selectedVolume.halfExtents?.x ?? 5, y: selectedVolume.halfExtents?.y ?? 5, z: selectedVolume.halfExtents?.z ?? 5, [axis]: Math.max(0.5, value) } } }, { coalesce: `he:${axis}:${selectedVolume.id}` }) }, axis)))) : null, _jsx(ClearanceField, { meta: selectedVolume.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { meta: { ...selectedVolume.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedVolume.id}` }) }), _jsx(TriggerInspector, { target: "volume", meta: selectedVolume.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { meta: { ...selectedVolume.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedVolume.id}` }) }), _jsx(ParentField, { session: session, id: selectedVolume.id }), _jsx(VegetationFields, { volume: selectedVolume, onMeta: (patch, coalesce) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { meta: { ...selectedVolume.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedVolume.id}` }) }), isSceneKind(selectedVolume.kind) ? (_jsx(KindInspector, { object: volumeObject(selectedVolume), meta: selectedVolume.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setVolume", id: selectedVolume.id, patch: { meta: { ...selectedVolume.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedVolume.id}` }) })) : null] })) : null, selection.length <= 1 && selectedPath !== undefined ? (_jsxs("div", { className: "mt-3 space-y-2", children: [_jsx("input", { className: `w-full ${INPUT} font-medium text-cyan-200`, value: selectedPath.label ?? "", placeholder: selectedPath.id, onChange: (event) => session.dispatch({ type: "setPath", id: selectedPath.id, patch: { label: event.target.value } }, { coalesce: `label:${selectedPath.id}` }) }), _jsxs("div", { className: "text-neutral-500", children: [selectedPath.kind, " \u00B7 ", selectedPath.points.length, " points"] }), _jsx(KindColorFields, { kind: selectedPath.kind, color: selectedPath.color, onKind: (kind) => session.dispatch({ type: "setPath", id: selectedPath.id, patch: { kind } }, { coalesce: `kind:${selectedPath.id}` }), onColor: (color) => session.dispatch({ type: "setPath", id: selectedPath.id, patch: { color } }, { coalesce: `color:${selectedPath.id}` }) }), _jsx(NumberField, { label: "width", value: selectedPath.width ?? 4, onCommit: (value) => session.dispatch({ type: "setPath", id: selectedPath.id, patch: { width: Math.max(0.5, value) } }, { coalesce: `width:${selectedPath.id}` }) }), pathPoint !== null && pathPoint.pathId === selectedPath.id ? (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-neutral-400", children: ["Point ", pathPoint.index + 1, "/", selectedPath.points.length] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.07] px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15", onClick: () => { const at = pathPoint.index; const points = [...selectedPath.points.slice(0, at + 1), { ...selectedPath.points[at] }, ...selectedPath.points.slice(at + 1)]; session.dispatch({ type: "setPath", id: selectedPath.id, patch: { points } }); }, children: "Insert point" }), _jsx("button", { type: "button", className: "rounded-md bg-rose-500/15 px-2 py-1 text-rose-200 ring-1 ring-inset ring-rose-400/25 transition-colors hover:bg-rose-500/25 disabled:opacity-40", disabled: selectedPath.points.length <= 2, onClick: () => { const points = selectedPath.points.filter((_, index) => index !== pathPoint.index); ui.patch({ pathPoint: null }); session.dispatch({ type: "setPath", id: selectedPath.id, patch: { points } }); }, children: "Delete point" })] })] })) : _jsx("div", { className: "text-[10px] text-neutral-500", children: "Click a vertex sphere to edit points." }), isSceneKind(selectedPath.kind) ? (_jsx(KindInspector, { object: pathObject(selectedPath), meta: selectedPath.meta, onMeta: (patch, coalesce) => session.dispatch({ type: "setPath", id: selectedPath.id, patch: { meta: { ...selectedPath.meta, ...patch } } }, { coalesce: `${coalesce}:${selectedPath.id}` }) })) : null, _jsx(ParentField, { session: session, id: selectedPath.id })] })) : null, selection.length <= 1 && selectedNote !== undefined ? (_jsxs("div", { className: "mt-3 space-y-2", children: [_jsxs("div", { className: "text-neutral-500", children: ["note \u00B7 ", selectedNote.id] }), _jsx("textarea", { className: `h-24 w-full ${INPUT} text-neutral-100`, value: selectedNote.text, onChange: (event) => session.dispatch({ type: "setNote", id: selectedNote.id, patch: { text: event.target.value } }, { coalesce: `text:${selectedNote.id}` }) }), ["x", "y", "z"].map((axis) => (_jsx(NumberField, { label: axis, value: selectedNote.position[axis], onCommit: (value) => session.dispatch({ type: "setNote", id: selectedNote.id, patch: { position: { ...selectedNote.position, [axis]: value } } }, { coalesce: `npos:${axis}:${selectedNote.id}` }) }, axis)))] })) : null, liveEntity !== null ? _jsxs("div", { className: "mt-3 space-y-1", children: [_jsx("div", { className: "text-cyan-200", children: liveEntity.name }), _jsxs("div", { className: "text-neutral-500", children: ["live entity \u00B7 ", liveEntity.role, " \u00B7 ", liveEntity.id] }), _jsxs("div", { className: "text-neutral-400", children: ["x ", liveEntity.position[0].toFixed(1), " \u00B7 y ", liveEntity.position[1].toFixed(1), " \u00B7 z ", liveEntity.position[2].toFixed(1)] }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Live world object \u2014 edit its source data to move it permanently." })] }) : null, liveObject !== null ? _jsxs("div", { className: "mt-3 space-y-1", children: [_jsx("div", { className: "text-cyan-200", children: liveObject.catalogId }), _jsxs("div", { className: "text-neutral-500", children: ["live object \u00B7 ", liveObject.instanceId] }), _jsxs("div", { className: "text-neutral-400", children: ["x ", liveObject.position[0].toFixed(1), " \u00B7 y ", liveObject.position[1].toFixed(1), " \u00B7 z ", liveObject.position[2].toFixed(1)] }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Live world object \u2014 edit its source data to move it permanently." })] }) : null, selection.length === 0 && liveEntity === null && liveObject === null ? _jsx("div", { className: "mt-3 text-neutral-500", children: "Select an authored or live world object, or + Add to place new ones." }) : null] }) }));
110
+ }
@@ -0,0 +1,10 @@
1
+ import type { EditorHostApi } from "./session.js";
2
+ /**
3
+ * Viewport half of drag-drop material assignment: drop a material chip (dragged from the asset
4
+ * browser's palette) onto a tagged object to assign it, or onto bare ground/terrain to paint that
5
+ * material at the drop point. No-op when the drag payload isn't a material id.
6
+ * @internal — mounted by `EditorApp`'s world overlay.
7
+ */
8
+ export declare const MaterialDropZone: import("react").MemoExoticComponent<({ api }: {
9
+ api: EditorHostApi;
10
+ }) => null>;
@@ -0,0 +1,77 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { memo, useEffect } from "react";
3
+ import * as THREE from "three";
4
+ import { MATERIAL_DRAG_MIME } from "./AssetBrowser.js";
5
+ const SELECT_TAGS = ["jgEditorId", "jgEntityId", "jgObjectId"];
6
+ const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
7
+ /**
8
+ * Viewport half of drag-drop material assignment: drop a material chip (dragged from the asset
9
+ * browser's palette) onto a tagged object to assign it, or onto bare ground/terrain to paint that
10
+ * material at the drop point. No-op when the drag payload isn't a material id.
11
+ * @internal — mounted by `EditorApp`'s world overlay.
12
+ */
13
+ export const MaterialDropZone = memo(function MaterialDropZone({ api }) {
14
+ const gl = useThree((state) => state.gl);
15
+ const camera = useThree((state) => state.camera);
16
+ const scene = useThree((state) => state.scene);
17
+ useEffect(() => {
18
+ const canvas = gl.domElement;
19
+ const raycaster = new THREE.Raycaster();
20
+ const ndc = new THREE.Vector2();
21
+ const planeHit = new THREE.Vector3();
22
+ const onDragOver = (event) => {
23
+ if (event.dataTransfer === null || !event.dataTransfer.types.includes(MATERIAL_DRAG_MIME))
24
+ return;
25
+ event.preventDefault();
26
+ event.dataTransfer.dropEffect = "copy";
27
+ };
28
+ const onDrop = (event) => {
29
+ const materialId = event.dataTransfer?.getData(MATERIAL_DRAG_MIME) ?? "";
30
+ if (materialId.length === 0)
31
+ return;
32
+ event.preventDefault();
33
+ const rect = canvas.getBoundingClientRect();
34
+ ndc.set(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
35
+ raycaster.setFromCamera(ndc, camera);
36
+ for (const hit of raycaster.intersectObjects(scene.children, true)) {
37
+ if (!hit.object.isMesh || hit.object.visible === false)
38
+ continue;
39
+ let node = hit.object;
40
+ let taggedId = null;
41
+ let gizmoHit = false;
42
+ while (node !== null && taggedId === null && !gizmoHit) {
43
+ if (node.type.startsWith("TransformControls"))
44
+ gizmoHit = true;
45
+ for (const tag of SELECT_TAGS) {
46
+ const id = node.userData[tag];
47
+ if (typeof id === "string") {
48
+ taggedId = id;
49
+ break;
50
+ }
51
+ }
52
+ node = node.parent;
53
+ }
54
+ if (gizmoHit)
55
+ continue;
56
+ if (taggedId !== null) {
57
+ api.handle({ method: "assign_material", ids: [taggedId], materialId });
58
+ return;
59
+ }
60
+ break;
61
+ }
62
+ const terrain = api.getSession().getState().document.terrain;
63
+ if (terrain === undefined)
64
+ return;
65
+ if (raycaster.ray.intersectPlane(GROUND_PLANE, planeHit) === null)
66
+ return;
67
+ api.handle({ method: "paint_terrain", surface: materialId, x: planeHit.x, z: planeHit.z });
68
+ };
69
+ canvas.addEventListener("dragover", onDragOver);
70
+ canvas.addEventListener("drop", onDrop);
71
+ return () => {
72
+ canvas.removeEventListener("dragover", onDragOver);
73
+ canvas.removeEventListener("drop", onDrop);
74
+ };
75
+ }, [gl, camera, scene, api]);
76
+ return null;
77
+ });
@@ -0,0 +1,14 @@
1
+ import type { EditorSession } from "@jgengine/core/editor/index";
2
+ import type { EditorHostApi } from "./session.js";
3
+ /**
4
+ * The scene outliner as an isolated, selector-subscribed, virtualized panel. It reads only the
5
+ * document + selection slices via `useStoreSelector`, so UI-only churn (gizmo mode, snapping, tool
6
+ * changes) never rerenders it, and it windows its row list with `virtualWindow`, so a scene with
7
+ * thousands of objects only ever mounts the visible handful of rows. Memoized against its stable
8
+ * `session`/`api` props.
9
+ * @internal — mounted by `EditorChrome`.
10
+ */
11
+ export declare const OutlinerPanel: import("react").MemoExoticComponent<({ session, api, }: {
12
+ session: EditorSession;
13
+ api: EditorHostApi;
14
+ }) => import("react").JSX.Element>;
@@ -0,0 +1,84 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
+ import { MATERIAL_DRAG_MIME } from "./AssetBrowser.js";
4
+ import { flattenOutliner } from "./outlinerModel.js";
5
+ import { shallowArrayEqual, useStoreSelector, virtualWindow } from "./useStoreSelector.js";
6
+ const ROW_HEIGHT = 26;
7
+ const INPUT = "rounded-md border border-white/10 bg-black/40 px-2 py-1 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60";
8
+ /**
9
+ * The scene outliner as an isolated, selector-subscribed, virtualized panel. It reads only the
10
+ * document + selection slices via `useStoreSelector`, so UI-only churn (gizmo mode, snapping, tool
11
+ * changes) never rerenders it, and it windows its row list with `virtualWindow`, so a scene with
12
+ * thousands of objects only ever mounts the visible handful of rows. Memoized against its stable
13
+ * `session`/`api` props.
14
+ * @internal — mounted by `EditorChrome`.
15
+ */
16
+ export const OutlinerPanel = memo(function OutlinerPanel({ session, api, }) {
17
+ const document = useStoreSelector(session, (state) => state.document);
18
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
19
+ const selectedId = selection[0];
20
+ const [view, setView] = useState("kind");
21
+ const [query, setQuery] = useState("");
22
+ const [collapsedKinds, setCollapsedKinds] = useState({});
23
+ const [collapsedNodes, setCollapsedNodes] = useState({});
24
+ const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
25
+ const scrollRef = useRef(null);
26
+ const [scrollTop, setScrollTop] = useState(0);
27
+ const [viewportHeight, setViewportHeight] = useState(320);
28
+ useEffect(() => {
29
+ const element = scrollRef.current;
30
+ if (element === null)
31
+ return;
32
+ const measure = () => setViewportHeight(element.clientHeight);
33
+ measure();
34
+ const observer = new ResizeObserver(measure);
35
+ observer.observe(element);
36
+ return () => observer.disconnect();
37
+ }, []);
38
+ const win = virtualWindow(scrollTop, viewportHeight, ROW_HEIGHT, rows.length);
39
+ const visible = rows.slice(win.start, win.end);
40
+ const selectRow = (id, additive) => {
41
+ if (additive) {
42
+ const current = session.getState().selection;
43
+ const next = current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id];
44
+ session.dispatch({ type: "select", ids: next });
45
+ return;
46
+ }
47
+ session.dispatch({ type: "select", ids: [id] });
48
+ api.handle({ method: "camera_goto", id });
49
+ };
50
+ const [materialDropTarget, setMaterialDropTarget] = useState(null);
51
+ const materialDropProps = (ids, key) => ({
52
+ onDragOver: (event) => {
53
+ if (!event.dataTransfer.types.includes(MATERIAL_DRAG_MIME))
54
+ return;
55
+ event.preventDefault();
56
+ event.dataTransfer.dropEffect = "copy";
57
+ if (materialDropTarget !== key)
58
+ setMaterialDropTarget(key);
59
+ },
60
+ onDragLeave: () => setMaterialDropTarget((current) => (current === key ? null : current)),
61
+ onDrop: (event) => {
62
+ const materialId = event.dataTransfer.getData(MATERIAL_DRAG_MIME);
63
+ if (materialId.length === 0)
64
+ return;
65
+ event.preventDefault();
66
+ setMaterialDropTarget(null);
67
+ api.handle({ method: "assign_material", ids: [...ids], materialId });
68
+ },
69
+ });
70
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.08] p-2", children: [_jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search objects and kinds\u2026", className: `w-full ${INPUT} px-2.5 py-1.5` }), _jsx("div", { className: "flex gap-0.5 rounded-md bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["kind", "tree"].map((mode) => (_jsx("button", { type: "button", className: `flex-1 rounded px-2 py-0.5 text-[10px] transition-colors ${view === mode ? "bg-cyan-500/80 text-white" : "text-neutral-400 hover:text-neutral-200"}`, onClick: () => setView(mode), children: mode === "kind" ? "By kind" : "Tree" }, mode))) })] }), _jsx("div", { ref: scrollRef, className: "min-h-0 flex-1 overflow-auto p-2", onScroll: (event) => setScrollTop(event.currentTarget.scrollTop), children: rows.length === 0 ? (_jsx("div", { className: "p-3 text-center text-neutral-600", children: query.length > 0 ? "No matching objects" : "No objects yet" })) : (_jsx("div", { style: { height: win.totalHeight, position: "relative" }, children: _jsx("div", { style: { transform: `translateY(${win.offsetTop}px)` }, children: visible.map((row) => {
71
+ if (row.type === "group") {
72
+ return (_jsxs("button", { type: "button", style: { height: ROW_HEIGHT }, className: "flex w-full items-center gap-1 rounded-md px-1.5 text-left font-semibold text-neutral-300 transition-colors hover:bg-white/[0.06]", onClick: () => setCollapsedKinds((previous) => ({ ...previous, [row.kind]: !(previous[row.kind] === true) })), children: [_jsx("span", { className: "w-3 text-neutral-500", children: row.collapsed ? "▸" : "▾" }), _jsx("span", { children: row.kind }), _jsx("span", { className: "ml-auto text-neutral-500", children: row.total })] }, row.key));
73
+ }
74
+ if (row.type === "kindItem") {
75
+ const rowSelected = selectedId !== undefined && row.ids.includes(selectedId);
76
+ const cycleIndex = rowSelected ? row.ids.indexOf(selectedId) + 1 : 0;
77
+ const dropActive = materialDropTarget === row.key;
78
+ return (_jsxs("button", { type: "button", style: { height: ROW_HEIGHT }, className: `block w-full truncate rounded-md pl-5 pr-1.5 text-left transition-colors ${dropActive ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50" : rowSelected ? "bg-cyan-500/15 text-cyan-100 ring-1 ring-inset ring-cyan-400/20" : "text-neutral-300 hover:bg-white/[0.06]"}`, onClick: (event) => selectRow(row.ids[0], event.ctrlKey || event.metaKey || event.shiftKey), ...materialDropProps(row.ids, row.key), children: [row.label, row.ids.length > 1 ? _jsxs("span", { className: "text-neutral-500", children: [" \u00D7", row.ids.length, rowSelected ? ` · ${cycleIndex}/${row.ids.length} · N next` : ""] }) : null] }, row.key));
79
+ }
80
+ const rowSelected = selection.includes(row.id);
81
+ const dropActive = materialDropTarget === row.key;
82
+ return (_jsxs("div", { style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: "flex items-center", children: [row.hasChildren ? (_jsx("button", { type: "button", className: "w-4 shrink-0 text-neutral-500 transition-colors hover:text-neutral-200", onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: collapsedNodes[row.id] === true ? "▸" : "▾" })) : _jsx("span", { className: "w-4 shrink-0" }), _jsxs("button", { type: "button", className: `flex-1 truncate rounded-md px-1.5 text-left transition-colors ${dropActive ? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50" : rowSelected ? "bg-cyan-500/15 text-cyan-100 ring-1 ring-inset ring-cyan-400/20" : "text-neutral-300 hover:bg-white/[0.06]"}`, onClick: (event) => selectRow(row.id, event.ctrlKey || event.metaKey || event.shiftKey), title: row.id, ...materialDropProps([row.id], row.key), children: [row.label, _jsx("span", { className: "ml-1 text-[9px] text-neutral-600", children: row.kind })] })] }, row.key));
83
+ }) }) })) })] }));
84
+ });
@@ -0,0 +1,5 @@
1
+ import type { EditorHostApi } from "./session.js";
2
+ /** In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. */
3
+ export declare const PerfProbe: import("react").MemoExoticComponent<({ api }: {
4
+ api: EditorHostApi;
5
+ }) => null>;
@@ -0,0 +1,34 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { memo, useRef } from "react";
3
+ import { editorPerfMarks } from "./perfMarks.js";
4
+ const SAMPLE_WINDOW_MS = 500;
5
+ /** In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. */
6
+ export const PerfProbe = memo(function PerfProbe({ api }) {
7
+ const gl = useThree((state) => state.gl);
8
+ const framesRef = useRef(0);
9
+ const windowStartRef = useRef(0);
10
+ useFrame(() => {
11
+ const now = performance.now();
12
+ if (windowStartRef.current === 0)
13
+ windowStartRef.current = now;
14
+ framesRef.current += 1;
15
+ const elapsed = now - windowStartRef.current;
16
+ if (elapsed < SAMPLE_WINDOW_MS)
17
+ return;
18
+ const fps = (framesRef.current * 1000) / elapsed;
19
+ const authoring = editorPerfMarks.flush();
20
+ api.setPerf({
21
+ fps: Math.round(fps * 10) / 10,
22
+ frameMs: Math.round((elapsed / framesRef.current) * 100) / 100,
23
+ drawCalls: gl.info.render.calls,
24
+ triangles: gl.info.render.triangles,
25
+ sampledAt: now,
26
+ raycastMs: authoring.raycastMs,
27
+ rebuildMs: authoring.rebuildMs,
28
+ authoringMs: authoring.authoringMs,
29
+ });
30
+ framesRef.current = 0;
31
+ windowStartRef.current = now;
32
+ });
33
+ return null;
34
+ });
@@ -0,0 +1,13 @@
1
+ import type { EditorSession } from "@jgengine/core/editor/index";
2
+ import type { EditorHostApi } from "./session.js";
3
+ /**
4
+ * Reusable object stamps: "make prefab" extracts the current selection into a named, serializable
5
+ * fragment centered on its own bounds; "insert" stamps a fresh, freshly-id'd instance at the
6
+ * camera focus point, tagged so `detach_prefab_instance` can later break the link. Prefabs travel
7
+ * with the document — export/import (or copy the `prefabs` array) to reuse across games.
8
+ * @internal — mounted by `EditorChrome` as a left-aside tab.
9
+ */
10
+ export declare function PrefabsPanel({ session, api }: {
11
+ session: EditorSession;
12
+ api: EditorHostApi;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
4
+ const INPUT = "rounded-md border border-white/10 bg-black/40 px-2 py-1 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60";
5
+ const BTN = "rounded-md bg-white/[0.04] px-2 py-1 text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 hover:text-neutral-100";
6
+ function newPrefabId() {
7
+ return `prefab_${Date.now().toString(36)}_${Math.floor(Math.random() * 1296).toString(36)}`;
8
+ }
9
+ /**
10
+ * Reusable object stamps: "make prefab" extracts the current selection into a named, serializable
11
+ * fragment centered on its own bounds; "insert" stamps a fresh, freshly-id'd instance at the
12
+ * camera focus point, tagged so `detach_prefab_instance` can later break the link. Prefabs travel
13
+ * with the document — export/import (or copy the `prefabs` array) to reuse across games.
14
+ * @internal — mounted by `EditorChrome` as a left-aside tab.
15
+ */
16
+ export function PrefabsPanel({ session, api }) {
17
+ const document = useStoreSelector(session, (state) => state.document);
18
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
19
+ const [name, setName] = useState("");
20
+ const prefabs = document.prefabs;
21
+ const makeFromSelection = () => {
22
+ const trimmed = name.trim();
23
+ if (trimmed.length === 0 || selection.length === 0)
24
+ return;
25
+ session.dispatch({ type: "createPrefab", id: newPrefabId(), name: trimmed, ids: selection });
26
+ setName("");
27
+ };
28
+ const insertAt = (prefab) => {
29
+ const focus = api.getFocusTarget() ?? { x: 0, y: 0, z: 0 };
30
+ session.dispatch({ type: "insertPrefab", prefabId: prefab.id, at: focus });
31
+ };
32
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-2 p-2", children: [_jsxs("div", { className: "flex gap-1", children: [_jsx("input", { className: `min-w-0 flex-1 ${INPUT}`, placeholder: "New prefab name\u2026", value: name, onChange: (event) => setName(event.target.value), onKeyDown: (event) => {
33
+ if (event.key === "Enter")
34
+ makeFromSelection();
35
+ } }), _jsx("button", { type: "button", className: BTN, disabled: selection.length === 0 || name.trim().length === 0, onClick: makeFromSelection, children: "Make prefab" })] }), selection.length === 0 ? (_jsx("div", { className: "text-[10px] text-neutral-500", children: "Select objects, name them, then make a prefab from the selection." })) : null, _jsx("div", { className: "min-h-0 flex-1 space-y-1 overflow-auto pr-1", children: prefabs.length === 0 ? (_jsx("div", { className: "text-neutral-600", children: "No prefabs yet." })) : (prefabs.map((prefab) => {
36
+ const count = prefab.fragment.markers.length +
37
+ prefab.fragment.volumes.length +
38
+ prefab.fragment.paths.length +
39
+ prefab.fragment.annotations.length;
40
+ return (_jsxs("div", { className: "flex items-center gap-2 rounded-md bg-white/[0.03] p-1.5 ring-1 ring-inset ring-white/[0.06]", children: [_jsxs("div", { className: "min-w-0 flex-1 truncate", children: [_jsx("div", { className: "truncate text-neutral-100", children: prefab.name }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: [count, " objects"] })] }), _jsx("button", { type: "button", className: "shrink-0 rounded-md bg-cyan-500/20 px-2 py-0.5 text-cyan-200 ring-1 ring-inset ring-cyan-400/30 transition-colors hover:bg-cyan-500/30", onClick: () => insertAt(prefab), children: "Insert" }), _jsx("button", { type: "button", className: "shrink-0 rounded-md bg-rose-500/15 px-2 py-0.5 text-rose-200 ring-1 ring-inset ring-rose-400/25 transition-colors hover:bg-rose-500/25", onClick: () => session.dispatch({ type: "deletePrefab", prefabId: prefab.id }), children: "Delete" })] }, prefab.id));
41
+ })) })] }));
42
+ }
@@ -0,0 +1,18 @@
1
+ import type { EditorHostApi } from "./session.js";
2
+ /**
3
+ * Publishes live scene entities onto the editor reverse channel and gates `ctx.time` against the
4
+ * host play-control (pause / step). Mount only in editor play mode.
5
+ * @internal
6
+ */
7
+ export declare function RuntimePlayPublisher({ api }: {
8
+ api: EditorHostApi;
9
+ }): null;
10
+ /**
11
+ * Play-mode chrome: pause/step controls plus a compact reverse-channel entity inspector.
12
+ * Transform nudges go through `runtime_set` (document write-back by default).
13
+ * @internal
14
+ */
15
+ export declare function RuntimePlayInspectorChrome({ api, onExit, }: {
16
+ api: EditorHostApi;
17
+ onExit: () => void;
18
+ }): import("react").JSX.Element;