@jgengine/editor 0.18.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +504 -7
- package/dist/ClipPreviewLayer.d.ts +2 -3
- package/dist/ClipPreviewLayer.js +24 -26
- package/dist/CreatorApp.d.ts +12 -0
- package/dist/CreatorApp.js +107 -0
- package/dist/DebugDraw.js +3 -1
- package/dist/EditorApp.d.ts +14 -2
- package/dist/EditorApp.js +29 -14
- package/dist/EditorChrome.d.ts +9 -1
- package/dist/EditorChrome.js +61 -35
- package/dist/InspectorPanel.d.ts +3 -1
- package/dist/InspectorPanel.js +12 -8
- package/dist/LightingPanel.js +5 -1
- package/dist/MaterialAssetEditor.d.ts +13 -0
- package/dist/MaterialAssetEditor.js +109 -0
- package/dist/MaterialPreview.d.ts +16 -0
- package/dist/MaterialPreview.js +41 -0
- package/dist/MaterialsWorkspacePanel.d.ts +14 -2
- package/dist/MaterialsWorkspacePanel.js +78 -4
- package/dist/RuntimePlayBridge.js +1 -6
- package/dist/SelectionGizmo.js +23 -14
- package/dist/SimulationInspector.d.ts +9 -0
- package/dist/SimulationInspector.js +47 -0
- package/dist/TerrainSculpt.js +3 -1
- package/dist/agent/toolBridge.js +2 -0
- package/dist/authoringPlacement.d.ts +21 -0
- package/dist/authoringPlacement.js +34 -0
- package/dist/handlers/context.d.ts +1 -0
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +31 -1
- package/dist/handlers/hierarchy.d.ts +1 -1
- package/dist/handlers/hierarchy.js +4 -0
- package/dist/handlers/navmesh.d.ts +2 -0
- package/dist/handlers/navmesh.js +16 -4
- package/dist/handlers/runtime.js +20 -17
- package/dist/handlers/terrain.js +52 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/materialControls.d.ts +32 -0
- package/dist/materialControls.js +79 -0
- package/dist/mcp/fieldSpec.d.ts +1 -1
- package/dist/mcp/fieldSpec.js +13 -4
- package/dist/mcp/importOptionalGameModule.d.ts +2 -0
- package/dist/mcp/importOptionalGameModule.js +13 -0
- package/dist/mcp/loadGameCatalogs.js +5 -12
- package/dist/mcp/rpcRequest.d.ts +3 -3
- package/dist/mcp/rpcRequest.js +10 -4
- package/dist/mcp/tools.js +13 -4
- package/dist/modelAnimationAuthoring.d.ts +49 -6
- package/dist/modelAnimationAuthoring.js +118 -64
- package/dist/session.d.ts +64 -0
- package/dist/session.js +58 -7
- package/dist/shell/AnimationGraphPanel.d.ts +16 -0
- package/dist/shell/AnimationGraphPanel.js +180 -0
- package/dist/shell/AnimationPanel.d.ts +3 -1
- package/dist/shell/AnimationPanel.js +9 -7
- package/dist/shell/PlayModeBar.d.ts +2 -1
- package/dist/shell/PlayModeBar.js +2 -2
- package/dist/shell/SceneToolbar.d.ts +2 -1
- package/dist/shell/SceneToolbar.js +5 -4
- package/dist/shell/TopAppBar.d.ts +2 -1
- package/dist/shell/TopAppBar.js +2 -2
- package/dist/shell/WorkspaceRail.js +1 -1
- package/dist/shell/animGraphPreview.d.ts +58 -0
- package/dist/shell/animGraphPreview.js +95 -0
- package/dist/shell/clipPreview.d.ts +9 -0
- package/dist/useDocumentSave.d.ts +20 -0
- package/dist/useDocumentSave.js +33 -0
- package/package.json +6 -5
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-editor/SKILL.md +12 -2
- package/skills/jgengine-editor/api.md +120 -16
- package/skills/jgengine-editor/capabilities.md +40 -0
- package/skills/jgengine-editor/reference.md +161 -3
- package/skills/jgengine-editor/references/live-sync.md +15 -0
- package/skills/level-design/SKILL.md +1 -1
package/dist/EditorChrome.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { editorDocumentSize, editorParentOf, extractEditorFragment, } from "@jgengine/core/editor/index";
|
|
4
4
|
import { scatterRegionEstimate, SCATTER_PATH_KIND } from "@jgengine/core/world/scatterRegion";
|
|
5
5
|
import { editorAssetFromImport, mergeEditorAssets, } from "./AssetBrowser.js";
|
|
@@ -7,6 +7,9 @@ import { CatalogsPanel } from "./CatalogsPanel.js";
|
|
|
7
7
|
import { CollectionsPanel } from "./CollectionsPanel.js";
|
|
8
8
|
import { EditorContextMenu } from "./EditorContextMenu.js";
|
|
9
9
|
import { ParentPickerMenu } from "./ParentPickerMenu.js";
|
|
10
|
+
import { modelWithAuthoredMaterials } from "@jgengine/core/editor/materialAuthoring";
|
|
11
|
+
import { modelWithAuthoredNodeVisibility } from "@jgengine/core/editor/modelNodeVisibility";
|
|
12
|
+
import { MaterialPreview } from "./MaterialPreview.js";
|
|
10
13
|
import { MaterialsWorkspacePanel } from "./MaterialsWorkspacePanel.js";
|
|
11
14
|
import { PrefabsPanel } from "./PrefabsPanel.js";
|
|
12
15
|
import { listParentCandidates } from "./parentCandidates.js";
|
|
@@ -37,6 +40,7 @@ import { createPerfHistoryStore } from "./shell/perfHistory.js";
|
|
|
37
40
|
import { BORDER, FOCUS_RING } from "./shell/theme.js";
|
|
38
41
|
import { IconButton, Kbd, PanelResizer } from "./shell/ui.js";
|
|
39
42
|
import { ScriptingPanel } from "./ScriptingPanel.js";
|
|
43
|
+
import { EditorDocumentSaveContext, useDocumentSave } from "./useDocumentSave.js";
|
|
40
44
|
let clipboardFragment = null;
|
|
41
45
|
const SHORTCUTS = [
|
|
42
46
|
{ keys: "W / E / R", action: "Move · rotate · scale gizmo" },
|
|
@@ -72,47 +76,21 @@ function downloadText(filename, text) {
|
|
|
72
76
|
anchor.click();
|
|
73
77
|
URL.revokeObjectURL(url);
|
|
74
78
|
}
|
|
75
|
-
function useDocumentSave(session, save, onResult) {
|
|
76
|
-
const [saveState, setSaveState] = useState("idle");
|
|
77
|
-
const [saveError, setSaveError] = useState(null);
|
|
78
|
-
const [lastSavedAt, setLastSavedAt] = useState(null);
|
|
79
|
-
const savedDocRef = useRef(session.getState().document);
|
|
80
|
-
const dirty = session.getState().document !== savedDocRef.current;
|
|
81
|
-
const doSave = () => {
|
|
82
|
-
if (save === undefined || saveState === "saving")
|
|
83
|
-
return;
|
|
84
|
-
const document = session.getState().document;
|
|
85
|
-
setSaveState("saving");
|
|
86
|
-
void save(session.exportJson(true)).then((result) => {
|
|
87
|
-
if (result.ok) {
|
|
88
|
-
savedDocRef.current = document;
|
|
89
|
-
setSaveError(null);
|
|
90
|
-
setSaveState("saved");
|
|
91
|
-
setLastSavedAt(Date.now());
|
|
92
|
-
onResult?.(true, result.path === undefined ? "Scene saved" : `Scene saved to ${result.path}`);
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
setSaveError(result.error ?? "save failed");
|
|
96
|
-
setSaveState("error");
|
|
97
|
-
onResult?.(false, `Save failed: ${result.error ?? "unknown error"}`);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
};
|
|
101
|
-
return { available: save !== undefined, dirty, saveState, saveError, lastSavedAt, doSave };
|
|
102
|
-
}
|
|
103
79
|
const LEFT_PAGES = [
|
|
104
80
|
{ id: "hierarchy", label: "Hierarchy" },
|
|
105
81
|
{ id: "collections", label: "Sets" },
|
|
106
82
|
{ id: "prefabs", label: "Prefabs" },
|
|
107
83
|
{ id: "catalogs", label: "Data" },
|
|
108
84
|
];
|
|
85
|
+
/** The Animation workspace needs room for a graph's layers, so it raises a shorter dock to this height. */
|
|
86
|
+
const ANIMATION_DOCK_HEIGHT = 400;
|
|
109
87
|
/**
|
|
110
88
|
* The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable
|
|
111
89
|
* hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI
|
|
112
90
|
* assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
|
|
113
91
|
* and host RPC. Mounted by `EditorApp`; not a game-author entry point.
|
|
114
92
|
*/
|
|
115
|
-
export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
|
|
93
|
+
export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, allowedKinds, exitLabel, maxImportBytes, documentSave, draftAutosave = true, creatorPolicy, }) {
|
|
116
94
|
const layoutRef = useRef(null);
|
|
117
95
|
layoutRef.current ??= createShellLayoutStore(gameId);
|
|
118
96
|
const layout = layoutRef.current;
|
|
@@ -126,6 +104,8 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
126
104
|
/** Models added this session via Content Browser Import / drop — merged over the game catalog. */
|
|
127
105
|
const [importedAssets, setImportedAssets] = useState([]);
|
|
128
106
|
const [importBusy, setImportBusy] = useState(false);
|
|
107
|
+
const [materialInventory, setMaterialInventory] = useState(null);
|
|
108
|
+
const [materialPreviewFailure, setMaterialPreviewFailure] = useState(null);
|
|
129
109
|
// Bridge global RPC/agent console emits into the dock console for this chrome instance.
|
|
130
110
|
useEffect(() => installEditorConsoleSink((severity, source, message) => {
|
|
131
111
|
consoleStore.log(severity, source, message);
|
|
@@ -154,7 +134,44 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
154
134
|
const state = session.getState();
|
|
155
135
|
const uiState = ui.getState();
|
|
156
136
|
const layoutState = layout.getState();
|
|
157
|
-
const
|
|
137
|
+
const materialMarker = state.document.markers.find((item) => state.selection.includes(item.id));
|
|
138
|
+
const materialSourceUrl = liveAssets.find((item) => item.id === (materialMarker?.catalogId ?? materialMarker?.meta?.catalogId))?.url;
|
|
139
|
+
const materialPreviewConfig = useMemo(() => {
|
|
140
|
+
try {
|
|
141
|
+
return { model: materialSourceUrl && materialMarker ? modelWithAuthoredNodeVisibility(modelWithAuthoredMaterials({ url: materialSourceUrl, targetHeight: 1.8, animation: "none" }, state.document, materialMarker.id), materialMarker) : undefined, error: null };
|
|
142
|
+
}
|
|
143
|
+
catch (failure) {
|
|
144
|
+
return { model: undefined, error: failure instanceof Error ? failure.message : String(failure) };
|
|
145
|
+
}
|
|
146
|
+
}, [materialSourceUrl, materialMarker, state.document]);
|
|
147
|
+
const materialSourceKey = JSON.stringify([materialMarker?.id, materialSourceUrl, materialPreviewConfig]);
|
|
148
|
+
const materialMarkerId = materialMarker?.id;
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (layoutState.workspace !== "materials" || !materialMarkerId || !materialSourceUrl)
|
|
151
|
+
return;
|
|
152
|
+
if (api.getMaterialSlots(materialMarkerId).status === "ready")
|
|
153
|
+
return;
|
|
154
|
+
api.reportMaterialSlots(materialMarkerId, materialSourceUrl, materialPreviewConfig.error
|
|
155
|
+
? { status: "unavailable", reason: materialPreviewConfig.error }
|
|
156
|
+
: { status: "loading" });
|
|
157
|
+
return () => {
|
|
158
|
+
const current = api.getMaterialSlots(materialMarkerId);
|
|
159
|
+
if (current.sourceUrl === materialSourceUrl && current.status === "loading")
|
|
160
|
+
api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "unavailable", reason: "The model preview closed before slot inspection completed." });
|
|
161
|
+
};
|
|
162
|
+
}, [api, materialMarkerId, materialSourceUrl, layoutState.workspace, materialPreviewConfig.error]);
|
|
163
|
+
const reportMaterialSlots = useCallback((slots) => {
|
|
164
|
+
if (materialMarkerId && materialSourceUrl && api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "ready", slots }))
|
|
165
|
+
setMaterialInventory({ source: materialSourceKey, slots });
|
|
166
|
+
}, [api, materialMarkerId, materialSourceUrl, materialSourceKey]);
|
|
167
|
+
const reportMaterialError = useCallback((message) => {
|
|
168
|
+
setMaterialPreviewFailure({ source: materialSourceKey, message });
|
|
169
|
+
if (message && materialMarkerId && materialSourceUrl && api.getMaterialSlots(materialMarkerId).status !== "ready")
|
|
170
|
+
api.reportMaterialSlots(materialMarkerId, materialSourceUrl, { status: "unavailable", reason: message });
|
|
171
|
+
}, [api, materialMarkerId, materialSourceUrl, materialSourceKey]);
|
|
172
|
+
const sharedSave = useContext(EditorDocumentSaveContext) ?? documentSave;
|
|
173
|
+
const localSave = useDocumentSave(session, sharedSave === undefined ? save : undefined, (ok, detail) => consoleStore.log(ok ? "info" : "error", "save", detail));
|
|
174
|
+
const docSave = sharedSave ?? localSave;
|
|
158
175
|
const docSaveRef = useRef(docSave);
|
|
159
176
|
docSaveRef.current = docSave;
|
|
160
177
|
const outlinerGroups = useMemo(() => buildOutlinerGroups(state.document), [state.document]);
|
|
@@ -575,6 +592,10 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
575
592
|
}
|
|
576
593
|
}, [importAsset, importBusy, layout, notify, onRegisterAsset]);
|
|
577
594
|
const importFile = (file) => {
|
|
595
|
+
if (maxImportBytes !== undefined && file.size > maxImportBytes) {
|
|
596
|
+
notify("Import failed: scene exceeds byte budget", "error");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
578
599
|
void file.text().then((text) => {
|
|
579
600
|
try {
|
|
580
601
|
session.dispatch({ type: "importJson", json: text });
|
|
@@ -612,6 +633,11 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
612
633
|
};
|
|
613
634
|
const selectWorkspace = (workspace) => {
|
|
614
635
|
layout.setWorkspace(workspace);
|
|
636
|
+
if (workspace === "animation") {
|
|
637
|
+
openBottomTab("animation");
|
|
638
|
+
if (layout.getState().bottomHeight < ANIMATION_DOCK_HEIGHT)
|
|
639
|
+
layout.patch({ bottomHeight: ANIMATION_DOCK_HEIGHT });
|
|
640
|
+
}
|
|
615
641
|
if (workspace === "terrain")
|
|
616
642
|
ui.setTool("terrain");
|
|
617
643
|
else if (ui.getState().tool === "terrain")
|
|
@@ -674,12 +700,12 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
674
700
|
const railActive = uiState.tool === "terrain" ? "terrain" : layoutState.workspace;
|
|
675
701
|
// Only advertise the exit chord when there is a game to return to.
|
|
676
702
|
const shortcuts = onExitEditor !== undefined ? [...SHORTCUTS, { keys: "F2+Q", action: "Exit editor to game" }] : SHORTCUTS;
|
|
677
|
-
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { gameId: gameId, onExitToGame: onExitEditor, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
|
|
703
|
+
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { exitLabel: exitLabel, gameId: gameId, onExitToGame: onExitEditor, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
|
|
678
704
|
const file = event.target.files?.[0];
|
|
679
705
|
if (file !== undefined)
|
|
680
706
|
importFile(file);
|
|
681
707
|
event.target.value = "";
|
|
682
|
-
} }), _jsx(SceneToolbar, { tool: uiState.tool, gizmoMode: uiState.gizmoMode, gizmoSpace: uiState.gizmoSpace, gizmoPivot: uiState.gizmoPivot, snapMode: uiState.snapMode, gridSize: uiState.gridSize, rotationSnapDeg: uiState.rotationSnapDeg, scaleSnap: uiState.scaleSnap, cameraProjection: uiState.cameraProjection, showGrid: uiState.showGrid, showContours: uiState.showContours, showSurfaceGrid: uiState.showSurfaceGrid, showElevation: uiState.showElevation, placementActive: placement !== null, onSetTool: (tool) => ui.setTool(tool), onSetGizmoMode: (mode) => ui.patch({ gizmoMode: mode }), onSetGizmoSpace: (space) => ui.patch({ gizmoSpace: space }), onSetGizmoPivot: (pivot) => ui.patch({ gizmoPivot: pivot }), onSetSnapMode: (mode) => ui.patch({ snapMode: mode }), onSetGridSize: (size) => ui.patch({ gridSize: size }), onSetRotationSnapDeg: (deg) => ui.patch({ rotationSnapDeg: deg }), onSetScaleSnap: (snap) => ui.patch({ scaleSnap: snap }), onSetCameraProjection: (projection) => ui.patch({ cameraProjection: projection }), onToggleGrid: () => ui.patch({ showGrid: !uiState.showGrid }), onToggleContours: () => ui.patch({ showContours: !uiState.showContours }), onToggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), onToggleElevation: () => ui.patch({ showElevation: !uiState.showElevation }), onFrame: () => {
|
|
708
|
+
} }), _jsx(SceneToolbar, { allowedKinds: allowedKinds, tool: uiState.tool, gizmoMode: uiState.gizmoMode, gizmoSpace: uiState.gizmoSpace, gizmoPivot: uiState.gizmoPivot, snapMode: uiState.snapMode, gridSize: uiState.gridSize, rotationSnapDeg: uiState.rotationSnapDeg, scaleSnap: uiState.scaleSnap, cameraProjection: uiState.cameraProjection, showGrid: uiState.showGrid, showContours: uiState.showContours, showSurfaceGrid: uiState.showSurfaceGrid, showElevation: uiState.showElevation, placementActive: placement !== null, onSetTool: (tool) => ui.setTool(tool), onSetGizmoMode: (mode) => ui.patch({ gizmoMode: mode }), onSetGizmoSpace: (space) => ui.patch({ gizmoSpace: space }), onSetGizmoPivot: (pivot) => ui.patch({ gizmoPivot: pivot }), onSetSnapMode: (mode) => ui.patch({ snapMode: mode }), onSetGridSize: (size) => ui.patch({ gridSize: size }), onSetRotationSnapDeg: (deg) => ui.patch({ rotationSnapDeg: deg }), onSetScaleSnap: (snap) => ui.patch({ scaleSnap: snap }), onSetCameraProjection: (projection) => ui.patch({ cameraProjection: projection }), onToggleGrid: () => ui.patch({ showGrid: !uiState.showGrid }), onToggleContours: () => ui.patch({ showContours: !uiState.showContours }), onToggleSurfaceGrid: () => ui.patch({ showSurfaceGrid: !uiState.showSurfaceGrid }), onToggleElevation: () => ui.patch({ showElevation: !uiState.showElevation }), onFrame: () => {
|
|
683
709
|
const selected = session.getState().selection[0];
|
|
684
710
|
if (selected !== undefined)
|
|
685
711
|
api.handle({ method: "camera_goto", id: selected });
|
|
@@ -701,7 +727,7 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
701
727
|
: null;
|
|
702
728
|
const selected = layoutState.leftPage === page.id;
|
|
703
729
|
return (_jsxs("button", { type: "button", role: "tab", "aria-selected": selected, onClick: () => layout.patch({ leftPage: page.id }), className: `flex h-6.5 items-center gap-1 rounded-[5px] px-2 text-[11px] transition-colors ${FOCUS_RING} ${selected ? "bg-white/[0.08] text-neutral-100" : "text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300"}`, children: [page.label, badge !== null && badge > 0 ? _jsx("span", { className: "text-[9px] tabular-nums text-neutral-500", children: badge }) : null] }, page.id));
|
|
704
|
-
})), _jsx(IconButton, { icon: "close", label: "Collapse left panel", size: 11, tone: "ghost", className: "ml-auto", onClick: () => layout.patch({ leftOpen: false }) })] }), layoutState.workspace === "multiplayer" ? (_jsx(NetworkWorkspacePanel, { snapshot: networkSnapshot })) : layoutState.workspace === "materials" ? (_jsx(MaterialsWorkspacePanel, { session: session, api: api })) : layoutState.workspace === "scripting" ? (_jsx(ScriptingPanel, { session: session, api: api })) : layoutState.leftPage === "collections" ? (_jsx(CollectionsPanel, { session: session })) : layoutState.leftPage === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : layoutState.leftPage === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsx(HierarchyPanel, { session: session, api: api, onAdd: () => setPaletteQuery("add "), onRowContextMenu: (point, id) => {
|
|
730
|
+
})), _jsx(IconButton, { icon: "close", label: "Collapse left panel", size: 11, tone: "ghost", className: "ml-auto", onClick: () => layout.patch({ leftOpen: false }) })] }), layoutState.workspace === "multiplayer" ? (_jsx(NetworkWorkspacePanel, { snapshot: networkSnapshot })) : layoutState.workspace === "materials" ? (_jsx(MaterialsWorkspacePanel, { session: session, api: api, materialSlots: materialInventory?.source === materialSourceKey ? materialInventory.slots : [], previewError: materialPreviewConfig.error ?? (materialPreviewFailure?.source === materialSourceKey ? materialPreviewFailure.message : null), onSave: docSave.available ? docSave.doSave : undefined, preview: (asset, mode) => materialPreviewConfig.error || (!materialPreviewConfig.model && !asset) ? null : _jsx(MaterialPreview, { asset: asset, mode: mode, model: materialPreviewConfig.model, onSlots: reportMaterialSlots, onError: reportMaterialError, environment: state.document.environment }) })) : layoutState.workspace === "scripting" ? (_jsx(ScriptingPanel, { session: session, api: api })) : layoutState.leftPage === "collections" ? (_jsx(CollectionsPanel, { session: session })) : layoutState.leftPage === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : layoutState.leftPage === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsx(HierarchyPanel, { session: session, api: api, onAdd: () => setPaletteQuery("add "), onRowContextMenu: (point, id) => {
|
|
705
731
|
const current = session.getState().selection;
|
|
706
732
|
if (!current.includes(id))
|
|
707
733
|
session.dispatch({ type: "select", ids: [id] });
|
|
@@ -749,5 +775,5 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
749
775
|
["profiler", "Profiler"],
|
|
750
776
|
["animation", "Animation"],
|
|
751
777
|
["assistant", "AI Assistant"],
|
|
752
|
-
].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave:
|
|
778
|
+
].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { creatorPolicy: creatorPolicy, session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: draftAutosave }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
|
|
753
779
|
}
|
package/dist/InspectorPanel.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CreatorPolicy } from "@jgengine/core/editor/creatorStorage";
|
|
1
2
|
import { type EditorSession } from "@jgengine/core/editor/index";
|
|
2
3
|
import { type EditorAssetEntry } from "./AssetBrowser.js";
|
|
3
4
|
import type { EditorHostApi } from "./session.js";
|
|
@@ -10,9 +11,10 @@ import type { InspectorTab } from "./shell/layoutStore.js";
|
|
|
10
11
|
* `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
|
|
11
12
|
* @internal — mounted by `EditorChrome` inside the right dock.
|
|
12
13
|
*/
|
|
13
|
-
export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }: {
|
|
14
|
+
export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, creatorPolicy, }: {
|
|
14
15
|
session: EditorSession;
|
|
15
16
|
ui: EditorUiStore;
|
|
17
|
+
creatorPolicy?: CreatorPolicy;
|
|
16
18
|
onClose?: () => void;
|
|
17
19
|
/** Host API for RPC-backed actions (material assignment, camera). */
|
|
18
20
|
api?: EditorHostApi;
|
package/dist/InspectorPanel.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { SimulationInspector } from "./SimulationInspector.js";
|
|
2
3
|
import { useState } from "react";
|
|
3
4
|
import { editorParentOf, findEditorNote, findEditorPath, isEditorObjectLocked, } from "@jgengine/core/editor/index";
|
|
4
5
|
import { readVegetationSettings, vegetationFootprint } from "@jgengine/core/world/vegetation";
|
|
@@ -11,7 +12,7 @@ import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatc
|
|
|
11
12
|
import { SchemaInspector } from "./SchemaInspector.js";
|
|
12
13
|
import { TriggerInspector } from "./TriggerInspector.js";
|
|
13
14
|
import { describeClipRoles } from "./AssetBrowser.js";
|
|
14
|
-
import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS,
|
|
15
|
+
import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS, readAnimationSettingResult, setAnimationMode, setLocomotionClip, setLocomotionNumber, setOneShotClip, setPlaybackBoolean, setPlaybackClip, setPlaybackNumber, setPlaybackClock, setAutomaticRoles, } from "./modelAnimationAuthoring.js";
|
|
15
16
|
import { listParentCandidates } from "./parentCandidates.js";
|
|
16
17
|
import { TERRAIN_MATERIALS } from "./uiStore.js";
|
|
17
18
|
import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
|
|
@@ -127,23 +128,25 @@ function ComponentAddBar({ target, meta, onMeta, api, selectionIds, }) {
|
|
|
127
128
|
}
|
|
128
129
|
/** A labeled clip dropdown (never free-text) for a role/event; "— none —" clears the binding. */
|
|
129
130
|
function ClipSelectRow({ label, clips, value, onChange, }) {
|
|
130
|
-
return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
|
|
131
|
+
return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), value !== null && !clips.includes(value) ? _jsxs("option", { value: value, children: [value, " (missing)"] }) : null, clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
|
|
131
132
|
}
|
|
132
133
|
const ANIMATION_MODE_HINT = {
|
|
133
134
|
default: "Inherits the catalog default: a rigged asset auto-animates from its clip roles.",
|
|
134
135
|
auto: "Forces derivation of idle/walk/run + hit/death/attack from this rig's clip names.",
|
|
135
136
|
none: "Renders the bind pose — no clip playback.",
|
|
136
|
-
custom: "Author explicit role
|
|
137
|
+
custom: "Author explicit playback or role bindings below; states and graphs take priority over a single clip.",
|
|
137
138
|
};
|
|
138
139
|
/**
|
|
139
140
|
* Inspector section authoring a placement's `ModelConfig.animation` for a rigged catalog asset.
|
|
140
141
|
* Persists to `marker.meta.animation` through the passed `onChange` (an undoable `setMarker` dispatch).
|
|
141
142
|
* All clip picks are dropdowns over the asset's real clip list — never free-text.
|
|
142
143
|
*/
|
|
143
|
-
function ModelAnimationSection({ clips, setting, onChange, }) {
|
|
144
|
+
function ModelAnimationSection({ clips, setting, diagnostics, onChange, }) {
|
|
144
145
|
const mode = animationMode(setting);
|
|
145
146
|
const config = mode === "custom" && typeof setting === "object" ? setting : null;
|
|
146
|
-
|
|
147
|
+
if (diagnostics.length > 0)
|
|
148
|
+
return (_jsxs("div", { className: "space-y-2 text-[10px]", children: [_jsxs("div", { role: "alert", "aria-label": "Animation diagnostics", className: "space-y-1 text-amber-200", children: [_jsx("p", { children: "The saved animation override is invalid. Playback edits are unavailable until it is repaired or replaced." }), diagnostics.map((entry) => _jsxs("div", { children: [_jsxs("div", { className: "break-all", children: [entry.path, ": ", entry.message] }), _jsx("div", { children: entry.repair })] }, `${entry.path}:${entry.message}`))] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "Replace the invalid animation override" }), _jsxs("select", { className: `h-6.5 w-full px-1.5 ${INPUT_CLS}`, value: "", "aria-label": "Replace invalid animation override", onChange: (event) => onChange(setAnimationMode(undefined, event.target.value, clips)), children: [_jsx("option", { value: "", disabled: true, children: "Choose an explicit replacement" }), _jsx("option", { value: "default", children: "Remove override (inherit catalog)" }), _jsx("option", { value: "auto", children: "Replace with Auto" }), _jsx("option", { value: "none", children: "Replace with None (bind pose)" }), _jsx("option", { value: "custom", disabled: clips.length === 0, children: "Replace with Custom (derive from this rig)" })] })] }), _jsx("p", { className: "text-neutral-500", children: "Replacement discards the invalid override and can be undone. Raw metadata remains available below." })] }));
|
|
149
|
+
return (_jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "text-[10px] text-neutral-500", children: [clips.length, " clips \u00B7 ", describeClipRoles(clips)] }), _jsx(FieldRow, { label: "Mode", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: mode, "aria-label": "Animation mode", onChange: (event) => onChange(setAnimationMode(setting, event.target.value, clips)), children: [_jsx("option", { value: "default", children: "Default (catalog)" }), _jsx("option", { value: "auto", children: "Auto (derive)" }), _jsx("option", { value: "none", children: "None (bind pose)" }), _jsx("option", { value: "custom", children: "Custom" })] }) }), config === null ? (_jsx("div", { className: "text-[10px] leading-relaxed text-neutral-600", children: ANIMATION_MODE_HINT[mode] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Playback" }), _jsx(FieldRow, { label: "Clock", title: "Real time preserves playback speed; game time follows this character's simulation speed and time scale. Unbound previews use real time.", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, "aria-label": "Animation clock", value: config.clock ?? "real", onChange: (event) => onChange(setPlaybackClock(setting, event.target.value)), children: [_jsx("option", { value: "real", children: "Real time (default)" }), _jsx("option", { value: "game", children: "Game time" })] }) }), _jsx(FieldRow, { label: "Derive roles", title: "Infer roles from the loaded rig. Explicit mappings and graphs replace inferred fields whole.", children: _jsx("input", { type: "checkbox", "aria-label": "Derive loaded clip roles", checked: config.auto === true, onChange: (event) => onChange(setAutomaticRoles(setting, event.target.checked)) }) }), _jsx(ClipSelectRow, { label: "Single clip", clips: clips, value: config.clip ?? null, onChange: (clip) => onChange(setPlaybackClip(setting, clip)) }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "Picking a single clip replaces derived roles, locomotion states and a stored graph." }), _jsx(FieldRow, { label: "Rate", title: "Playback multiplier; zero holds playback.", children: _jsx(AxisNumberField, { label: "rate", step: 0.1, value: config.timeScale ?? 1, onCommit: (value) => onChange(setPlaybackNumber(setting, "timeScale", value)) }) }), _jsx(FieldRow, { label: "Hold pose", title: "Stops playback at the current pose. Single clips also support an authored seek time.", children: _jsx("input", { type: "checkbox", "aria-label": "Hold animation pose", checked: config.paused === true, onChange: (event) => onChange(setPlaybackBoolean(setting, "paused", event.target.checked)) }) }), config.states === undefined && config.graph === undefined ? (_jsxs(_Fragment, { children: [_jsx(FieldRow, { label: "Time", title: "Start or hold the selected clip at this time in seconds.", children: _jsx(AxisNumberField, { label: "time", step: 0.05, value: config.time ?? 0, onCommit: (value) => onChange(setPlaybackNumber(setting, "time", value)) }) }), _jsx(FieldRow, { label: "Loop", children: _jsx("input", { type: "checkbox", "aria-label": "Loop animation clip", checked: config.loop !== false, onChange: (event) => onChange(setPlaybackBoolean(setting, "loop", event.target.checked)) }) })] })) : null] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Locomotion states" }), config.graph === undefined && config.states !== undefined && (config.states.idle === undefined || config.states.idle.trim().length === 0) ? (_jsx("div", { role: "status", className: "text-[10px] text-amber-300", children: "Choose an idle clip to enable locomotion. An explicitly selected single clip remains active until then." })) : null, config.graph === undefined && config.states?.idle !== undefined && config.states.idle.trim().length > 0 && (config.states.walk === undefined || config.states.walk.trim().length === 0) ? (_jsx("div", { role: "status", className: "text-[10px] text-amber-300", children: "Walk is unset: idle continues while moving. Pick a walk clip to animate movement." })) : null, LOCOMOTION_ROLES.map((role) => (_jsx(ClipSelectRow, { label: role, clips: clips, value: config.states?.[role] ?? null, onChange: (clip) => onChange(setLocomotionClip(setting, role, clip, clips)) }, role))), _jsx(FieldRow, { label: "walkSpeed", title: "Speed (u/s) above which the entity counts as moving (walk).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.1, value: config.states?.walkSpeed ?? 0.5, onCommit: (value) => onChange(setLocomotionNumber(setting, "walkSpeed", Math.max(0, value), clips)) }) }), _jsx(FieldRow, { label: "runSpeed", title: "Speed above which the run clip plays (when a run clip is set).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.5, value: config.states?.runSpeed ?? 6, onCommit: (value) => onChange(setLocomotionNumber(setting, "runSpeed", Math.max(0, value), clips)) }) }), _jsx(FieldRow, { label: "fadeSec", title: "Crossfade duration (s) when the locomotion state changes.", children: _jsx(AxisNumberField, { label: "s", step: 0.05, value: config.states?.fadeSec ?? 0.2, onCommit: (value) => onChange(setLocomotionNumber(setting, "fadeSec", Math.max(0, value), clips)) }) })] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "One-shot events" }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "hit / death auto-fire on damage/death; others via playEntityAnimation." }), ONE_SHOT_EVENTS.map((event) => (_jsx(FieldRow, { label: event, title: "Choose one clip or Ctrl/Cmd-click multiple variants. Clear selection to remove the binding.", children: _jsxs("select", { multiple: true, size: 3, className: `w-full min-w-0 px-1.5 ${INPUT_CLS}`, "aria-label": `${event} clip variants`, value: typeof config.oneShots?.[event] === "string" ? [config.oneShots[event]] : [...(config.oneShots?.[event] ?? [])], onChange: (change) => onChange(setOneShotClip(setting, event, Array.from(change.target.selectedOptions, (option) => option.value))), children: [clips.map((clip) => _jsx("option", { value: clip, children: clip }, clip)), (typeof config.oneShots?.[event] === "string" ? [config.oneShots[event]] : [...(config.oneShots?.[event] ?? [])]).filter((clip) => !clips.includes(clip)).map((clip) => _jsxs("option", { value: clip, children: [clip, " (missing)"] }, clip))] }) }, event)))] })] }))] }));
|
|
147
150
|
}
|
|
148
151
|
function Section({ id, title, icon, sections, children, trailing, }) {
|
|
149
152
|
return (_jsx(CollapsibleSection, { title: title, ...(icon === undefined ? {} : { icon }), open: sections.collapsed[id] !== true, onToggle: () => sections.toggle(id), ...(trailing === undefined ? {} : { trailing }), children: children }));
|
|
@@ -165,7 +168,7 @@ function MaterialsTab({ api, selection, meta, }) {
|
|
|
165
168
|
* `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
|
|
166
169
|
* @internal — mounted by `EditorChrome` inside the right dock.
|
|
167
170
|
*/
|
|
168
|
-
export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }) {
|
|
171
|
+
export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, creatorPolicy, }) {
|
|
169
172
|
const document = useStoreSelector(session, (state) => state.document);
|
|
170
173
|
const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
|
|
171
174
|
const pathPoint = useStoreSelector(ui, (state) => state.pathPoint);
|
|
@@ -240,12 +243,13 @@ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelec
|
|
|
240
243
|
const markerCatalog = markerCatalogId(marker);
|
|
241
244
|
const markerRigAsset = markerCatalog === null ? undefined : assets?.find((entry) => entry.id === markerCatalog);
|
|
242
245
|
const markerRigClips = markerRigAsset?.clips !== undefined && markerRigAsset.clips.length > 0 ? markerRigAsset.clips : null;
|
|
246
|
+
const animation = readAnimationSettingResult(document, marker.id);
|
|
243
247
|
const hasTrigger = hasAuthoredTrigger(marker.meta);
|
|
244
248
|
const hasMaterial = hasMaterialAssignment(marker.meta);
|
|
245
249
|
const hasGenerator = typeof marker.meta?.["assetId"] === "string" && getAssetGenerator(marker.meta["assetId"]) !== undefined;
|
|
246
250
|
const hasKindParams = isSceneKind(marker.kind);
|
|
247
251
|
const componentCards = (_jsxs(_Fragment, { children: [_jsx(ComponentAddBar, { target: "marker", meta: marker.meta, onMeta: onMeta, api: api, selectionIds: [marker.id] }), hasKindParams ? (_jsx(Section, { id: "kindParams", title: "Kind parameters", icon: "settings", sections: sections, children: _jsx(KindInspector, { object: markerObject(marker), meta: marker.meta, onMeta: onMeta }) })) : null, hasTrigger ? (_jsx(Section, { id: "trigger", title: "Trigger", icon: "target", sections: sections, trailing: _jsx(SectionAction, { label: "Remove trigger component", onClick: () => onMeta(clearTriggerInstallPatch(), "component:remove-trigger"), children: "remove" }), children: _jsx(TriggerInspector, { target: "marker", meta: marker.meta, onMeta: onMeta }) })) : null, hasMaterial ? (_jsx(Section, { id: "material", title: "Material", icon: "sphere", sections: sections, trailing: _jsx(SectionAction, { label: "Remove material assignment", onClick: () => onMeta(clearMaterialAssignmentPatch(), "component:remove-material"), children: "remove" }), children: _jsxs("div", { className: "text-[11px] text-neutral-300", children: ["materialId: ", _jsx("span", { className: NUMERIC, children: String(marker.meta?.["materialId"]) })] }) })) : null, hasGenerator ? (_jsx(Section, { id: "generator", title: "Generator", icon: "cube", sections: sections, children: _jsx(GeneratorInspector, { meta: marker.meta, onMeta: onMeta }) })) : null, !hasKindParams && !hasTrigger && !hasMaterial && !hasGenerator && !canAuthorTrigger("marker") ? (_jsx(EmptyState, { icon: "settings", title: "No components", description: "This marker has no optional components. Games that register trigger actions enable Add trigger here." })) : null] }));
|
|
248
|
-
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips, setting:
|
|
252
|
+
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null || typeof animation.setting === "object" || animation.diagnostics.length > 0 ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips ?? [], setting: animation.setting, diagnostics: animation.diagnostics, onChange: (next) => onMeta(animationMetaPatch(next), "animation") }) })) : null, marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
|
|
249
253
|
}
|
|
250
254
|
else if (selectedVolume !== undefined) {
|
|
251
255
|
const volume = selectedVolume;
|
|
@@ -288,7 +292,7 @@ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelec
|
|
|
288
292
|
body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: "prop", id: liveObject.instanceId, label: liveObject.catalogId, placeholder: liveObject.instanceId, locked: false }), _jsx(Section, { id: "live", title: "Live object", icon: "cube", sections: sections, children: _jsxs("div", { className: `space-y-1 text-[11px] text-neutral-400 ${NUMERIC}`, children: [_jsxs("div", { 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-600", children: "Read-only live world object \u2014 edit its source data to move it permanently." })] }) })] }));
|
|
289
293
|
}
|
|
290
294
|
else {
|
|
291
|
-
body = (_jsx(EmptyState, { icon: "cursor", title: "Nothing selected", description: "
|
|
295
|
+
body = (creatorPolicy !== undefined && !(creatorPolicy.maxSimulationParticles || creatorPolicy.maxFireCells || creatorPolicy.maxPopulation || creatorPolicy.maxForceFields) ? _jsx(EmptyState, { icon: "cursor", title: "Nothing selected", description: "Select an object to edit it, or use Add to place approved content." }) : _jsx(SimulationInspector, { session: session, creatorPolicy: creatorPolicy }));
|
|
292
296
|
}
|
|
293
297
|
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsx(PanelTabs, { ariaLabel: "Inspector tabs", active: activeTab, onSelect: selectTab, tabs: [
|
|
294
298
|
{ id: "inspector", label: "Inspector" },
|
package/dist/LightingPanel.js
CHANGED
|
@@ -13,6 +13,8 @@ const DISPLAY_DEFAULTS = {
|
|
|
13
13
|
timeOfDay: false,
|
|
14
14
|
sunIntensity: 1,
|
|
15
15
|
ambientIntensity: 0.45,
|
|
16
|
+
sunAzimuth: 0,
|
|
17
|
+
sunElevation: 68,
|
|
16
18
|
fogNear: 80,
|
|
17
19
|
fogFar: 400,
|
|
18
20
|
fogColor: "",
|
|
@@ -25,6 +27,8 @@ function readDisplay(env) {
|
|
|
25
27
|
timeOfDay: env?.timeOfDay ?? DISPLAY_DEFAULTS.timeOfDay,
|
|
26
28
|
sunIntensity: env?.sunIntensity ?? DISPLAY_DEFAULTS.sunIntensity,
|
|
27
29
|
ambientIntensity: env?.ambientIntensity ?? DISPLAY_DEFAULTS.ambientIntensity,
|
|
30
|
+
sunAzimuth: env?.sunAzimuth ?? DISPLAY_DEFAULTS.sunAzimuth,
|
|
31
|
+
sunElevation: env?.sunElevation ?? DISPLAY_DEFAULTS.sunElevation,
|
|
28
32
|
fogNear: env?.fog?.near ?? DISPLAY_DEFAULTS.fogNear,
|
|
29
33
|
fogFar: env?.fog?.far ?? DISPLAY_DEFAULTS.fogFar,
|
|
30
34
|
fogColor: env?.fog?.color ?? DISPLAY_DEFAULTS.fogColor,
|
|
@@ -70,7 +74,7 @@ export function LightingPanel({ session }) {
|
|
|
70
74
|
? { kind, sky: "#87b5e0", ground: "#3d4a38" }
|
|
71
75
|
: kind === "hdri" ? { kind, url: "" } : { kind, urls: ["", "", "", "", "", ""] };
|
|
72
76
|
patch({ source: nextSource }, "env:source");
|
|
73
|
-
}, children: [_jsx("option", { value: "gradient", children: "Gradient" }), _jsx("option", { value: "hdri", children: "HDRI / EXR" }), _jsx("option", { value: "cube", children: "Cube map" })] }), source?.kind === "hdri" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: "https://\u2026/environment.hdr", value: source.url, onChange: (event) => patch({ source: { ...source, url: event.target.value } }, "env:sourceUrl") })) : null, source?.kind === "cube" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: " six face URLs, comma-separated", value: source.urls.join(","), onChange: (event) => patch({ source: { ...source, urls: event.target.value.split(",").slice(0, 6) } }, "env:sourceCube") })) : null] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sun & ambient" }), _jsx(SliderRow, { label: "sun", value: display.sunIntensity, min: 0, max: 3, step: 0.05, onChange: (value) => patch({ sunIntensity: value }, "env:sun"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "ambient", value: display.ambientIntensity, min: 0, max: 2, step: 0.05, onChange: (value) => patch({ ambientIntensity: value }, "env:ambient"), format: (v) => v.toFixed(2) })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Fog" }), _jsx(SliderRow, { label: "near", value: display.fogNear, min: 0, max: 500, step: 1, onChange: (value) => patch({ fog: { near: value } }, "env:fogNear"), format: (v) => `${Math.round(v)}m` }), _jsx(SliderRow, { label: "far", value: display.fogFar, min: 10, max: 2000, step: 5, onChange: (value) => patch({ fog: { far: value } }, "env:fogFar"), format: (v) => `${Math.round(v)}m` }), _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: "color" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#a8c4d8", value: display.fogColor, onChange: (event) => {
|
|
77
|
+
}, children: [_jsx("option", { value: "gradient", children: "Gradient" }), _jsx("option", { value: "hdri", children: "HDRI / EXR" }), _jsx("option", { value: "cube", children: "Cube map" })] }), source?.kind === "hdri" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: "https://\u2026/environment.hdr", value: source.url, onChange: (event) => patch({ source: { ...source, url: event.target.value } }, "env:sourceUrl") })) : null, source?.kind === "cube" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: " six face URLs, comma-separated", value: source.urls.join(","), onChange: (event) => patch({ source: { ...source, urls: event.target.value.split(",").slice(0, 6) } }, "env:sourceCube") })) : null] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sun & ambient" }), _jsx(SliderRow, { label: "sun", value: display.sunIntensity, min: 0, max: 3, step: 0.05, onChange: (value) => patch({ sunIntensity: value }, "env:sun"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "ambient", value: display.ambientIntensity, min: 0, max: 2, step: 0.05, onChange: (value) => patch({ ambientIntensity: value }, "env:ambient"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "bearing", value: display.sunAzimuth, min: 0, max: 360, step: 1, onChange: (value) => patch({ sunAzimuth: value }, "env:sunAzimuth"), format: (v) => `${Math.round(v)}°` }), _jsx(SliderRow, { label: "height", value: display.sunElevation, min: 0, max: 90, step: 1, onChange: (value) => patch({ sunElevation: value }, "env:sunElevation"), format: (v) => `${Math.round(v)}°` })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Fog" }), _jsx(SliderRow, { label: "near", value: display.fogNear, min: 0, max: 500, step: 1, onChange: (value) => patch({ fog: { near: value } }, "env:fogNear"), format: (v) => `${Math.round(v)}m` }), _jsx(SliderRow, { label: "far", value: display.fogFar, min: 10, max: 2000, step: 5, onChange: (value) => patch({ fog: { far: value } }, "env:fogFar"), format: (v) => `${Math.round(v)}m` }), _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: "color" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#a8c4d8", value: display.fogColor, onChange: (event) => {
|
|
74
78
|
const color = event.target.value.trim();
|
|
75
79
|
const fog = { ...(env?.fog ?? {}) };
|
|
76
80
|
if (color.length === 0)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type MaterialAsset } from "@jgengine/core/material/materialAsset";
|
|
3
|
+
/** A callback-driven physical material editor; the host owns persistence, undo and preview rendering. */
|
|
4
|
+
export interface MaterialAssetEditorProps {
|
|
5
|
+
asset: MaterialAsset;
|
|
6
|
+
onChange(asset: MaterialAsset, coalesce?: string): void;
|
|
7
|
+
preview?: (asset: MaterialAsset, mode: "neutral" | "game") => ReactNode;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Edits sparse reusable material assets without replacing untouched imported material values.
|
|
11
|
+
* @internal Mounted by the material workspace.
|
|
12
|
+
*/
|
|
13
|
+
export declare function MaterialAssetEditor({ asset, onChange, preview }: MaterialAssetEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { MATERIAL_TEXTURE_SEMANTICS, validateMaterialAsset, } from "@jgengine/core/material/materialAsset";
|
|
4
|
+
import { advancedMaterialControls, materialAuthoringNotes, materialControlGroups, prepareMaterialAuthoringEdit } from "./materialControls.js";
|
|
5
|
+
import { CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
|
|
6
|
+
/**
|
|
7
|
+
* Edits sparse reusable material assets without replacing untouched imported material values.
|
|
8
|
+
* @internal Mounted by the material workspace.
|
|
9
|
+
*/
|
|
10
|
+
export function MaterialAssetEditor({ asset, onChange, preview }) {
|
|
11
|
+
const [previewMode, setPreviewMode] = useState("neutral");
|
|
12
|
+
const [mapRole, setMapRole] = useState("color");
|
|
13
|
+
const [mapUrl, setMapUrl] = useState("");
|
|
14
|
+
const diagnostics = validateMaterialAsset(asset);
|
|
15
|
+
const commit = (next, coalesce) => onChange(prepareMaterialAuthoringEdit(next), coalesce);
|
|
16
|
+
const patchSurface = (patch, field) => {
|
|
17
|
+
const surface = { ...asset.surface, ...patch };
|
|
18
|
+
for (const key of Object.keys(surface)) {
|
|
19
|
+
if (surface[key] === undefined)
|
|
20
|
+
delete surface[key];
|
|
21
|
+
}
|
|
22
|
+
commit({ ...asset, surface }, `material:${asset.id}:${field}`);
|
|
23
|
+
};
|
|
24
|
+
const patchTexture = (role, texture) => {
|
|
25
|
+
const textures = { ...asset.textures };
|
|
26
|
+
if (texture === undefined)
|
|
27
|
+
delete textures[role];
|
|
28
|
+
else
|
|
29
|
+
textures[role] = texture;
|
|
30
|
+
commit({ ...asset, textures }, `material:${asset.id}:texture:${role}`);
|
|
31
|
+
};
|
|
32
|
+
const renderControl = (control) => (_jsx(SurfaceField, { control: control, value: asset.surface[control.key], onChange: (value) => patchSurface({ [control.key]: value }, control.key) }, control.key));
|
|
33
|
+
return (_jsxs("div", { className: "space-y-3 p-2 text-[11px] text-neutral-300", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Asset name" }), _jsx("input", { "aria-label": "Material asset name", className: `w-full px-2 py-1 ${INPUT_CLS}`, defaultValue: asset.name, onBlur: (event) => { const name = event.target.value.trim(); if (name && name !== asset.name)
|
|
34
|
+
commit({ ...asset, name }); } }, `${asset.id}:${asset.name}`)] }), _jsxs("div", { className: "break-all text-[10px] text-neutral-500", children: ["Stable ID: ", asset.id, " \u00B7 saved with the scene document"] }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Surface family" }), _jsx("select", { "aria-label": "Material family", className: `px-2 py-1 ${INPUT_CLS}`, value: asset.family, onChange: (event) => commit({ ...asset, family: event.target.value }), children: ["standard", "fabric", "hair", "glass", "metal", "skin", "stone", "plastic"].map((family) => _jsx("option", { value: family, children: family }, family)) })] }), _jsx("p", { className: "text-[10px] leading-relaxed text-neutral-500", children: "Empty fields inherit the imported surface. A displayed placeholder is the renderer default, which may differ from your asset. Reset removes only this override." }), preview ? _jsxs("section", { className: "space-y-2", "aria-label": "Material preview", children: [_jsx("div", { className: "flex gap-1", role: "group", "aria-label": "Preview lighting", children: ["neutral", "game"].map((mode) => _jsx("button", { type: "button", "aria-pressed": previewMode === mode, className: `${CONTROL} ${FOCUS_RING} flex-1 px-2 py-1`, onClick: () => setPreviewMode(mode), children: mode === "neutral" ? "Neutral light" : "Document light" }, mode)) }), preview(asset, previewMode)] }) : null, materialControlGroups(asset.family).map((group) => _jsxs("section", { className: "space-y-2 border-t border-white/[0.07] pt-2", children: [_jsx("div", { className: MICRO_LABEL, children: group.label }), _jsx("p", { className: "text-[10px] leading-relaxed text-neutral-500", children: group.hint }), group.controls.map(renderControl), group.label === "Coverage" ? _jsx(CoverageFields, { surface: asset.surface, onPatch: patchSurface }) : null, asset.family === "fabric" && group.label === "Detail" ? _jsx(FabricFields, { asset: asset, onChange: commit }) : null, asset.family === "hair" && group.label === "Strand highlights" ? _jsx(HairFields, { asset: asset, onChange: commit }) : null, asset.family === "hair" && group.label === "Backlighting" ? _jsx(HairBacklightFields, { asset: asset, onChange: commit }) : null] }, group.label)), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsxs("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: ["Texture maps (", Object.keys(asset.textures ?? {}).length, ")"] }), _jsxs("div", { className: "space-y-2 pt-2", children: [_jsx("p", { className: "text-[10px] text-neutral-500", children: "Use an existing runtime URL. Color and emissive use sRGB; numeric maps use linear data and the shader's packed channel." }), _jsxs("label", { className: "block", children: [_jsx("span", { className: MICRO_LABEL, children: "Map role" }), _jsx("select", { "aria-label": "New texture role", className: `mt-1 w-full px-2 py-1 ${INPUT_CLS}`, value: mapRole, onChange: (event) => setMapRole(event.target.value), children: Object.keys(MATERIAL_TEXTURE_SEMANTICS).map((role) => _jsx("option", { value: role, children: role }, role)) })] }), _jsx("input", { "aria-label": "New texture URL", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: mapUrl, onChange: (event) => setMapUrl(event.target.value), placeholder: "/textures/my-surface.png" }), _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1`, disabled: !mapUrl.trim(), onClick: () => { if (!mapUrl.trim())
|
|
35
|
+
return; patchTexture(mapRole, { url: mapUrl.trim(), ...MATERIAL_TEXTURE_SEMANTICS[mapRole] }); setMapUrl(""); }, children: "Assign map" }), Object.entries(asset.textures ?? {}).map(([role, texture]) => _jsx(TextureFields, { role: role, texture: texture, onChange: (next) => patchTexture(role, next) }, role))] })] }), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsx("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: "Advanced physical settings" }), _jsxs("div", { className: "space-y-2 pt-2", children: [advancedMaterialControls.map(renderControl), _jsx(PairField, { label: "Normal strength XY", value: asset.surface.normalScale, fallback: [1, 1], onChange: (normalScale) => patchSurface({ normalScale }, "normalScale") }), _jsx(PairField, { label: "Film thickness range (nm)", value: asset.surface.iridescenceThicknessRange, fallback: [100, 400], onChange: (iridescenceThicknessRange) => patchSurface({ iridescenceThicknessRange }, "iridescenceThicknessRange") }), _jsx(CoverageFields, { surface: asset.surface, onPatch: patchSurface }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["Declared capabilities: ", asset.capabilities.join(", ") || "none", ". Expensive lobes render only when enabled."] })] })] }), _jsxs("details", { className: "border-t border-white/[0.07] pt-2", children: [_jsx("summary", { className: `cursor-pointer py-1 ${FOCUS_RING}`, children: "Authorship and provenance" }), _jsx("div", { className: "space-y-2 pt-2", children: _jsx(ProvenanceFields, { label: "Material", provenance: asset.provenance, onChange: (provenance) => commit({ ...asset, provenance }) }, JSON.stringify(asset.provenance)) })] }), materialAuthoringNotes(asset).map((note) => _jsx("p", { className: "text-[10px] leading-relaxed text-amber-200/80", children: note }, note)), diagnostics.length ? _jsx("ul", { className: "space-y-1 text-[10px]", "aria-label": "Material diagnostics", children: diagnostics.map((entry) => _jsxs("li", { className: entry.severity === "error" ? "text-rose-300" : "text-amber-200/80", children: [entry.path, ": ", entry.message] }, `${entry.code}:${entry.path}`)) }) : null] }));
|
|
36
|
+
}
|
|
37
|
+
function SurfaceField({ control, value, onChange }) {
|
|
38
|
+
return _jsxs("label", { className: "block space-y-1", title: control.hint, children: [_jsxs("span", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: control.label }), value === undefined ? _jsx("span", { className: "text-[9px] text-neutral-600", children: "Inherited" }) : _jsx("button", { type: "button", className: `${FOCUS_RING} text-[9px] text-neutral-400 hover:text-neutral-100`, onClick: () => onChange(undefined), children: "Reset" })] }), control.kind === "color" ? _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { "aria-label": control.label, type: "color", value: typeof value === "string" ? value : String(control.fallback), onChange: (event) => onChange(event.target.value) }), _jsx("span", { className: "text-[10px] text-neutral-500", children: typeof value === "string" ? value : "Imported color" })] }) : _jsx("input", { "aria-label": control.label, type: "number", min: control.min, max: control.max, step: control.step ?? 0.01, value: typeof value === "number" ? value : "", placeholder: String(control.fallback), className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { if (!event.target.value)
|
|
39
|
+
onChange(undefined);
|
|
40
|
+
else {
|
|
41
|
+
const next = Number(event.target.value);
|
|
42
|
+
if (Number.isFinite(next))
|
|
43
|
+
onChange(next);
|
|
44
|
+
} } })] });
|
|
45
|
+
}
|
|
46
|
+
function CoverageFields({ surface, onPatch }) {
|
|
47
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Alpha behavior" }), _jsxs("select", { "aria-label": "Alpha behavior", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: surface.alphaMode ?? "", onChange: (event) => onPatch({ alphaMode: (event.target.value || undefined) }, "alphaMode"), children: [_jsx("option", { value: "", children: "Imported behavior" }), _jsx("option", { value: "opaque", children: "Opaque" }), _jsx("option", { value: "mask", children: "Mask / cutout" }), _jsx("option", { value: "blend", children: "Alpha blend" })] })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Surface sides" }), _jsxs("select", { "aria-label": "Surface sides", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: surface.doubleSided === undefined ? "" : String(surface.doubleSided), onChange: (event) => onPatch({ doubleSided: event.target.value === "" ? undefined : event.target.value === "true" }, "doubleSided"), children: [_jsx("option", { value: "", children: "Imported sides" }), _jsx("option", { value: "false", children: "Front face" }), _jsx("option", { value: "true", children: "Both sides" })] })] })] });
|
|
48
|
+
}
|
|
49
|
+
function PairField({ label, value, fallback, onChange }) {
|
|
50
|
+
return _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: label }), value ? _jsx("button", { type: "button", className: `${FOCUS_RING} text-[9px]`, onClick: () => onChange(undefined), children: "Reset" }) : _jsx("span", { className: "text-[9px] text-neutral-600", children: "Inherited" })] }), _jsx("div", { className: "grid grid-cols-2 gap-1", children: [0, 1].map((index) => _jsx("input", { "aria-label": `${label} ${index + 1}`, type: "number", step: "0.01", className: `min-w-0 px-2 py-1 ${INPUT_CLS}`, value: value?.[index] ?? "", placeholder: String(fallback[index]), onChange: (event) => { const next = Number(event.target.value); if (!event.target.value || !Number.isFinite(next))
|
|
51
|
+
return; const pair = [...(value ?? fallback)]; pair[index] = next; onChange(pair); } }, index)) })] });
|
|
52
|
+
}
|
|
53
|
+
function TextureFields({ role, texture, onChange }) {
|
|
54
|
+
const patch = (partial) => {
|
|
55
|
+
const next = { ...texture, ...partial };
|
|
56
|
+
if (next.transform) {
|
|
57
|
+
next.transform = { ...next.transform };
|
|
58
|
+
for (const key of Object.keys(next.transform)) {
|
|
59
|
+
if (next.transform[key] === undefined)
|
|
60
|
+
delete next.transform[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
onChange(next);
|
|
64
|
+
};
|
|
65
|
+
return _jsxs("details", { className: "rounded border border-white/[0.08] p-2", open: true, children: [_jsxs("summary", { className: `cursor-pointer ${FOCUS_RING}`, children: [role, " \u00B7 ", texture.colorSpace, " \u00B7 ", texture.channel ?? MATERIAL_TEXTURE_SEMANTICS[role].channel] }), _jsxs("div", { className: "space-y-2 pt-2", children: [_jsx("input", { "aria-label": `${role} texture URL`, className: `w-full px-2 py-1 ${INPUT_CLS}`, defaultValue: texture.url, onBlur: (event) => { if (event.target.value.trim() && event.target.value.trim() !== texture.url)
|
|
66
|
+
patch({ url: event.target.value.trim() }); } }, texture.url), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "UV set" }), _jsx("select", { "aria-label": `${role} UV set`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.uvSet ?? 0, onChange: (event) => patch({ uvSet: Number(event.target.value) }), children: [0, 1, 2, 3].map((uv) => _jsx("option", { value: uv, children: uv }, uv)) })] }), _jsx(PairField, { label: `${role} UV scale`, value: texture.transform?.scale, fallback: [1, 1], onChange: (scale) => patch({ transform: { ...texture.transform, scale } }) }), _jsx(PairField, { label: `${role} UV offset`, value: texture.transform?.offset, fallback: [0, 0], onChange: (offset) => patch({ transform: { ...texture.transform, offset } }) }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "UV rotation (radians)" }), _jsx("input", { "aria-label": `${role} UV rotation`, type: "number", step: "0.01", value: texture.transform?.rotation ?? 0, className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { const rotation = Number(event.target.value); if (Number.isFinite(rotation))
|
|
67
|
+
patch({ transform: { ...texture.transform, rotation } }); } })] }), _jsx(PairField, { label: `${role} physical size (metres)`, value: texture.physicalSize, fallback: [1, 1], onChange: (physicalSize) => patch({ physicalSize }) }), role === "normal" || role === "clearcoatNormal" ? _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Normal convention" }), _jsxs("select", { "aria-label": `${role} normal convention`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.normalConvention ?? "opengl", onChange: (event) => patch({ normalConvention: event.target.value }), children: [_jsx("option", { value: "opengl", children: "OpenGL (+Y)" }), _jsx("option", { value: "directx", children: "DirectX (-Y)" })] })] }) : null, ["wrapS", "wrapT"].map((axis) => _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: axis }), _jsxs("select", { "aria-label": `${role} ${axis}`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.[axis] ?? "repeat", onChange: (event) => patch({ sampler: { ...texture.sampler, [axis]: event.target.value } }), children: [_jsx("option", { value: "repeat", children: "Repeat" }), _jsx("option", { value: "clamp", children: "Clamp" }), _jsx("option", { value: "mirror", children: "Mirror" })] })] }, axis)), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Compression" }), _jsx("select", { "aria-label": `${role} compression`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.compression ?? "none", onChange: (event) => patch({ compression: event.target.value }), children: ["none", "ktx2", "basisu", "webp"].map((compression) => _jsx("option", { value: compression, children: compression }, compression)) })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Minification filter" }), _jsx("select", { "aria-label": `${role} minification filter`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.minFilter ?? "linear-mipmap-linear", onChange: (event) => patch({ sampler: { ...texture.sampler, minFilter: event.target.value } }), children: ["nearest", "linear", "nearest-mipmap-nearest", "nearest-mipmap-linear", "linear-mipmap-nearest", "linear-mipmap-linear"].map((filter) => _jsx("option", { value: filter, children: filter }, filter)) })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Magnification filter" }), _jsxs("select", { "aria-label": `${role} magnification filter`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: texture.sampler?.magFilter ?? "linear", onChange: (event) => patch({ sampler: { ...texture.sampler, magFilter: event.target.value } }), children: [_jsx("option", { value: "nearest", children: "Nearest" }), _jsx("option", { value: "linear", children: "Linear" })] })] }), _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Texture anisotropic filtering" }), _jsx("input", { "aria-label": `${role} texture anisotropy`, type: "number", min: "1", step: "1", value: texture.sampler?.anisotropy ?? 1, className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => { const anisotropy = Number(event.target.value); if (Number.isFinite(anisotropy) && anisotropy >= 1)
|
|
68
|
+
patch({ sampler: { ...texture.sampler, anisotropy } }); } })] }), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Compression describes the supplied file; this editor does not transcode it. Physical size records authored metres; UV scale controls sampling." }), _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[10px] ${FOCUS_RING}`, children: "Texture attribution" }), _jsx("div", { className: "pt-2", children: _jsx(ProvenanceFields, { label: `${role} texture`, provenance: texture.provenance, onChange: (provenance) => patch({ provenance }) }, JSON.stringify(texture.provenance)) })] }), _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1 text-rose-200`, onClick: () => onChange(undefined), children: "Remove map override" })] })] });
|
|
69
|
+
}
|
|
70
|
+
function FabricFields({ asset, onChange }) {
|
|
71
|
+
const fabric = asset.fabric;
|
|
72
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Fabric construction" }), _jsxs("select", { "aria-label": "Fabric construction", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: fabric?.construction ?? "", onChange: (event) => { const construction = event.target.value; if (!construction) {
|
|
73
|
+
const next = { ...asset };
|
|
74
|
+
delete next.fabric;
|
|
75
|
+
onChange(next);
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
onChange({ ...asset, fabric: construction === "fuzzy" ? { construction: "fuzzy" } : { ...fabric, construction: "woven" } }); }, children: [_jsx("option", { value: "", children: "No appearance adapter" }), _jsx("option", { value: "fuzzy", children: "Fuzzy / grazing sheen" }), _jsx("option", { value: "woven", children: "Woven / directional threads" })] })] }), fabric?.construction === "woven" ? _jsxs(_Fragment, { children: [[
|
|
79
|
+
{ key: "weaveDirection", label: "Weave direction (radians)", fallback: 0, min: -Math.PI, max: Math.PI },
|
|
80
|
+
{ key: "threadScale", label: "Thread cycles per UV", fallback: 80, min: 0.01 },
|
|
81
|
+
{ key: "normalStrength", label: "Thread normal response", fallback: 0.12, min: 0, max: 1 },
|
|
82
|
+
{ key: "variation", label: "Roughness variation", fallback: 0.05, min: 0, max: 1 },
|
|
83
|
+
].map((field) => _jsx(SurfaceField, { control: { ...field, kind: "number", step: 0.01 }, value: fabric[field.key], onChange: (value) => { const next = { ...fabric, [field.key]: value }; if (value === undefined)
|
|
84
|
+
delete next[field.key]; onChange({ ...asset, fabric: next }, `material:${asset.id}:fabric:${field.key}`); } }, field.key)), _jsx("p", { className: "text-[10px] text-neutral-500", children: "Procedural threads modify normals and roughness on UV-mapped geometry. Explicit physical highlight fields override adapter defaults." })] }) : null] });
|
|
85
|
+
}
|
|
86
|
+
function HairFields({ asset, onChange }) {
|
|
87
|
+
const hair = asset.hair;
|
|
88
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Existing geometry" }), _jsxs("select", { "aria-label": "Hair geometry kind", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: hair?.geometry ?? "", onChange: (event) => { const geometry = event.target.value; if (!geometry) {
|
|
89
|
+
const next = { ...asset };
|
|
90
|
+
delete next.hair;
|
|
91
|
+
onChange(next);
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
onChange({ ...asset, hair: geometry === "strands" ? { geometry: "strands", ...(hair?.strandDirection === undefined ? {} : { strandDirection: hair.strandDirection }) } : { ...hair, geometry: "cards" } }); }, children: [_jsx("option", { value: "", children: "No appearance adapter" }), _jsx("option", { value: "cards", children: "Hair cards" }), _jsx("option", { value: "strands", children: "Strand mesh" })] })] }), hair ? _jsx(SurfaceField, { control: { label: "Strand direction (radians)", kind: "number", fallback: 0, min: -Math.PI, max: Math.PI, step: 0.01 }, value: hair.strandDirection, onChange: (value) => { const next = { ...hair, strandDirection: typeof value === "number" ? value : undefined }; if (value === undefined)
|
|
95
|
+
delete next.strandDirection; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:direction`); } }) : null] });
|
|
96
|
+
}
|
|
97
|
+
function HairBacklightFields({ asset, onChange }) {
|
|
98
|
+
const hair = asset.hair;
|
|
99
|
+
if (hair?.geometry !== "cards")
|
|
100
|
+
return _jsx("p", { className: "text-[10px] text-neutral-500", children: "Backlighting is supported by the hair-card adapter. Select existing card geometry under Strand highlights." });
|
|
101
|
+
return _jsxs("div", { className: "space-y-2", children: [_jsx(SurfaceField, { control: { label: "Card backlight strength", kind: "number", fallback: 0.25, min: 0, max: 1, step: 0.01 }, value: hair.backlightStrength, onChange: (value) => { const next = { ...hair, backlightStrength: typeof value === "number" ? value : undefined }; if (value === undefined)
|
|
102
|
+
delete next.backlightStrength; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:backlight`); } }), _jsx(SurfaceField, { control: { label: "Card backlight color", kind: "color", fallback: asset.surface.color ?? "#ffffff" }, value: hair.backlightColor, onChange: (value) => { const next = { ...hair, backlightColor: typeof value === "string" ? value : undefined }; if (value === undefined)
|
|
103
|
+
delete next.backlightColor; onChange({ ...asset, hair: next }, `material:${asset.id}:hair:backlightColor`); } })] });
|
|
104
|
+
}
|
|
105
|
+
function ProvenanceFields({ label, provenance, onChange }) {
|
|
106
|
+
const [draft, setDraft] = useState({ author: provenance?.author ?? "", license: provenance?.license ?? "", sourceUrl: provenance?.sourceUrl ?? "", description: provenance?.description ?? "" });
|
|
107
|
+
const validSource = !draft.sourceUrl.trim() || /^https?:\/\//i.test(draft.sourceUrl.trim());
|
|
108
|
+
return _jsxs("div", { className: "space-y-2", children: [["author", "license", "sourceUrl", "description"].map((field) => _jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: field === "sourceUrl" ? "Source URL" : field === "description" ? "Notes" : field }), _jsx("input", { "aria-label": `${label} ${field}`, className: `w-full px-2 py-1 ${INPUT_CLS}`, value: draft[field], onChange: (event) => setDraft({ ...draft, [field]: event.target.value }) })] }, field)), !validSource ? _jsx("p", { className: "text-[10px] text-rose-300", children: "Use an HTTP or HTTPS source URL." }) : null, _jsx("button", { type: "button", className: `${CONTROL} ${FOCUS_RING} px-2 py-1`, disabled: !draft.author.trim() || !draft.license.trim() || !validSource, onClick: () => onChange({ author: draft.author.trim(), license: draft.license.trim(), ...(draft.sourceUrl.trim() ? { sourceUrl: draft.sourceUrl.trim() } : {}), ...(draft.description.trim() ? { description: draft.description.trim() } : {}) }), children: "Apply attribution" }), provenance?.sourceUrl && /^https?:\/\//i.test(provenance.sourceUrl) ? _jsx("a", { href: provenance.sourceUrl, target: "_blank", rel: "noreferrer", className: `ml-2 break-all text-[10px] text-cyan-300 underline ${FOCUS_RING}`, children: "View source" }) : null] });
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
|
+
import { type ModelMaterialSlotInfo } from "@jgengine/shell/render/materialAsset";
|
|
3
|
+
import type { MaterialAsset } from "@jgengine/core/material/materialAsset";
|
|
4
|
+
import type { EditorEnvironment } from "@jgengine/core/editor/types";
|
|
5
|
+
/**
|
|
6
|
+
* Material preview lighting uses an isolated renderer; document settings never modify the game.
|
|
7
|
+
* @internal Mounted by the material workspace.
|
|
8
|
+
*/
|
|
9
|
+
export declare function MaterialPreview({ asset, mode, environment, model, onSlots, onError }: {
|
|
10
|
+
asset?: MaterialAsset;
|
|
11
|
+
mode: "neutral" | "game";
|
|
12
|
+
environment?: EditorEnvironment;
|
|
13
|
+
model?: ModelConfig;
|
|
14
|
+
onSlots?: (slots: ModelMaterialSlotInfo[]) => void;
|
|
15
|
+
onError?: (message: string | null) => void;
|
|
16
|
+
}): import("react").JSX.Element;
|