@jgengine/editor 0.11.0 → 0.12.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.
- package/CHANGELOG.md +26 -0
- package/README.md +12 -0
- package/dist/AssetBrowser.d.ts +26 -1
- package/dist/AssetBrowser.js +54 -1
- package/dist/AssetDropZone.d.ts +9 -0
- package/dist/AssetDropZone.js +65 -0
- package/dist/CatalogsPanel.d.ts +4 -2
- package/dist/CatalogsPanel.js +162 -9
- package/dist/CityPreview.d.ts +11 -0
- package/dist/CityPreview.js +45 -0
- package/dist/CollectionsPanel.js +5 -5
- package/dist/DebugDraw.d.ts +3 -1
- package/dist/DebugDraw.js +37 -19
- package/dist/EditorApp.d.ts +9 -1
- package/dist/EditorApp.js +147 -35
- package/dist/EditorChrome.d.ts +25 -6
- package/dist/EditorChrome.js +310 -130
- package/dist/InspectorPanel.d.ts +17 -7
- package/dist/InspectorPanel.js +173 -27
- package/dist/LightingPanel.d.ts +11 -0
- package/dist/LightingPanel.js +99 -0
- package/dist/MaterialsWorkspacePanel.d.ts +12 -0
- package/dist/MaterialsWorkspacePanel.js +86 -0
- package/dist/ParentPickerMenu.d.ts +13 -0
- package/dist/ParentPickerMenu.js +33 -0
- package/dist/PerfProbe.js +33 -0
- package/dist/PrefabsPanel.js +5 -5
- package/dist/RuntimePlayBridge.d.ts +5 -3
- package/dist/RuntimePlayBridge.js +6 -13
- package/dist/ScatterPreview.d.ts +3 -1
- package/dist/ScatterPreview.js +10 -7
- package/dist/SchemaInspector.js +21 -8
- package/dist/ScriptingPanel.d.ts +15 -0
- package/dist/ScriptingPanel.js +46 -0
- package/dist/SelectionGizmo.js +114 -30
- package/dist/StandaloneEditor.d.ts +16 -9
- package/dist/StandaloneEditor.js +19 -20
- package/dist/TerrainPanel.d.ts +3 -2
- package/dist/TerrainPanel.js +16 -5
- package/dist/TerrainSculpt.js +98 -59
- package/dist/agent/AgentPanel.d.ts +5 -2
- package/dist/agent/AgentPanel.js +11 -6
- package/dist/agent/toolBridge.js +5 -0
- package/dist/assetImport.d.ts +36 -0
- package/dist/assetImport.js +66 -0
- package/dist/authoredComponentMeta.d.ts +25 -0
- package/dist/authoredComponentMeta.js +65 -0
- package/dist/gizmoPivot.d.ts +4 -0
- package/dist/gizmoPivot.js +34 -0
- package/dist/handlers/catalogs.d.ts +3 -0
- package/dist/handlers/catalogs.js +137 -0
- package/dist/handlers/collections.d.ts +3 -0
- package/dist/handlers/collections.js +57 -0
- package/dist/handlers/context.d.ts +36 -0
- package/dist/handlers/context.js +1 -0
- package/dist/handlers/document.d.ts +3 -0
- package/dist/handlers/document.js +350 -0
- package/dist/handlers/grids.d.ts +3 -0
- package/dist/handlers/grids.js +142 -0
- package/dist/handlers/hierarchy.d.ts +3 -0
- package/dist/handlers/hierarchy.js +55 -0
- package/dist/handlers/index.d.ts +8 -0
- package/dist/handlers/index.js +23 -0
- package/dist/handlers/minimap.d.ts +9 -0
- package/dist/handlers/minimap.js +32 -0
- package/dist/handlers/runtime.d.ts +3 -0
- package/dist/handlers/runtime.js +191 -0
- package/dist/handlers/terrain.d.ts +3 -0
- package/dist/handlers/terrain.js +204 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/listDocumentTriggers.d.ts +42 -0
- package/dist/listDocumentTriggers.js +49 -0
- package/dist/materialAssignments.d.ts +63 -0
- package/dist/materialAssignments.js +84 -0
- package/dist/mcp/fieldSpec.d.ts +32 -0
- package/dist/mcp/fieldSpec.js +173 -0
- package/dist/mcp/rpcRequest.d.ts +8 -5
- package/dist/mcp/rpcRequest.js +72 -93
- package/dist/mcp/tools.js +104 -912
- package/dist/networkSnapshot.d.ts +82 -0
- package/dist/networkSnapshot.js +73 -0
- package/dist/parentCandidates.d.ts +12 -0
- package/dist/parentCandidates.js +25 -0
- package/dist/session.d.ts +77 -4
- package/dist/session.js +36 -1017
- package/dist/shell/AnimationPanel.d.ts +17 -0
- package/dist/shell/AnimationPanel.js +134 -0
- package/dist/shell/AssetThumbnail.d.ts +12 -0
- package/dist/shell/AssetThumbnail.js +23 -0
- package/dist/shell/BottomDock.d.ts +25 -0
- package/dist/shell/BottomDock.js +40 -0
- package/dist/shell/CommandPalette.d.ts +12 -0
- package/dist/shell/CommandPalette.js +56 -0
- package/dist/shell/ConsolePanel.d.ts +8 -0
- package/dist/shell/ConsolePanel.js +45 -0
- package/dist/shell/ContentBrowser.d.ts +21 -0
- package/dist/shell/ContentBrowser.js +130 -0
- package/dist/shell/HierarchyPanel.d.ts +26 -0
- package/dist/shell/HierarchyPanel.js +387 -0
- package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
- package/dist/shell/NetworkWorkspacePanel.js +49 -0
- package/dist/shell/PlayModeBar.d.ts +12 -0
- package/dist/shell/PlayModeBar.js +27 -0
- package/dist/shell/ProfilerPanel.d.ts +10 -0
- package/dist/shell/ProfilerPanel.js +41 -0
- package/dist/shell/SceneToolbar.d.ts +43 -0
- package/dist/shell/SceneToolbar.js +122 -0
- package/dist/shell/StatusBar.d.ts +18 -0
- package/dist/shell/StatusBar.js +41 -0
- package/dist/shell/TopAppBar.d.ts +33 -0
- package/dist/shell/TopAppBar.js +64 -0
- package/dist/shell/ViewportOverlays.d.ts +23 -0
- package/dist/shell/ViewportOverlays.js +136 -0
- package/dist/shell/WorkspaceRail.d.ts +20 -0
- package/dist/shell/WorkspaceRail.js +31 -0
- package/dist/shell/cameraTelemetry.d.ts +27 -0
- package/dist/shell/cameraTelemetry.js +24 -0
- package/dist/shell/commandRegistry.d.ts +58 -0
- package/dist/shell/commandRegistry.js +133 -0
- package/dist/shell/consoleSink.d.ts +11 -0
- package/dist/shell/consoleSink.js +17 -0
- package/dist/shell/consoleStore.d.ts +27 -0
- package/dist/shell/consoleStore.js +43 -0
- package/dist/shell/fields.d.ts +39 -0
- package/dist/shell/fields.js +104 -0
- package/dist/shell/formatSavedRelative.d.ts +2 -0
- package/dist/shell/formatSavedRelative.js +14 -0
- package/dist/shell/glbThumbnailCache.d.ts +45 -0
- package/dist/shell/glbThumbnailCache.js +241 -0
- package/dist/shell/hierarchyA11y.d.ts +21 -0
- package/dist/shell/hierarchyA11y.js +35 -0
- package/dist/shell/icons.d.ts +13 -0
- package/dist/shell/icons.js +85 -0
- package/dist/shell/layoutStore.d.ts +68 -0
- package/dist/shell/layoutStore.js +156 -0
- package/dist/shell/pathFlythrough.d.ts +37 -0
- package/dist/shell/pathFlythrough.js +54 -0
- package/dist/shell/perfHistory.d.ts +70 -0
- package/dist/shell/perfHistory.js +130 -0
- package/dist/shell/renameObject.d.ts +3 -0
- package/dist/shell/renameObject.js +25 -0
- package/dist/shell/theme.d.ts +36 -0
- package/dist/shell/theme.js +37 -0
- package/dist/shell/ui.d.ts +84 -0
- package/dist/shell/ui.js +90 -0
- package/dist/shell/useGlbThumbnail.d.ts +8 -0
- package/dist/shell/useGlbThumbnail.js +19 -0
- package/dist/uiStore.d.ts +27 -0
- package/dist/uiStore.js +10 -0
- package/dist/viewportContextMenu.d.ts +4 -2
- package/dist/viewportContextMenu.js +5 -1
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +7 -4
- package/skills/jgengine/api.md +172 -79
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-editor/SKILL.md +18 -3
- package/skills/jgengine-editor/api.md +397 -18
- package/skills/jgengine-editor/capabilities.md +46 -0
- package/skills/jgengine-editor/reference.md +87 -0
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/OutlinerPanel.d.ts +0 -14
- package/dist/OutlinerPanel.js +0 -84
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { WELL_KNOWN_MARKER_KINDS } from "@jgengine/core/editor/index";
|
|
4
|
+
import { VEGETATION_VOLUME_KIND } from "@jgengine/core/world/vegetation";
|
|
5
|
+
import { listSceneKinds } from "@jgengine/core/scene/sceneKinds";
|
|
6
|
+
import { ROTATION_SNAP_CHOICES_DEG, SCALE_SNAP_CHOICES, } from "../uiStore.js";
|
|
7
|
+
import { Icon } from "./icons.js";
|
|
8
|
+
import { BORDER, FOCUS_RING, MICRO_LABEL } from "./theme.js";
|
|
9
|
+
import { IconButton, Segmented, ToolbarDivider } from "./ui.js";
|
|
10
|
+
/** Volume placement entries offered by the Add menu. */
|
|
11
|
+
export const ADD_VOLUME_ENTRIES = [
|
|
12
|
+
{ label: "Zone (sphere)", tool: { tool: "volume", kind: "zone", shape: "sphere" } },
|
|
13
|
+
{ label: "Zone (box)", tool: { tool: "volume", kind: "zone", shape: "box" } },
|
|
14
|
+
{ label: "Zone (cylinder)", tool: { tool: "volume", kind: "zone", shape: "cylinder" } },
|
|
15
|
+
{ label: "Aggro range", tool: { tool: "volume", kind: "aggro", shape: "sphere" } },
|
|
16
|
+
{ label: "Leash range", tool: { tool: "volume", kind: "leash", shape: "sphere" } },
|
|
17
|
+
{ label: "Discover area", tool: { tool: "volume", kind: "discover", shape: "sphere" } },
|
|
18
|
+
{ label: "Capture area", tool: { tool: "volume", kind: "capture", shape: "cylinder" } },
|
|
19
|
+
{ label: "Vegetation (box)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "box" } },
|
|
20
|
+
{ label: "Vegetation (circle)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "sphere" } },
|
|
21
|
+
];
|
|
22
|
+
const GRID_SIZES = [0.5, 1, 2, 4, 8];
|
|
23
|
+
function MenuShell({ open, onClose, children, width = "w-56", }) {
|
|
24
|
+
const ref = useRef(null);
|
|
25
|
+
// Fixed positioning: the toolbar scrolls horizontally (overflow-x-auto), which also clips
|
|
26
|
+
// absolutely-positioned descendants — an absolute menu would render invisible below it.
|
|
27
|
+
const [anchor, setAnchor] = useState(null);
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (!open) {
|
|
30
|
+
setAnchor(null);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const rect = ref.current?.parentElement?.getBoundingClientRect();
|
|
34
|
+
if (rect !== undefined)
|
|
35
|
+
setAnchor({ left: rect.left, top: rect.bottom + 4 });
|
|
36
|
+
}, [open]);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open)
|
|
39
|
+
return;
|
|
40
|
+
const onPointerDown = (event) => {
|
|
41
|
+
if (ref.current !== null && !ref.current.contains(event.target))
|
|
42
|
+
onClose();
|
|
43
|
+
};
|
|
44
|
+
const onKey = (event) => {
|
|
45
|
+
if (event.key === "Escape")
|
|
46
|
+
onClose();
|
|
47
|
+
};
|
|
48
|
+
window.addEventListener("pointerdown", onPointerDown);
|
|
49
|
+
window.addEventListener("keydown", onKey);
|
|
50
|
+
return () => {
|
|
51
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
52
|
+
window.removeEventListener("keydown", onKey);
|
|
53
|
+
};
|
|
54
|
+
}, [open, onClose]);
|
|
55
|
+
if (!open)
|
|
56
|
+
return null;
|
|
57
|
+
return (_jsx("div", { ref: ref, style: anchor === null ? undefined : { left: anchor.left, top: anchor.top }, className: `fixed z-[70] max-h-[56vh] ${width} overflow-auto rounded-[8px] border border-white/10 bg-[#14171d] p-1.5 shadow-2xl shadow-black/60`, children: children }));
|
|
58
|
+
}
|
|
59
|
+
const MENU_ITEM = `block w-full rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
|
|
60
|
+
/** Small preset chip inside the snap menu (grid sizes, rotation/scale increments). */
|
|
61
|
+
function SnapChip({ label, active, onClick }) {
|
|
62
|
+
return (_jsx("button", { type: "button", onClick: onClick, "aria-pressed": active, className: `rounded-[4px] px-1.5 py-0.5 text-[10px] tabular-nums transition-colors ${FOCUS_RING} ${active ? "bg-cyan-500/20 text-cyan-100" : "bg-white/[0.05] text-neutral-400 hover:text-neutral-200"}`, children: label }));
|
|
63
|
+
}
|
|
64
|
+
const PIVOT_LABEL = {
|
|
65
|
+
origin: "Origin",
|
|
66
|
+
center: "Center",
|
|
67
|
+
median: "Median",
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
|
|
71
|
+
* snapping, viewport overlays, framing, projection, and the Add menu.
|
|
72
|
+
*/
|
|
73
|
+
export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }) {
|
|
74
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
75
|
+
const [snapOpen, setSnapOpen] = useState(false);
|
|
76
|
+
const [pivotOpen, setPivotOpen] = useState(false);
|
|
77
|
+
const lastSnapRef = useRef("ground");
|
|
78
|
+
if (snapMode !== "off")
|
|
79
|
+
lastSnapRef.current = snapMode;
|
|
80
|
+
// Recomputed every render (opening the menu rerenders): studios may register after the chrome
|
|
81
|
+
// mounts (game modules load lazily), and a once-on-mount snapshot would silently hide them.
|
|
82
|
+
const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined);
|
|
83
|
+
const pick = (placement) => {
|
|
84
|
+
setAddOpen(false);
|
|
85
|
+
onStartPlacement(placement);
|
|
86
|
+
};
|
|
87
|
+
return (_jsxs("div", { className: `pointer-events-auto relative z-[55] flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b ${BORDER} bg-[#0e1014] px-2 [scrollbar-width:none]`, role: "toolbar", "aria-label": "Scene tools", children: [_jsx(Segmented, { ariaLabel: "Editing tool", value: tool, onChange: onSetTool, options: [
|
|
88
|
+
{ value: "select", label: "Select", icon: "cursor" },
|
|
89
|
+
{ value: "terrain", label: "Terrain", icon: "terrain", kbd: "T" },
|
|
90
|
+
] }), _jsx(ToolbarDivider, {}), _jsx(Segmented, { ariaLabel: "Transform gizmo", value: gizmoMode, onChange: onSetGizmoMode, disabled: tool === "terrain", options: [
|
|
91
|
+
{ value: "translate", label: "Move", icon: "move", kbd: "W", iconOnly: true },
|
|
92
|
+
{ value: "rotate", label: "Rotate", icon: "rotate", kbd: "E", iconOnly: true },
|
|
93
|
+
{ value: "scale", label: "Scale", icon: "scale", kbd: "R", iconOnly: true },
|
|
94
|
+
] }), _jsx(Segmented, { ariaLabel: "Gizmo space", value: gizmoSpace, onChange: onSetGizmoSpace, disabled: tool === "terrain", options: [
|
|
95
|
+
{ value: "world", label: "World" },
|
|
96
|
+
{ value: "local", label: "Local" },
|
|
97
|
+
] }), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setPivotOpen((value) => !value), disabled: tool === "terrain", "aria-label": `Gizmo pivot: ${PIVOT_LABEL[gizmoPivot]}`, "aria-expanded": pivotOpen, title: "Gizmo pivot for multi-select: origin (primary), center (mean), or median", className: `flex h-6 items-center gap-1 rounded-[5px] border border-white/[0.07] bg-[#191d24] px-2 text-[11px] text-neutral-300 transition-colors hover:bg-[#1f242d] disabled:opacity-40 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "target", size: 12 }), PIVOT_LABEL[gizmoPivot], _jsx(Icon, { name: "chevronDown", size: 10, className: "text-neutral-500" })] }), _jsxs(MenuShell, { open: pivotOpen, onClose: () => setPivotOpen(false), width: "w-44", children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Gizmo pivot" }), ["origin", "center", "median"].map((pivot) => (_jsxs("button", { type: "button", className: `${MENU_ITEM} ${gizmoPivot === pivot ? "text-cyan-200" : ""}`, onClick: () => {
|
|
98
|
+
onSetGizmoPivot(pivot);
|
|
99
|
+
setPivotOpen(false);
|
|
100
|
+
}, children: [PIVOT_LABEL[pivot], gizmoPivot === pivot ? " ✓" : ""] }, pivot)))] })] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative flex items-center gap-1", children: [_jsx(IconButton, { icon: "magnet", label: snapMode === "off" ? "Enable snapping" : `Snapping: ${snapMode}`, active: snapMode !== "off", onClick: () => onSetSnapMode(snapMode === "off" ? lastSnapRef.current : "off") }), _jsxs("button", { type: "button", onClick: () => setSnapOpen((value) => !value), "aria-label": "Snapping options", "aria-expanded": snapOpen, className: `flex h-7 items-center gap-1 rounded-[5px] border border-white/[0.07] bg-[#191d24] px-2 text-[11px] text-neutral-300 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [snapMode === "off" ? "Snap off" : snapMode === "grid" ? `Grid ${gridSize}m` : "Ground", _jsx(Icon, { name: "chevronDown", size: 10, className: "text-neutral-500" })] }), _jsxs(MenuShell, { open: snapOpen, onClose: () => setSnapOpen(false), width: "w-52", children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Translation snap" }), ["ground", "grid", "off"].map((mode) => (_jsxs("button", { type: "button", className: `${MENU_ITEM} ${snapMode === mode ? "text-cyan-200" : ""}`, onClick: () => {
|
|
101
|
+
onSetSnapMode(mode);
|
|
102
|
+
if (mode !== "grid")
|
|
103
|
+
setSnapOpen(false);
|
|
104
|
+
}, children: [mode === "ground" ? "Snap to ground" : mode === "grid" ? "Snap to grid" : "Snapping off", snapMode === mode ? " ✓" : ""] }, mode))), snapMode === "grid" ? (_jsx("div", { className: "flex items-center gap-1 px-2 py-1.5", children: GRID_SIZES.map((size) => (_jsx(SnapChip, { label: `${size}m`, active: gridSize === size, onClick: () => {
|
|
105
|
+
onSetGridSize(size);
|
|
106
|
+
setSnapOpen(false);
|
|
107
|
+
} }, size))) })) : null, _jsx("div", { className: "my-1 h-px bg-white/[0.07]" }), _jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Rotation snap" }), _jsxs("div", { className: "flex items-center gap-1 px-2 py-1.5", children: [_jsx(SnapChip, { label: "Off", active: rotationSnapDeg === null, onClick: () => onSetRotationSnapDeg(null) }), ROTATION_SNAP_CHOICES_DEG.map((deg) => (_jsx(SnapChip, { label: `${deg}°`, active: rotationSnapDeg === deg, onClick: () => onSetRotationSnapDeg(deg) }, deg)))] }), _jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Scale snap" }), _jsxs("div", { className: "flex items-center gap-1 px-2 py-1.5", children: [_jsx(SnapChip, { label: "Off", active: scaleSnap === null, onClick: () => onSetScaleSnap(null) }), SCALE_SNAP_CHOICES.map((snap) => (_jsx(SnapChip, { label: `${snap}`, active: scaleSnap === snap, onClick: () => onSetScaleSnap(snap) }, snap)))] })] })] }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "grid", label: "Reference grid (G)", active: showGrid, onClick: onToggleGrid }), _jsx(IconButton, { icon: "terrain", label: "Elevation contours", active: showContours, onClick: onToggleContours }), _jsx(IconButton, { icon: "layers", label: "Terrain-draped grid", active: showSurfaceGrid, onClick: onToggleSurfaceGrid }), _jsx(IconButton, { icon: "gauge", label: "Elevation readout", active: showElevation, onClick: onToggleElevation }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "frame", label: "Frame selection / scene (F)", onClick: onFrame }), _jsxs("button", { type: "button", onClick: () => onSetCameraProjection(cameraProjection === "perspective" ? "orthographic" : "perspective"), title: cameraProjection === "perspective"
|
|
108
|
+
? "Perspective projection — click for orthographic"
|
|
109
|
+
: "Orthographic projection — click for perspective", "aria-label": cameraProjection === "perspective"
|
|
110
|
+
? "Camera projection: perspective (switch to orthographic)"
|
|
111
|
+
: "Camera projection: orthographic (switch to perspective)", "aria-pressed": cameraProjection === "orthographic", className: `flex h-6 items-center gap-1 rounded-[5px] border px-2 text-[11px] transition-colors ${FOCUS_RING} ${cameraProjection === "orthographic"
|
|
112
|
+
? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
|
|
113
|
+
: "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
|
|
114
|
+
? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
|
|
115
|
+
: "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Studios" }), studioKinds.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
|
|
116
|
+
? { tool: "path", kind: definition.kind }
|
|
117
|
+
: definition.target === "volume"
|
|
118
|
+
? { tool: "volume", kind: definition.kind, shape: "box" }
|
|
119
|
+
: { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
|
|
120
|
+
? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
|
|
121
|
+
: "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
|
|
122
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EditorHostApi, EditorPerfSample } from "../session.js";
|
|
2
|
+
import type { PerfHistoryStore } from "./perfHistory.js";
|
|
3
|
+
/** Shared editor-host perf poll: samples on an interval and feeds the profiler history. */
|
|
4
|
+
export declare function usePerfSample(api: EditorHostApi, history?: PerfHistoryStore): EditorPerfSample | null;
|
|
5
|
+
/** Compact triangle-count formatting (1.2M / 340k). */
|
|
6
|
+
export declare function formatTriangles(count: number): string;
|
|
7
|
+
/**
|
|
8
|
+
* Bottom status strip: readiness, scene stats, live perf pill, autosave state, and camera hints.
|
|
9
|
+
* Owns its own perf poll (and the profiler history feed) so the 500ms tick never rerenders the shell.
|
|
10
|
+
*/
|
|
11
|
+
export declare function StatusBar({ api, history, objects, foliage, selectionCount, autosave, }: {
|
|
12
|
+
api: EditorHostApi;
|
|
13
|
+
history: PerfHistoryStore;
|
|
14
|
+
objects: number;
|
|
15
|
+
foliage: number;
|
|
16
|
+
selectionCount: number;
|
|
17
|
+
autosave: boolean;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { classifyEditorPerf } from "../perfPill.js";
|
|
4
|
+
import { BORDER, NUMERIC } from "./theme.js";
|
|
5
|
+
const PERF_POLL_MS = 500;
|
|
6
|
+
/** Shared editor-host perf poll: samples on an interval and feeds the profiler history. */
|
|
7
|
+
export function usePerfSample(api, history) {
|
|
8
|
+
const [sample, setSample] = useState(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const timer = setInterval(() => {
|
|
11
|
+
const next = api.getPerf();
|
|
12
|
+
setSample(next);
|
|
13
|
+
if (next !== null && history !== undefined)
|
|
14
|
+
history.push(next);
|
|
15
|
+
}, PERF_POLL_MS);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, [api, history]);
|
|
18
|
+
return sample;
|
|
19
|
+
}
|
|
20
|
+
/** Compact triangle-count formatting (1.2M / 340k). */
|
|
21
|
+
export function formatTriangles(count) {
|
|
22
|
+
if (count >= 1_000_000)
|
|
23
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
24
|
+
if (count >= 1_000)
|
|
25
|
+
return `${(count / 1_000).toFixed(0)}k`;
|
|
26
|
+
return String(count);
|
|
27
|
+
}
|
|
28
|
+
const PERF_TONE_CLASS = {
|
|
29
|
+
idle: "text-neutral-500",
|
|
30
|
+
healthy: "text-emerald-400",
|
|
31
|
+
busy: "text-rose-400",
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Bottom status strip: readiness, scene stats, live perf pill, autosave state, and camera hints.
|
|
35
|
+
* Owns its own perf poll (and the profiler history feed) so the 500ms tick never rerenders the shell.
|
|
36
|
+
*/
|
|
37
|
+
export function StatusBar({ api, history, objects, foliage, selectionCount, autosave, }) {
|
|
38
|
+
const perf = usePerfSample(api, history);
|
|
39
|
+
const tone = perf === null ? "idle" : classifyEditorPerf(perf);
|
|
40
|
+
return (_jsxs("footer", { className: `pointer-events-auto flex h-6 shrink-0 items-center gap-3 border-t ${BORDER} bg-[#0e1014] px-2.5 text-[10px] text-neutral-500`, children: [_jsxs("span", { className: "flex items-center gap-1.5 text-neutral-400", children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${tone === "busy" ? "bg-rose-400" : "bg-emerald-500"}` }), "Ready"] }), _jsxs("span", { className: NUMERIC, children: [objects, " objects"] }), foliage > 0 ? _jsxs("span", { className: NUMERIC, children: ["\u2248", formatTriangles(foliage), " foliage"] }) : null, selectionCount > 0 ? _jsxs("span", { className: `${NUMERIC} text-cyan-300/80`, children: [selectionCount, " selected"] }) : null, perf !== null ? (_jsxs("span", { className: `${NUMERIC} ${PERF_TONE_CLASS[tone]}`, children: [tone === "idle" ? "idle" : `${perf.fps.toFixed(0)} fps`, tone === "idle" && perf.drawCalls === 0 ? "" : ` · ${perf.drawCalls} draws · ${formatTriangles(perf.triangles)} tris`] })) : null, _jsx("span", { className: "ml-auto hidden lg:inline", children: "RMB orbit \u00B7 MMB pan \u00B7 W/E/R gizmo \u00B7 F frame \u00B7 ? shortcuts" }), _jsxs("span", { className: "flex items-center gap-1.5", children: ["Draft autosave", _jsx("span", { className: autosave ? "text-emerald-400" : "text-neutral-600", children: autosave ? "on" : "off" })] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @internal Re-export for historical TopAppBar importers. */
|
|
2
|
+
export { formatSavedRelative } from "./formatSavedRelative.js";
|
|
3
|
+
/** Document save lifecycle mirrored from `useDocumentSave`. */
|
|
4
|
+
export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
|
|
5
|
+
/**
|
|
6
|
+
* Global application bar: identity + save state on the left, command palette in the center,
|
|
7
|
+
* history / run controls / document actions on the right. Pause and Step stay disabled in
|
|
8
|
+
* edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
|
|
9
|
+
* runtime pause/step RPCs so mode switches keep shell chrome.
|
|
10
|
+
*/
|
|
11
|
+
export declare function TopAppBar({ gameId, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
|
|
12
|
+
gameId: string;
|
|
13
|
+
dirty: boolean;
|
|
14
|
+
saveState: TopBarSaveState;
|
|
15
|
+
/** Epoch ms of the last successful save this session; omit/null when never saved. */
|
|
16
|
+
lastSavedAt?: number | null;
|
|
17
|
+
saveAvailable: boolean;
|
|
18
|
+
saveError: string | null;
|
|
19
|
+
canUndo: boolean;
|
|
20
|
+
canRedo: boolean;
|
|
21
|
+
onUndo: () => void;
|
|
22
|
+
onRedo: () => void;
|
|
23
|
+
onSave: () => void;
|
|
24
|
+
onPlay: () => void;
|
|
25
|
+
onWalk: () => void;
|
|
26
|
+
onHud: () => void;
|
|
27
|
+
onImport: () => void;
|
|
28
|
+
onExport: () => void;
|
|
29
|
+
onCopyJson: () => void;
|
|
30
|
+
onOpenPalette: () => void;
|
|
31
|
+
onToggleHelp: () => void;
|
|
32
|
+
onResetLayout: () => void;
|
|
33
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { formatSavedRelative } from "./formatSavedRelative.js";
|
|
4
|
+
import { Icon } from "./icons.js";
|
|
5
|
+
import { BORDER, FOCUS_RING } from "./theme.js";
|
|
6
|
+
import { IconButton, Kbd, ToolbarDivider } from "./ui.js";
|
|
7
|
+
/** @internal Re-export for historical TopAppBar importers. */
|
|
8
|
+
export { formatSavedRelative } from "./formatSavedRelative.js";
|
|
9
|
+
function SaveStatus({ dirty, saveState, lastSavedAt, }) {
|
|
10
|
+
// Tick once a minute so "Saved 2m ago" advances without a save/dirty transition.
|
|
11
|
+
const [, setTick] = useState(0);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (dirty || lastSavedAt === null)
|
|
14
|
+
return;
|
|
15
|
+
const id = window.setInterval(() => setTick((value) => value + 1), 30_000);
|
|
16
|
+
return () => window.clearInterval(id);
|
|
17
|
+
}, [dirty, lastSavedAt]);
|
|
18
|
+
if (saveState === "saving") {
|
|
19
|
+
return (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-neutral-400", children: [_jsx("span", { className: "h-1.5 w-1.5 animate-pulse rounded-full bg-cyan-400" }), "Saving\u2026"] }));
|
|
20
|
+
}
|
|
21
|
+
if (saveState === "error") {
|
|
22
|
+
return (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-rose-300", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-rose-400" }), "Save failed"] }));
|
|
23
|
+
}
|
|
24
|
+
if (dirty) {
|
|
25
|
+
return (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-amber-300/90", title: "Unsaved edits", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-amber-400" }), "Unsaved changes"] }));
|
|
26
|
+
}
|
|
27
|
+
const relative = lastSavedAt === null ? null : formatSavedRelative(lastSavedAt);
|
|
28
|
+
const label = relative === null ? "Saved" : relative === "just now" ? "Saved just now" : `Saved ${relative}`;
|
|
29
|
+
return (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-neutral-500", title: lastSavedAt === null ? "All edits saved" : `Last saved ${new Date(lastSavedAt).toLocaleString()}`, children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-emerald-500/80" }), label] }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Global application bar: identity + save state on the left, command palette in the center,
|
|
33
|
+
* history / run controls / document actions on the right. Pause and Step stay disabled in
|
|
34
|
+
* edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
|
|
35
|
+
* runtime pause/step RPCs so mode switches keep shell chrome.
|
|
36
|
+
*/
|
|
37
|
+
export function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
|
|
38
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
39
|
+
const menuRef = useRef(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!menuOpen)
|
|
42
|
+
return;
|
|
43
|
+
const onPointerDown = (event) => {
|
|
44
|
+
if (menuRef.current !== null && !menuRef.current.contains(event.target))
|
|
45
|
+
setMenuOpen(false);
|
|
46
|
+
};
|
|
47
|
+
const onKey = (event) => {
|
|
48
|
+
if (event.key === "Escape")
|
|
49
|
+
setMenuOpen(false);
|
|
50
|
+
};
|
|
51
|
+
window.addEventListener("pointerdown", onPointerDown);
|
|
52
|
+
window.addEventListener("keydown", onKey);
|
|
53
|
+
return () => {
|
|
54
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
55
|
+
window.removeEventListener("keydown", onKey);
|
|
56
|
+
};
|
|
57
|
+
}, [menuOpen]);
|
|
58
|
+
const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
|
|
59
|
+
return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
|
|
60
|
+
? "bg-rose-600/90 text-white hover:bg-rose-500"
|
|
61
|
+
: dirty
|
|
62
|
+
? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
|
|
63
|
+
: "border border-white/[0.07] bg-[#191d24] text-neutral-400 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "save", size: 12 }), saveState === "saving" ? "Saving…" : saveState === "error" ? "Retry save" : "Save"] })) : (_jsxs("button", { type: "button", onClick: onExport, title: "Download the scene document JSON", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-cyan-400/30 bg-cyan-500/15 px-3 text-[12px] font-medium text-cyan-200 transition-colors hover:bg-cyan-500/25 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "export", size: 12 }), "Export"] })), _jsxs("div", { className: "relative", ref: menuRef, children: [_jsx(IconButton, { icon: "settings", label: "Editor menu", onClick: () => setMenuOpen((value) => !value), active: menuOpen, tone: "ghost" }), menuOpen ? (_jsxs("div", { className: "absolute right-0 top-9 z-[70] w-56 rounded-[8px] border border-white/10 bg-[#14171d] p-1.5 shadow-2xl shadow-black/60", children: [_jsxs("button", { type: "button", className: menuItem, onClick: () => { setMenuOpen(false); onImport(); }, children: [_jsx(Icon, { name: "import", size: 13 }), " Import scene JSON\u2026"] }), _jsxs("button", { type: "button", className: menuItem, onClick: () => { setMenuOpen(false); onExport(); }, children: [_jsx(Icon, { name: "export", size: 13 }), " Export scene JSON"] }), _jsxs("button", { type: "button", className: menuItem, onClick: () => { setMenuOpen(false); onCopyJson(); }, children: [_jsx(Icon, { name: "copy", size: 13 }), " Copy JSON to clipboard"] }), _jsx("div", { className: "my-1 h-px bg-white/[0.07]" }), _jsxs("button", { type: "button", className: menuItem, onClick: () => { setMenuOpen(false); onResetLayout(); }, children: [_jsx(Icon, { name: "panel", size: 13 }), " Reset panel layout"] }), _jsxs("button", { type: "button", className: menuItem, onClick: () => { setMenuOpen(false); onToggleHelp(); }, children: [_jsx(Icon, { name: "help", size: 13 }), " Keyboard shortcuts"] })] })) : null] }), _jsx(IconButton, { icon: "help", label: "Keyboard shortcuts (?)", onClick: onToggleHelp, tone: "ghost" })] })] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EditorDocument } from "@jgengine/core/editor/index";
|
|
2
|
+
import type { EditorHostApi } from "../session.js";
|
|
3
|
+
/**
|
|
4
|
+
* Top-left viewport performance readout backed by the real in-canvas {@link PerfProbe} samples.
|
|
5
|
+
* Rows without data (no sample yet) simply don't render — nothing is fabricated.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PerformanceOverlay: import("react").MemoExoticComponent<({ api }: {
|
|
8
|
+
api: EditorHostApi;
|
|
9
|
+
}) => import("react").JSX.Element | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Bottom-left orientation axis widget. Reads the camera basis published by the in-canvas probe on
|
|
12
|
+
* its own rAF loop and mutates SVG attributes directly, so orbiting never rerenders React.
|
|
13
|
+
*/
|
|
14
|
+
export declare const OrientationWidget: import("react").MemoExoticComponent<() => import("react").JSX.Element>;
|
|
15
|
+
/**
|
|
16
|
+
* Top-right collapsible viewport utility panel: real minimap from `document.minimap` when baked
|
|
17
|
+
* (via `bake_minimap` RPC — never a fake image), plus per-kind object counts and framing actions.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ViewportUtilityPanel({ document, api, selectionCount, }: {
|
|
20
|
+
document: EditorDocument;
|
|
21
|
+
api: EditorHostApi;
|
|
22
|
+
selectionCount: number;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { classifyEditorPerf } from "../perfPill.js";
|
|
4
|
+
import { getCameraTelemetry } from "./cameraTelemetry.js";
|
|
5
|
+
import { Icon } from "./icons.js";
|
|
6
|
+
import { formatTriangles, usePerfSample } from "./StatusBar.js";
|
|
7
|
+
import { FOCUS_RING, NUMERIC } from "./theme.js";
|
|
8
|
+
import { IconButton } from "./ui.js";
|
|
9
|
+
const OVERLAY_CARD = "rounded-[7px] border border-white/[0.08] bg-[#0e1014]/85 shadow-lg shadow-black/40 backdrop-blur-md";
|
|
10
|
+
/**
|
|
11
|
+
* Top-left viewport performance readout backed by the real in-canvas {@link PerfProbe} samples.
|
|
12
|
+
* Rows without data (no sample yet) simply don't render — nothing is fabricated.
|
|
13
|
+
*/
|
|
14
|
+
export const PerformanceOverlay = memo(function PerformanceOverlay({ api }) {
|
|
15
|
+
const perf = usePerfSample(api);
|
|
16
|
+
if (perf === null)
|
|
17
|
+
return null;
|
|
18
|
+
const tone = classifyEditorPerf(perf);
|
|
19
|
+
// A throttled render-on-demand window reports huge frame gaps and a reset draw counter;
|
|
20
|
+
// show placeholders instead of implying a 2,000ms frame.
|
|
21
|
+
const stale = tone === "idle" && perf.drawCalls === 0;
|
|
22
|
+
const rows = [
|
|
23
|
+
["FPS", tone === "idle" ? "idle" : perf.fps.toFixed(0)],
|
|
24
|
+
["Frame", stale ? "—" : `${perf.frameMs.toFixed(2)} ms`],
|
|
25
|
+
["Draw calls", stale ? "—" : String(perf.drawCalls)],
|
|
26
|
+
["Tris", stale ? "—" : formatTriangles(perf.triangles)],
|
|
27
|
+
];
|
|
28
|
+
if (perf.authoringMs !== undefined && perf.authoringMs > 0) {
|
|
29
|
+
rows.push(["Authoring", `${perf.authoringMs.toFixed(1)} ms`]);
|
|
30
|
+
}
|
|
31
|
+
return (_jsx("div", { className: `pointer-events-auto absolute left-2.5 top-2.5 z-30 w-36 p-2 ${OVERLAY_CARD}`, children: _jsx("div", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5", children: rows.map(([label, value]) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "text-[10px] text-neutral-500", children: label }), _jsx("span", { className: `text-right text-[10px] ${NUMERIC} ${label === "FPS" ? (tone === "busy" ? "text-rose-300" : tone === "healthy" ? "text-emerald-300" : "text-neutral-400") : "text-neutral-300"}`, children: value })] }, label))) }) }));
|
|
32
|
+
});
|
|
33
|
+
const AXES = [
|
|
34
|
+
{ axis: "x", color: "#f87171" },
|
|
35
|
+
{ axis: "y", color: "#4ade80" },
|
|
36
|
+
{ axis: "z", color: "#60a5fa" },
|
|
37
|
+
];
|
|
38
|
+
const WIDGET_SIZE = 84;
|
|
39
|
+
const WIDGET_RADIUS = 30;
|
|
40
|
+
/**
|
|
41
|
+
* Bottom-left orientation axis widget. Reads the camera basis published by the in-canvas probe on
|
|
42
|
+
* its own rAF loop and mutates SVG attributes directly, so orbiting never rerenders React.
|
|
43
|
+
*/
|
|
44
|
+
export const OrientationWidget = memo(function OrientationWidget() {
|
|
45
|
+
const svgRef = useRef(null);
|
|
46
|
+
const readoutRef = useRef(null);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const telemetry = getCameraTelemetry();
|
|
49
|
+
let lastVersion = -1;
|
|
50
|
+
let frame = 0;
|
|
51
|
+
const center = WIDGET_SIZE / 2;
|
|
52
|
+
const tick = () => {
|
|
53
|
+
frame = requestAnimationFrame(tick);
|
|
54
|
+
if (telemetry.version === lastVersion)
|
|
55
|
+
return;
|
|
56
|
+
lastVersion = telemetry.version;
|
|
57
|
+
const svg = svgRef.current;
|
|
58
|
+
if (svg === null)
|
|
59
|
+
return;
|
|
60
|
+
const basis = {
|
|
61
|
+
x: { sx: telemetry.rightX, sy: telemetry.upX, depth: telemetry.forwardX },
|
|
62
|
+
y: { sx: telemetry.rightY, sy: telemetry.upY, depth: telemetry.forwardY },
|
|
63
|
+
z: { sx: telemetry.rightZ, sy: telemetry.upZ, depth: telemetry.forwardZ },
|
|
64
|
+
};
|
|
65
|
+
for (const { axis } of AXES) {
|
|
66
|
+
const line = svg.querySelector(`[data-axis-line="${axis}"]`);
|
|
67
|
+
const dot = svg.querySelector(`[data-axis-dot="${axis}"]`);
|
|
68
|
+
const text = svg.querySelector(`[data-axis-label="${axis}"]`);
|
|
69
|
+
if (line === null || dot === null || text === null)
|
|
70
|
+
continue;
|
|
71
|
+
const { sx, sy, depth } = basis[axis];
|
|
72
|
+
const endX = center + sx * WIDGET_RADIUS;
|
|
73
|
+
const endY = center - sy * WIDGET_RADIUS;
|
|
74
|
+
line.setAttribute("x2", endX.toFixed(1));
|
|
75
|
+
line.setAttribute("y2", endY.toFixed(1));
|
|
76
|
+
dot.setAttribute("cx", endX.toFixed(1));
|
|
77
|
+
dot.setAttribute("cy", endY.toFixed(1));
|
|
78
|
+
text.setAttribute("x", (center + sx * (WIDGET_RADIUS + 8)).toFixed(1));
|
|
79
|
+
text.setAttribute("y", (center - sy * (WIDGET_RADIUS + 8) + 3).toFixed(1));
|
|
80
|
+
const toward = depth < 0;
|
|
81
|
+
const opacity = toward ? "1" : "0.45";
|
|
82
|
+
line.setAttribute("opacity", opacity);
|
|
83
|
+
dot.setAttribute("opacity", opacity);
|
|
84
|
+
text.setAttribute("opacity", opacity);
|
|
85
|
+
}
|
|
86
|
+
const readout = readoutRef.current;
|
|
87
|
+
if (readout !== null) {
|
|
88
|
+
const az = ((telemetry.azimuth * 180) / Math.PI + 360) % 360;
|
|
89
|
+
const el = (telemetry.elevation * 180) / Math.PI;
|
|
90
|
+
readout.textContent = `${az.toFixed(0)}° az · ${el.toFixed(0)}° el`;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
frame = requestAnimationFrame(tick);
|
|
94
|
+
return () => cancelAnimationFrame(frame);
|
|
95
|
+
}, []);
|
|
96
|
+
return (_jsxs("div", { className: `pointer-events-auto absolute bottom-2.5 left-2.5 z-30 flex flex-col items-center p-1.5 ${OVERLAY_CARD}`, children: [_jsxs("svg", { ref: svgRef, width: WIDGET_SIZE, height: WIDGET_SIZE, viewBox: `0 0 ${WIDGET_SIZE} ${WIDGET_SIZE}`, "aria-label": "Camera orientation axes", role: "img", children: [_jsx("circle", { cx: WIDGET_SIZE / 2, cy: WIDGET_SIZE / 2, r: WIDGET_RADIUS + 3, fill: "rgba(255,255,255,0.02)", stroke: "rgba(255,255,255,0.06)" }), AXES.map(({ axis, color }) => (_jsxs("g", { children: [_jsx("line", { "data-axis-line": axis, x1: WIDGET_SIZE / 2, y1: WIDGET_SIZE / 2, x2: WIDGET_SIZE / 2, y2: WIDGET_SIZE / 2, stroke: color, strokeWidth: 1.5 }), _jsx("circle", { "data-axis-dot": axis, cx: WIDGET_SIZE / 2, cy: WIDGET_SIZE / 2, r: 2.5, fill: color }), _jsx("text", { "data-axis-label": axis, x: WIDGET_SIZE / 2, y: WIDGET_SIZE / 2, textAnchor: "middle", fontSize: 8, fill: color, className: "uppercase", children: axis })] }, axis)))] }), _jsx("div", { ref: readoutRef, className: `text-[9px] text-neutral-500 ${NUMERIC}` })] }));
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Top-right collapsible viewport utility panel: real minimap from `document.minimap` when baked
|
|
100
|
+
* (via `bake_minimap` RPC — never a fake image), plus per-kind object counts and framing actions.
|
|
101
|
+
*/
|
|
102
|
+
export function ViewportUtilityPanel({ document, api, selectionCount, }) {
|
|
103
|
+
const [open, setOpen] = useState(true);
|
|
104
|
+
const [bakeBusy, setBakeBusy] = useState(false);
|
|
105
|
+
const [bakeError, setBakeError] = useState(null);
|
|
106
|
+
const counts = useMemo(() => {
|
|
107
|
+
const byKind = new Map();
|
|
108
|
+
const bump = (kind) => byKind.set(kind, (byKind.get(kind) ?? 0) + 1);
|
|
109
|
+
for (const marker of document.markers)
|
|
110
|
+
bump(marker.kind);
|
|
111
|
+
for (const volume of document.volumes)
|
|
112
|
+
bump(volume.kind);
|
|
113
|
+
for (const path of document.paths)
|
|
114
|
+
bump(path.kind);
|
|
115
|
+
for (let index = 0; index < document.annotations.length; index += 1)
|
|
116
|
+
bump("note");
|
|
117
|
+
return [...byKind.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
118
|
+
}, [document]);
|
|
119
|
+
const minimap = document.minimap;
|
|
120
|
+
const bake = () => {
|
|
121
|
+
setBakeBusy(true);
|
|
122
|
+
setBakeError(null);
|
|
123
|
+
// bake_minimap is synchronous; yield so the button can show a busy state for one frame.
|
|
124
|
+
requestAnimationFrame(() => {
|
|
125
|
+
const result = api.handle({ method: "bake_minimap", resolution: 128 });
|
|
126
|
+
setBakeBusy(false);
|
|
127
|
+
if (!result.ok)
|
|
128
|
+
setBakeError(result.error ?? "bake failed");
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
return (_jsxs("div", { className: `pointer-events-auto absolute right-2.5 top-2.5 z-30 w-48 ${OVERLAY_CARD}`, children: [_jsxs("div", { className: "flex h-7 items-center gap-1.5 px-2", children: [_jsx(Icon, { name: "layers", size: 12, className: "text-neutral-500" }), _jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wider text-neutral-400", children: "Scene overview" }), _jsx("button", { type: "button", "aria-label": open ? "Collapse scene overview" : "Expand scene overview", "aria-expanded": open, onClick: () => setOpen((value) => !value), className: `ml-auto flex h-5 w-5 items-center justify-center rounded-[4px] text-neutral-500 transition-colors hover:bg-white/[0.07] hover:text-neutral-200 ${FOCUS_RING}`, children: _jsx(Icon, { name: open ? "chevronDown" : "chevronRight", size: 10 }) })] }), open ? (_jsxs("div", { className: "border-t border-white/[0.06] p-2", children: [_jsx("div", { className: "mb-2 overflow-hidden rounded-[5px] border border-white/[0.07] bg-black/40", children: minimap !== undefined ? (_jsx("img", { src: minimap.background, alt: "Baked scene minimap", className: "block h-28 w-full object-cover", draggable: false })) : (_jsxs("div", { className: "flex h-28 flex-col items-center justify-center gap-1 px-2 text-center", children: [_jsx("span", { className: "text-[10px] text-neutral-500", children: "No minimap baked yet" }), _jsx("span", { className: "text-[9px] text-neutral-600", children: "Uses live terrain \u2014 not a placeholder image" })] })) }), _jsx("button", { type: "button", disabled: bakeBusy, onClick: bake, className: `mb-2 w-full rounded-[5px] border border-white/[0.07] bg-[#191d24] px-2 py-1 text-[10px] text-neutral-300 transition-colors hover:bg-[#1f242d] disabled:opacity-50 ${FOCUS_RING}`, children: bakeBusy ? "Baking…" : minimap !== undefined ? "Rebake minimap" : "Bake minimap" }), bakeError !== null ? _jsx("div", { className: "mb-2 text-[9px] text-rose-300", children: bakeError }) : null, counts.length === 0 ? (_jsx("div", { className: "py-1 text-[10px] text-neutral-600", children: "No authored objects yet." })) : (_jsx("div", { className: "space-y-0.5", children: counts.map(([kind, count]) => (_jsxs("div", { className: "flex items-center justify-between text-[10px]", children: [_jsx("span", { className: "truncate text-neutral-400", children: kind }), _jsx("span", { className: `text-neutral-300 ${NUMERIC}`, children: count })] }, kind))) })), _jsxs("div", { className: "mt-2 flex gap-1", children: [_jsx("button", { type: "button", onClick: () => api.handle({ method: "camera_frame" }), className: `flex-1 rounded-[5px] border border-white/[0.07] bg-[#191d24] px-2 py-1 text-[10px] text-neutral-300 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: "Frame all" }), _jsx(IconButton, { icon: "target", label: "Frame selection", disabled: selectionCount === 0, onClick: () => {
|
|
132
|
+
const id = api.getSession().getState().selection[0];
|
|
133
|
+
if (id !== undefined)
|
|
134
|
+
api.handle({ method: "camera_goto", id });
|
|
135
|
+
} })] })] })) : null] }));
|
|
136
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type IconName } from "./icons.js";
|
|
2
|
+
import type { EditorWorkspace } from "./layoutStore.js";
|
|
3
|
+
interface RailEntry {
|
|
4
|
+
id: EditorWorkspace;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: IconName;
|
|
7
|
+
/** False renders the mode as a visibly disabled, planned workspace. */
|
|
8
|
+
supported: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Rail order and support map. Unsupported modes are visible but disabled — never fake panels. */
|
|
11
|
+
export declare const WORKSPACES: readonly RailEntry[];
|
|
12
|
+
/**
|
|
13
|
+
* Narrow left workspace rail. Supported modes activate their home panels; planned modes are
|
|
14
|
+
* disabled with an explanatory tooltip so nothing pretends to work.
|
|
15
|
+
*/
|
|
16
|
+
export declare function WorkspaceRail({ active, onSelect, }: {
|
|
17
|
+
active: EditorWorkspace;
|
|
18
|
+
onSelect: (workspace: EditorWorkspace) => void;
|
|
19
|
+
}): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Icon } from "./icons.js";
|
|
3
|
+
import { BORDER, FOCUS_RING } from "./theme.js";
|
|
4
|
+
/** Rail order and support map. Unsupported modes are visible but disabled — never fake panels. */
|
|
5
|
+
export const WORKSPACES = [
|
|
6
|
+
{ id: "scene", label: "Scene", icon: "cube", supported: true },
|
|
7
|
+
{ id: "terrain", label: "Terrain", icon: "terrain", supported: true },
|
|
8
|
+
{ id: "assets", label: "Assets", icon: "image", supported: true },
|
|
9
|
+
{ id: "materials", label: "Materials", icon: "sphere", supported: true },
|
|
10
|
+
{ id: "scripting", label: "Scripting", icon: "script", supported: true },
|
|
11
|
+
{ id: "animation", label: "Animation", icon: "film", supported: false },
|
|
12
|
+
{ id: "audio", label: "Audio", icon: "audio", supported: false },
|
|
13
|
+
{ id: "lighting", label: "Lighting", icon: "bulb", supported: true },
|
|
14
|
+
{ id: "ai", label: "AI", icon: "sparkle", supported: true },
|
|
15
|
+
/** Presence/session inspection over host-supplied snapshot + game adapter config — never fabricated. */
|
|
16
|
+
{ id: "multiplayer", label: "Network", icon: "network", supported: true },
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Narrow left workspace rail. Supported modes activate their home panels; planned modes are
|
|
20
|
+
* disabled with an explanatory tooltip so nothing pretends to work.
|
|
21
|
+
*/
|
|
22
|
+
export function WorkspaceRail({ active, onSelect, }) {
|
|
23
|
+
return (_jsx("nav", { "aria-label": "Workspaces", className: `pointer-events-auto flex w-13 shrink-0 flex-col items-center gap-0.5 overflow-y-auto border-r ${BORDER} bg-[#0e1014] py-1.5 [scrollbar-width:none]`, children: WORKSPACES.map((entry) => {
|
|
24
|
+
const selected = entry.id === active;
|
|
25
|
+
return (_jsxs("button", { type: "button", disabled: !entry.supported, "aria-label": entry.supported ? `${entry.label} workspace` : `${entry.label} workspace — planned`, "aria-current": selected ? "page" : undefined, title: entry.supported ? `${entry.label} workspace` : `${entry.label} — planned workspace`, onClick: () => onSelect(entry.id), className: `relative flex w-11 flex-col items-center gap-0.5 rounded-[6px] py-1.5 transition-colors ${FOCUS_RING} ${selected
|
|
26
|
+
? "bg-cyan-500/15 text-cyan-200"
|
|
27
|
+
: entry.supported
|
|
28
|
+
? "text-neutral-500 hover:bg-white/[0.05] hover:text-neutral-300"
|
|
29
|
+
: "text-neutral-700"} disabled:pointer-events-none`, children: [selected ? _jsx("span", { className: "absolute -left-1 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-cyan-400" }) : null, _jsx(Icon, { name: entry.icon, size: 16 }), _jsx("span", { className: "text-[9px] leading-none", children: entry.label })] }, entry.id));
|
|
30
|
+
}) }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-frame camera orientation channel between the in-canvas probe and the DOM orientation
|
|
3
|
+
* widget. Deliberately a mutable record, not React state: the probe writes every frame and the
|
|
4
|
+
* widget reads on its own rAF loop, applying styles directly — zero shell rerenders.
|
|
5
|
+
*/
|
|
6
|
+
export interface CameraTelemetry {
|
|
7
|
+
/** Camera-space basis (world-matrix columns): screen-projects world axes without a matrix lib. */
|
|
8
|
+
rightX: number;
|
|
9
|
+
rightY: number;
|
|
10
|
+
rightZ: number;
|
|
11
|
+
upX: number;
|
|
12
|
+
upY: number;
|
|
13
|
+
upZ: number;
|
|
14
|
+
forwardX: number;
|
|
15
|
+
forwardY: number;
|
|
16
|
+
forwardZ: number;
|
|
17
|
+
/** Camera yaw around +Y in radians (viewing direction). */
|
|
18
|
+
azimuth: number;
|
|
19
|
+
/** Camera pitch above the horizon in radians. */
|
|
20
|
+
elevation: number;
|
|
21
|
+
/** Monotonic write counter so readers can skip untouched frames. */
|
|
22
|
+
version: number;
|
|
23
|
+
}
|
|
24
|
+
/** Creates a telemetry record seeded at rest (identity basis). */
|
|
25
|
+
export declare function createCameraTelemetry(): CameraTelemetry;
|
|
26
|
+
/** Shared telemetry singleton — probe and widget may mount in different React trees. */
|
|
27
|
+
export declare function getCameraTelemetry(): CameraTelemetry;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Creates a telemetry record seeded at rest (identity basis). */
|
|
2
|
+
export function createCameraTelemetry() {
|
|
3
|
+
return {
|
|
4
|
+
rightX: 1,
|
|
5
|
+
rightY: 0,
|
|
6
|
+
rightZ: 0,
|
|
7
|
+
upX: 0,
|
|
8
|
+
upY: 1,
|
|
9
|
+
upZ: 0,
|
|
10
|
+
forwardX: 0,
|
|
11
|
+
forwardY: 0,
|
|
12
|
+
forwardZ: 1,
|
|
13
|
+
azimuth: 0,
|
|
14
|
+
elevation: 0,
|
|
15
|
+
version: 0,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const globalKey = "__jgengineEditorCameraTelemetry";
|
|
19
|
+
/** Shared telemetry singleton — probe and widget may mount in different React trees. */
|
|
20
|
+
export function getCameraTelemetry() {
|
|
21
|
+
const root = globalThis;
|
|
22
|
+
root[globalKey] ??= createCameraTelemetry();
|
|
23
|
+
return root[globalKey];
|
|
24
|
+
}
|