cb3fb47561
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
2911 lines
114 KiB
TypeScript
2911 lines
114 KiB
TypeScript
import { useThemePreference } from './hooks/useThemePreference';
|
|
import { resolvePolicyDeployment, type PolicyDeployment } from '../rl/deployment';
|
|
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
|
|
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import {
|
|
lazy,
|
|
Suspense,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type DragEvent,
|
|
} from 'react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import {
|
|
Camera,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
CircleHelp,
|
|
Code2,
|
|
Crosshair,
|
|
Database,
|
|
Download,
|
|
Hand,
|
|
Maximize,
|
|
MousePointer2,
|
|
PanelsTopLeft,
|
|
Pause,
|
|
Play,
|
|
RotateCcw,
|
|
SlidersHorizontal,
|
|
SunMoon,
|
|
} from 'lucide-react';
|
|
import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
|
|
import {
|
|
filesFromDrop,
|
|
importBrowserFiles,
|
|
normalizeProjectPath,
|
|
ProjectImportError,
|
|
} from '../project/importer';
|
|
import {
|
|
MainThreadPhysicsAdapter,
|
|
type UrdfBaseMode,
|
|
type UrdfEnhancementOptions,
|
|
type UrdfLoadMode,
|
|
} from '../simulation/PhysicsAdapter';
|
|
import type { ActuatorParameters } from '../simulation/SimulationSession';
|
|
import type { DataRecorderConfig } from '../telemetry/DataRecorder';
|
|
import type { ControllerCommand, ControllerStatus } from '../controller/types';
|
|
import type { RLCommand, RLPolicyStatus } from '../rl/types';
|
|
import type { MuJoCoViewer, InteractionMode } from '../viewer/MuJoCoViewer';
|
|
import {
|
|
DEFAULT_VIEWER_DISPLAY_OPTIONS,
|
|
type ViewerDisplayOptions,
|
|
} from '../viewer/displayOptions';
|
|
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
|
|
import { WorkbenchHeader } from './components/WorkbenchHeader';
|
|
import { ViewerToolDock } from './components/ViewerToolDock';
|
|
import {
|
|
MapAssetDropIndicator,
|
|
MapDraftStatusOverlay,
|
|
MapViewportToolbar,
|
|
type MapAssetDropTarget,
|
|
} from './components/MapViewportTools';
|
|
import { ModelControlsSidebar } from './components/ModelControlsSidebar';
|
|
import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar';
|
|
import type { WorkspaceTool } from './components/WorkspaceToolsPanel';
|
|
import type { EditorSelection } from './editorSelection';
|
|
import { isTextEditingTarget } from './keyboard';
|
|
import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts';
|
|
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
|
|
import { EntrySelectionDialog } from './components/EntrySelectionDialog';
|
|
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
|
|
import { useSidebarLayout } from './hooks/useSidebarLayout';
|
|
import { ViewportOverlayLayout } from './components/ViewportOverlayLayout';
|
|
import { SimulationControls } from './components/SimulationControls';
|
|
import { ViewerDisplayPopover } from './components/ViewerDisplayPopover';
|
|
import { ViewportHUD } from './components/ViewportHUD';
|
|
import { ShortcutHelpDialog } from './components/ShortcutHelpDialog';
|
|
import { CommandPalette, type WorkbenchCommand } from './components/CommandPalette';
|
|
import {
|
|
NotificationCenter,
|
|
ToastViewport,
|
|
type WorkbenchNotification,
|
|
} from './components/NotificationCenter';
|
|
import { SettingsDialog } from './components/SettingsDialog';
|
|
import {
|
|
dispatchLayoutWidths,
|
|
LayoutSettingsDialog,
|
|
type LayoutPreset,
|
|
} from './components/LayoutSettingsDialog';
|
|
import { Button, ConfirmDialog, IconButton } from '../components/ui';
|
|
import { PanelWidthBudgetContext } from '../components/ui/panelWidthBudget';
|
|
import { DiagnosticsDrawer } from './components/DiagnosticsDrawer';
|
|
import { ToolbarOverflowMenu } from './components/ToolbarOverflowMenu';
|
|
|
|
import { UrdfImportOptionsDialog } from './components/UrdfImportOptionsDialog';
|
|
import {
|
|
downloadBytes,
|
|
exportedFileName,
|
|
mergeCachedFiles,
|
|
readCachedText,
|
|
upsertCachedMjcf,
|
|
} from '../project/cachedFiles';
|
|
import {
|
|
createPlacedMapAsset,
|
|
DEFAULT_MAP_SELECTION,
|
|
DEFAULT_PHYSICAL_MAP_CONFIG,
|
|
PHYSICAL_MAP_PRESET_LABELS,
|
|
mapLocalPointToWorld,
|
|
mapSelectionTransform,
|
|
mapWorldPointToLocal,
|
|
updatePlacedMapAsset,
|
|
type MapSelection,
|
|
type PlacedMapAsset,
|
|
type PlacedMapSelection,
|
|
type SystemTerrainPreset,
|
|
} from '../map/types';
|
|
import { discoverMapEntries, resolveProjectMap, visualMapAssets } from '../map/MapLoader';
|
|
import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema';
|
|
import type {
|
|
EditableMapDocument,
|
|
EditableMapObjectType,
|
|
MapEditorInteractionCallbacks,
|
|
MapEditorSessionState,
|
|
MapEditorTransformMode,
|
|
MapObjectPlacementMode,
|
|
} from '../map/editor/types';
|
|
import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from '../map/editor/assetCatalog';
|
|
import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler';
|
|
import { materializeEditableMapDrafts } from '../map/editor/EditableMapDraftCommit';
|
|
import { findCompiledEditableMapPick } from '../map/editor/compiledMapPick';
|
|
import { mapSceneSurfaceHeightAt } from '../map/sceneSurface';
|
|
import { importEditableMapDocument } from '../map/editor/MapDocumentImporter';
|
|
import { resolveProjectAssetPath } from '../map/mapPaths';
|
|
import {
|
|
clonePlacedMapAssets,
|
|
mapEditorDraftPreviewInstances,
|
|
resolveMapSceneLoadAssets,
|
|
restoreAppliedMapScene,
|
|
summarizeMapSceneDraft,
|
|
transformParametricMapAsset,
|
|
} from '../map/mapSceneDraft';
|
|
|
|
const SourceEditorDialog = lazy(() =>
|
|
import('./components/SourceEditorDialog').then((module) => ({
|
|
default: module.SourceEditorDialog,
|
|
})),
|
|
);
|
|
const WorkspaceToolsPanel = lazy(() =>
|
|
import('./components/WorkspaceToolsPanel').then((module) => ({
|
|
default: module.WorkspaceToolsPanel,
|
|
})),
|
|
);
|
|
|
|
function hasTransferType(dataTransfer: DataTransfer, type: string): boolean {
|
|
return Array.from(dataTransfer.types).includes(type);
|
|
}
|
|
|
|
function diagnostic(
|
|
category: AppDiagnostic['category'],
|
|
error: unknown,
|
|
path?: string,
|
|
): AppDiagnostic {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
return { category, summary: `${category}失败`, detail, path, at: Date.now() };
|
|
}
|
|
function initialDisplayOptions(): ViewerDisplayOptions {
|
|
try {
|
|
const stored = JSON.parse(
|
|
localStorage.getItem('mujoco-platform-display') ?? 'null',
|
|
) as Partial<ViewerDisplayOptions> | null;
|
|
if (!stored) return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
|
|
const next = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
|
|
for (const key of Object.keys(next) as (keyof ViewerDisplayOptions)[])
|
|
if (typeof stored[key] === 'boolean') next[key] = stored[key];
|
|
return next;
|
|
} catch {
|
|
return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
|
|
}
|
|
}
|
|
function convertedCachePath(entryPath: string): string {
|
|
const slash = entryPath.lastIndexOf('/');
|
|
return `${slash >= 0 ? entryPath.slice(0, slash + 1) : ''}.__converted_mjcf_cache__.xml`;
|
|
}
|
|
function urdfLinkNames(project: ProjectManifest | null, path: string | undefined): string[] {
|
|
const file = path ? project?.files.find((candidate) => candidate.path === path) : undefined;
|
|
if (!file) return [];
|
|
const document = new DOMParser().parseFromString(
|
|
new TextDecoder().decode(file.data),
|
|
'application/xml',
|
|
);
|
|
return Array.from(document.querySelectorAll('robot > link[name]'))
|
|
.map((link) => link.getAttribute('name'))
|
|
.filter((name): name is string => Boolean(name));
|
|
}
|
|
|
|
function omitManifestFiles(
|
|
manifest: ProjectManifest,
|
|
omittedPaths: ReadonlySet<string>,
|
|
): ProjectManifest {
|
|
if (!omittedPaths.size) return manifest;
|
|
const files = manifest.files.filter((file) => !omittedPaths.has(file.path));
|
|
return {
|
|
...manifest,
|
|
files,
|
|
maps: discoverMapEntries(files),
|
|
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
|
};
|
|
}
|
|
|
|
function manifestEditorDocuments(manifest: ProjectManifest): Map<string, EditableMapDocument> {
|
|
const documents = new Map<string, EditableMapDocument>();
|
|
for (const map of manifest.maps) {
|
|
if (!map.authoringPath) continue;
|
|
const file = manifest.files.find((candidate) => candidate.path === map.authoringPath);
|
|
if (file) documents.set(map.descriptorPath, decodeEditableMapDocument(file.data));
|
|
}
|
|
return documents;
|
|
}
|
|
|
|
export function App() {
|
|
const state = useAppStore(
|
|
useShallow((value) => ({
|
|
projectName: value.projectName,
|
|
files: value.files,
|
|
entries: value.entries,
|
|
selectedEntry: value.selectedEntry,
|
|
loading: value.loading,
|
|
diagnostic: value.diagnostic,
|
|
snapshot: value.snapshot,
|
|
selection: value.selection,
|
|
paused: value.paused,
|
|
speed: value.speed,
|
|
mode: value.mode,
|
|
clearProject: value.clearProject,
|
|
setProject: value.setProject,
|
|
setEntry: value.setEntry,
|
|
setLoading: value.setLoading,
|
|
setDiagnostic: value.setDiagnostic,
|
|
setSnapshot: value.setSnapshot,
|
|
setSelection: value.setSelection,
|
|
setPaused: value.setPaused,
|
|
setSpeed: value.setSpeed,
|
|
setMode: value.setMode,
|
|
setMetrics: value.setMetrics,
|
|
})),
|
|
);
|
|
const manifest = useRef<ProjectManifest | null>(null),
|
|
notificationId = useRef(0),
|
|
loadInFlight = useRef(false),
|
|
policyLoadInFlight = useRef(false),
|
|
importInFlight = useRef(false),
|
|
adapter = useRef(new MainThreadPhysicsAdapter()),
|
|
root = useRef<HTMLDivElement>(null),
|
|
viewportShell = useRef<HTMLElement>(null),
|
|
viewerHost = useRef<HTMLDivElement>(null),
|
|
viewer = useRef<MuJoCoViewer | null>(null),
|
|
viewerReady = useRef<Promise<MuJoCoViewer | null> | null>(null),
|
|
dragDepth = useRef(0),
|
|
editorInteraction = useRef<MapEditorInteractionCallbacks | null>(null),
|
|
editorDraftsRef = useRef<Map<string, EditableMapDocument>>(new Map()),
|
|
provisionalMapFilesRef = useRef<Map<string, string[]>>(new Map()),
|
|
pendingEditorObjectId = useRef<string | undefined>(undefined),
|
|
compiledMapBodyInteraction = useRef<(bodyName: string) => boolean>(() => false),
|
|
mapEditorPreviewInteraction = useRef<(mapAssetId: string, objectId: string) => void>(() => {}),
|
|
parametricMapInteraction = useRef<{
|
|
onSelect(id: string | null): void;
|
|
onTransform(id: string, position: [number, number], yawDeg: number): void;
|
|
} | null>(null),
|
|
pendingMapAsset = useRef<{
|
|
type: EditableMapObjectType;
|
|
position?: [number, number, number];
|
|
placementMode: MapObjectPlacementMode;
|
|
externalSupportTop?: number;
|
|
} | null>(null),
|
|
urdfEnhancementsRef = useRef<UrdfEnhancementOptions>({
|
|
addActuators: true,
|
|
addSensors: true,
|
|
sensorType: 'camera',
|
|
});
|
|
const {
|
|
width: workspaceWidth,
|
|
leftOpen,
|
|
rightOpen,
|
|
setLeftOpen,
|
|
setRightOpen,
|
|
} = useSidebarLayout();
|
|
const sensorCameraFrame = useRef<HTMLDivElement>(null);
|
|
const orientationHost = useRef<HTMLDivElement>(null);
|
|
const [mapCommitState, setMapCommitState] = useState<'idle' | 'submitting' | 'failed'>('idle');
|
|
const [forceScale, setForceScale] = useState(50),
|
|
[helpOpen, setHelpOpen] = useState(false),
|
|
[commandOpen, setCommandOpen] = useState(false),
|
|
[sourceOpen, setSourceOpen] = useState(false),
|
|
[generatedMjcf, setGeneratedMjcf] = useState<string>(),
|
|
[generatedMjcfPath, setGeneratedMjcfPath] = useState<string>(),
|
|
[pendingUrdfPath, setPendingUrdfPath] = useState<string>(),
|
|
[pendingUrdfMounts, setPendingUrdfMounts] = useState<string[]>([]),
|
|
[removeConfirmOpen, setRemoveConfirmOpen] = useState(false),
|
|
[fullscreen, setFullscreen] = useState(false),
|
|
[settingsOpen, setSettingsOpen] = useState(false),
|
|
[layoutOpen, setLayoutOpen] = useState(false),
|
|
[diagnosticsOpen, setDiagnosticsOpen] = useState(false),
|
|
[dragActive, setDragActive] = useState(false),
|
|
[importProgress, setImportProgress] = useState<ImportProgress>(),
|
|
[notifications, setNotifications] = useState<WorkbenchNotification[]>([]),
|
|
[toast, setToast] = useState<WorkbenchNotification>(),
|
|
[selectedControllerPath, setSelectedControllerPath] = useState<string>(),
|
|
[controllerStatus, setControllerStatus] = useState<ControllerStatus>(),
|
|
[selectedPolicyPath, setSelectedPolicyPath] = useState<string>(),
|
|
[policyStatus, setPolicyStatus] = useState<RLPolicyStatus>(),
|
|
[navigationTargetMode, setNavigationTargetMode] = useState(false),
|
|
[trainingDeployment, setTrainingDeployment] = useState<PolicyDeployment>(),
|
|
[showPerceptionRays, setShowPerceptionRays] = useState(true),
|
|
[projectMaps, setProjectMaps] = useState<MapEntry[]>([]),
|
|
[editorDocument, setEditorDocument] = useState<EditableMapDocument | null>(null),
|
|
[editorDrafts, setEditorDrafts] = useState<Map<string, EditableMapDocument>>(() => new Map()),
|
|
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
|
|
Map<string, EditableMapDocument>
|
|
>(() => new Map()),
|
|
[projectSidebarTab, setProjectSidebarTab] = useState<ProjectResourceTab>('assets'),
|
|
[editorSelection, setEditorSelection] = useState<EditorSelection | null>(null),
|
|
[workspaceTool, setWorkspaceTool] = useState<WorkspaceTool | null>(null),
|
|
[mapTransformMode, setMapTransformMode] = useState<MapEditorTransformMode>('translate'),
|
|
[mapSnapping, setMapSnapping] = useState(true),
|
|
[assetPlacementMode, setAssetPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'),
|
|
[editorSessionStates, setEditorSessionStates] = useState<Map<string, MapEditorSessionState>>(
|
|
() => new Map(),
|
|
),
|
|
[mapAssetDropTarget, setMapAssetDropTarget] = useState<MapAssetDropTarget>();
|
|
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
|
|
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
|
|
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
|
|
baseModeRef = useRef<UrdfBaseMode>('floating');
|
|
const [mapSelection, setMapSelection] = useState<MapSelection>(DEFAULT_MAP_SELECTION),
|
|
mapSelectionRef = useRef<MapSelection>(DEFAULT_MAP_SELECTION),
|
|
[placedMapAssets, setPlacedMapAssets] = useState<PlacedMapAsset[]>([]),
|
|
placedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
|
|
[appliedMapAssets, setAppliedMapAssets] = useState<PlacedMapAsset[]>([]),
|
|
appliedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
|
|
[activeMapAssetId, setActiveMapAssetId] = useState<string>(),
|
|
activeMapAssetIdRef = useRef<string | undefined>(undefined),
|
|
[showVisualMap, setShowVisualMap] = useState(true),
|
|
[showMapCollision, setShowMapCollision] = useState(false);
|
|
const [displayOptions, setDisplayOptions] = useState<ViewerDisplayOptions>(initialDisplayOptions),
|
|
[showSensorCamera, setShowSensorCamera] = useState(true),
|
|
[theme, setTheme] = useThemePreference(),
|
|
[jointAdvanced, setJointAdvanced] = useState(false),
|
|
[ignoreJointLimits, setIgnoreJointLimits] = useState(false),
|
|
[angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad');
|
|
const showCollision = displayOptions.showCollision,
|
|
setShowCollision = (value: boolean) =>
|
|
setDisplayOptions((options) => ({ ...options, showCollision: value }));
|
|
const mapSceneDraft = useMemo(
|
|
() => summarizeMapSceneDraft(placedMapAssets, appliedMapAssets),
|
|
[placedMapAssets, appliedMapAssets],
|
|
);
|
|
const pendingSceneIds = useMemo(() => {
|
|
const ids = new Set(mapSceneDraft.changedIds);
|
|
for (const asset of placedMapAssets)
|
|
if (asset.selection.kind === 'project' && editorDrafts.has(asset.selection.descriptorPath))
|
|
ids.add(asset.id);
|
|
return [...ids];
|
|
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]);
|
|
const editorOnlyDraftCount = useMemo(() => {
|
|
const changedIds = new Set(mapSceneDraft.changedIds);
|
|
const coveredDescriptors = new Set(
|
|
placedMapAssets.flatMap((asset) =>
|
|
changedIds.has(asset.id) && asset.selection.kind === 'project'
|
|
? [asset.selection.descriptorPath]
|
|
: [],
|
|
),
|
|
);
|
|
return [...editorDrafts.keys()]
|
|
.filter((path) => !coveredDescriptors.has(path))
|
|
.reduce(
|
|
(count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1),
|
|
0,
|
|
);
|
|
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]);
|
|
const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount,
|
|
mapSceneDirty = sceneDraftChangeCount > 0;
|
|
const activeEditorView =
|
|
mapSelection.kind === 'project'
|
|
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
|
|
: null;
|
|
const selectedMapObject =
|
|
editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId
|
|
? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId)
|
|
: undefined;
|
|
const mapEditingActive =
|
|
Boolean(activeMapAssetId) &&
|
|
(editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object');
|
|
const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode;
|
|
const viewerSettings = useRef({
|
|
mode: state.mode,
|
|
forceScale,
|
|
displayOptions,
|
|
showVisualMap,
|
|
showMapCollision,
|
|
showSensorCamera,
|
|
theme,
|
|
});
|
|
useEffect(() => {
|
|
viewerSettings.current = {
|
|
mode: state.mode,
|
|
forceScale,
|
|
displayOptions,
|
|
showVisualMap,
|
|
showMapCollision,
|
|
showSensorCamera,
|
|
theme,
|
|
};
|
|
}, [
|
|
state.mode,
|
|
forceScale,
|
|
displayOptions,
|
|
showVisualMap,
|
|
showMapCollision,
|
|
showSensorCamera,
|
|
theme,
|
|
]);
|
|
useEffect(() => {
|
|
const host = viewerHost.current;
|
|
if (!host) return;
|
|
let active = true;
|
|
const ready = import('../viewer/MuJoCoViewer')
|
|
.then(({ MuJoCoViewer: Viewer }) => {
|
|
if (!active) return null;
|
|
const next = new Viewer(host, {
|
|
onSelection: (selection) => {
|
|
if (selection && compiledMapBodyInteraction.current(selection.bodyName)) {
|
|
state.setSelection(null);
|
|
return;
|
|
}
|
|
state.setSelection(selection);
|
|
setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null);
|
|
if (selection) setRightOpen(true);
|
|
},
|
|
onNavigationMode: setNavigationTargetMode,
|
|
onNavigationTarget: (target) => {
|
|
adapter.current.setNavigationTarget(target);
|
|
const snapshot = adapter.current.snapshot();
|
|
state.setSnapshot(snapshot ?? undefined);
|
|
setPolicyStatus(snapshot?.rlPolicy);
|
|
},
|
|
onFrame: (frame, fps, snapshot) => {
|
|
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
|
|
.memory?.usedJSHeapSize;
|
|
state.setMetrics(
|
|
fps,
|
|
frame.stepMs,
|
|
memory === undefined ? undefined : memory / 1048576,
|
|
frame.overBudget,
|
|
);
|
|
if (snapshot) {
|
|
state.setSnapshot(snapshot);
|
|
setControllerStatus(snapshot.controller);
|
|
setPolicyStatus(snapshot.rlPolicy);
|
|
if (snapshot.controller?.error || snapshot.rlPolicy?.error) {
|
|
adapter.current.setPaused(true);
|
|
state.setPaused(true);
|
|
}
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
console.error('[MuJoCo] 视口运行失败', error);
|
|
state.setDiagnostic(
|
|
diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error),
|
|
);
|
|
},
|
|
onMapEditorSelect: (id) => {
|
|
editorInteraction.current?.onSelect(id);
|
|
const mapAssetId = activeMapAssetIdRef.current;
|
|
setEditorSelection(
|
|
mapAssetId
|
|
? id
|
|
? { kind: 'map-object', mapAssetId, objectId: id }
|
|
: { kind: 'map', mapAssetId }
|
|
: null,
|
|
);
|
|
if (mapAssetId) setRightOpen(true);
|
|
},
|
|
onMapEditorPreviewSelect: (mapAssetId, objectId) =>
|
|
mapEditorPreviewInteraction.current(mapAssetId, objectId),
|
|
onParametricMapSelect: (id) => parametricMapInteraction.current?.onSelect(id),
|
|
onParametricMapTransform: (id, position, yawDeg) =>
|
|
parametricMapInteraction.current?.onTransform(id, position, yawDeg),
|
|
onMapEditorTransform: (id, position, quaternion, scale) =>
|
|
editorInteraction.current?.onTransform({ id, position, quaternion, scale }),
|
|
});
|
|
if (!active) {
|
|
next.dispose();
|
|
return null;
|
|
}
|
|
viewer.current = next;
|
|
const settings = viewerSettings.current;
|
|
next.setMode(settings.mode);
|
|
next.forceScale = settings.forceScale;
|
|
next.setDisplayOptions(settings.displayOptions);
|
|
next.setMapDisplay(settings.showVisualMap, settings.showMapCollision);
|
|
next.setShowSensorCamera(settings.showSensorCamera);
|
|
next.setSensorCameraViewportElement(sensorCameraFrame.current);
|
|
if (orientationHost.current) next.setOrientationGizmoHost(orientationHost.current);
|
|
next.setTheme(settings.theme);
|
|
next.setParametricMapAssets(
|
|
placedMapAssetsRef.current,
|
|
summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current)
|
|
.changedIds,
|
|
);
|
|
return next;
|
|
})
|
|
.catch((error) => {
|
|
if (active) {
|
|
console.error('[MuJoCo] 三维视口初始化失败', error);
|
|
state.setDiagnostic(diagnostic('渲染', error));
|
|
}
|
|
return null;
|
|
});
|
|
viewerReady.current = ready;
|
|
return () => {
|
|
active = false;
|
|
if (viewerReady.current === ready) viewerReady.current = null;
|
|
viewer.current?.dispose();
|
|
viewer.current = null;
|
|
const retiredAdapter = adapter.current;
|
|
retiredAdapter.dispose();
|
|
if (adapter.current === retiredAdapter) adapter.current = new MainThreadPhysicsAdapter();
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
viewer.current?.setMode(state.mode);
|
|
}, [state.mode]);
|
|
useEffect(() => {
|
|
viewer.current?.setMapEditorTransformMode(mapTransformMode);
|
|
}, [mapTransformMode]);
|
|
useEffect(() => {
|
|
viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null);
|
|
}, [mapSnapping]);
|
|
useEffect(() => {
|
|
if (viewer.current) viewer.current.forceScale = forceScale;
|
|
}, [forceScale]);
|
|
useEffect(() => {
|
|
viewer.current?.setDisplayOptions(displayOptions);
|
|
try {
|
|
localStorage.setItem('mujoco-platform-display', JSON.stringify(displayOptions));
|
|
} catch {
|
|
/* 当前会话仍可修改 */
|
|
}
|
|
}, [displayOptions]);
|
|
useEffect(() => {
|
|
viewer.current?.setMapDisplay(showVisualMap, showMapCollision);
|
|
}, [showVisualMap, showMapCollision]);
|
|
useEffect(() => {
|
|
viewer.current?.setParametricMapAssets(
|
|
trainingDeployment ? [] : placedMapAssets,
|
|
mapSceneDraft.changedIds,
|
|
);
|
|
}, [placedMapAssets, mapSceneDraft.changedIds, trainingDeployment]);
|
|
useEffect(() => {
|
|
viewer.current?.setShowPerceptionRays(showPerceptionRays);
|
|
}, [showPerceptionRays]);
|
|
useEffect(() => {
|
|
viewer.current?.setShowSensorCamera(showSensorCamera);
|
|
}, [showSensorCamera]);
|
|
useEffect(() => {
|
|
viewer.current?.setTheme(theme);
|
|
}, [theme]);
|
|
useEffect(() => {
|
|
const change = () => setFullscreen(document.fullscreenElement === root.current);
|
|
document.addEventListener('fullscreenchange', change);
|
|
return () => document.removeEventListener('fullscreenchange', change);
|
|
}, []);
|
|
const loadEntry = useCallback(
|
|
async (
|
|
path: string,
|
|
requestedMode?: UrdfLoadMode,
|
|
requestedSceneAssets?: readonly PlacedMapAsset[],
|
|
requestedDeployment?: PolicyDeployment,
|
|
requestedPolicy?: { data: Uint8Array; path: string },
|
|
) => {
|
|
if (!manifest.current || loadInFlight.current) return false;
|
|
// 普通模型重载始终使用上次成功应用的地图基线。只有场景提交入口可以显式
|
|
// 传入草稿实例,防止切换模型/URDF 模式时把一半场景静默提前提交。
|
|
const sceneAssets = resolveMapSceneLoadAssets(
|
|
appliedMapAssetsRef.current,
|
|
requestedSceneAssets,
|
|
);
|
|
const previousState = useAppStore.getState();
|
|
const previousEntry = previousState.selectedEntry;
|
|
const previousPaused = previousState.paused;
|
|
loadInFlight.current = true;
|
|
setIgnoreJointLimits(false);
|
|
setControllerStatus(undefined);
|
|
setPolicyStatus(undefined);
|
|
state.setEntry(path);
|
|
state.setLoading(true);
|
|
setImportProgress({
|
|
title: '正在准备仿真',
|
|
label: '初始化三维视口',
|
|
detail: path,
|
|
value: 0.4,
|
|
});
|
|
state.setDiagnostic(undefined);
|
|
setGeneratedMjcf(undefined);
|
|
setGeneratedMjcfPath(undefined);
|
|
adapter.current.setPaused(true);
|
|
state.setPaused(true);
|
|
let attachedViewer: MuJoCoViewer | null = null;
|
|
let sessionSwapped = false;
|
|
try {
|
|
const activeViewer = viewer.current ?? (await viewerReady.current);
|
|
if (!activeViewer) throw new Error('三维视口尚未就绪,请重试');
|
|
const snapshot = await adapter.current.load(manifest.current, path, {
|
|
urdfMode: requestedMode ?? urdfModeRef.current,
|
|
baseMode: baseModeRef.current,
|
|
enhancements: urdfEnhancementsRef.current,
|
|
mapAssets: sceneAssets,
|
|
trainingDeployment: requestedDeployment,
|
|
trainingPolicy: requestedPolicy,
|
|
onProgress: ({ value, label }) =>
|
|
setImportProgress({
|
|
title: '正在准备仿真',
|
|
label,
|
|
detail: path,
|
|
value: 0.4 + value * 0.53,
|
|
}),
|
|
});
|
|
const supportFiles = adapter.current.cachedSupportFiles();
|
|
setImportProgress({
|
|
title: '正在准备仿真',
|
|
label: '创建三维场景',
|
|
detail: path,
|
|
value: 0.94,
|
|
});
|
|
adapter.current.setSpeed(useAppStore.getState().speed);
|
|
try {
|
|
activeViewer.attach(adapter.current.session);
|
|
attachedViewer = activeViewer;
|
|
sessionSwapped = true;
|
|
} catch (error) {
|
|
adapter.current.rollbackRetired();
|
|
activeViewer.attach(adapter.current.session);
|
|
throw error;
|
|
}
|
|
if (supportFiles.length && manifest.current) {
|
|
manifest.current = mergeCachedFiles(manifest.current, supportFiles);
|
|
state.setProject(
|
|
manifest.current.name,
|
|
manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
|
|
manifest.current.entries,
|
|
path,
|
|
);
|
|
}
|
|
state.setSnapshot(snapshot);
|
|
setMapCommitState('idle');
|
|
state.setSelection(null);
|
|
setEditorSelection(null);
|
|
state.setPaused(true);
|
|
setImportProgress({
|
|
title: '正在准备仿真',
|
|
label: '加载视觉地图与材质',
|
|
detail: path,
|
|
value: 0.97,
|
|
});
|
|
await activeViewer.setVisualMaps([]);
|
|
let visualMapWarning: string | undefined;
|
|
try {
|
|
const assets =
|
|
manifest.current && !requestedDeployment
|
|
? visualMapAssets(manifest.current, placedMapAssetsRef.current)
|
|
: [];
|
|
await activeViewer.setVisualMaps(assets);
|
|
} catch (error) {
|
|
visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`;
|
|
console.warn('[MuJoCo] 视觉地图加载失败', error);
|
|
}
|
|
try {
|
|
setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
|
|
setGeneratedMjcfPath(convertedCachePath(path));
|
|
} catch (error) {
|
|
console.warn('[MuJoCo] 无法生成源码预览', error);
|
|
}
|
|
const notice: WorkbenchNotification = {
|
|
id: ++notificationId.current,
|
|
title:
|
|
snapshot.warnings.length || visualMapWarning
|
|
? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整`
|
|
: '模型加载完成',
|
|
detail:
|
|
[...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') ||
|
|
path,
|
|
message: `${snapshot.model.nbody} Bodies · ${snapshot.model.njnt} Joints · ${snapshot.model.ngeom} Geoms`,
|
|
category: 'compile',
|
|
tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success',
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [notice, ...items].slice(0, 20));
|
|
setToast(notice);
|
|
if (requestedSceneAssets !== undefined) {
|
|
const committedMapAssets = clonePlacedMapAssets(sceneAssets);
|
|
appliedMapAssetsRef.current = committedMapAssets;
|
|
setAppliedMapAssets(committedMapAssets);
|
|
}
|
|
activeViewer.setParametricMapAssets(
|
|
requestedDeployment ? [] : placedMapAssetsRef.current,
|
|
summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current)
|
|
.changedIds,
|
|
);
|
|
adapter.current.releaseRetired();
|
|
setTrainingDeployment(requestedDeployment);
|
|
sessionSwapped = false;
|
|
return true;
|
|
} catch (error) {
|
|
if (sessionSwapped && attachedViewer) {
|
|
try {
|
|
adapter.current.rollbackRetired();
|
|
attachedViewer.attach(adapter.current.session);
|
|
} catch (rollbackError) {
|
|
console.error('[MuJoCo] 无法恢复上一仿真会话', rollbackError);
|
|
}
|
|
}
|
|
state.setDiagnostic(diagnostic('模型编译', error, path));
|
|
const notice: WorkbenchNotification = {
|
|
id: ++notificationId.current,
|
|
title: '模型编译失败',
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
message: path,
|
|
category: 'compile',
|
|
tone: 'danger',
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [notice, ...items].slice(0, 20));
|
|
setToast(notice);
|
|
const retained = adapter.current.snapshot();
|
|
if (retained && previousEntry) state.setEntry(previousEntry);
|
|
adapter.current.setPaused(previousPaused);
|
|
state.setPaused(previousPaused);
|
|
state.setSnapshot(retained ?? undefined);
|
|
setControllerStatus(retained?.controller);
|
|
setPolicyStatus(retained?.rlPolicy);
|
|
return false;
|
|
} finally {
|
|
loadInFlight.current = false;
|
|
setImportProgress(undefined);
|
|
state.setLoading(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
const requestLoadEntry = useCallback(
|
|
async (path: string) => {
|
|
const entry = manifest.current?.entries.find((candidate) => candidate.path === path);
|
|
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') {
|
|
setPendingUrdfMounts(urdfLinkNames(manifest.current, path));
|
|
setPendingUrdfPath(path);
|
|
return;
|
|
}
|
|
await loadEntry(path);
|
|
},
|
|
[loadEntry],
|
|
);
|
|
const confirmUrdfOptions = (options: UrdfEnhancementOptions) => {
|
|
const path = pendingUrdfPath;
|
|
if (!path) return;
|
|
urdfEnhancementsRef.current = options;
|
|
setPendingUrdfPath(undefined);
|
|
setPendingUrdfMounts([]);
|
|
void loadEntry(path);
|
|
};
|
|
const skipUrdfOptions = () =>
|
|
confirmUrdfOptions({ addActuators: false, addSensors: false, sensorType: 'camera' });
|
|
const ingest = useCallback(
|
|
async (files: File[], lockOwned = false) => {
|
|
if (importInFlight.current && !lockOwned) return;
|
|
importInFlight.current = true;
|
|
state.setLoading(true);
|
|
setImportProgress({
|
|
title: '正在导入工程',
|
|
label: '检查文件清单',
|
|
detail: files.length === 1 ? files[0].name : `${files.length} 个文件`,
|
|
value: 0.04,
|
|
});
|
|
try {
|
|
const next = await importBrowserFiles(
|
|
files,
|
|
DEFAULT_IMPORT_LIMITS,
|
|
({ phase, completed, total, path }) => {
|
|
const ratio = total ? completed / total : 0;
|
|
const label =
|
|
phase === 'reading'
|
|
? '读取工程文件'
|
|
: phase === 'extracting'
|
|
? '在后台解压工程包'
|
|
: '索引模型与地图入口';
|
|
const value =
|
|
phase === 'reading'
|
|
? 0.06 + ratio * 0.2
|
|
: phase === 'extracting'
|
|
? 0.28 + ratio * 0.07
|
|
: 0.37;
|
|
setImportProgress({ title: '正在导入工程', label, detail: path, value });
|
|
},
|
|
);
|
|
setImportProgress({
|
|
title: '正在导入工程',
|
|
label: '处理模型资源与入口',
|
|
detail: `${next.files.length} 个文件`,
|
|
value: 0.39,
|
|
});
|
|
manifest.current = next;
|
|
setProjectMaps(next.maps);
|
|
setCommittedEditorDocuments(manifestEditorDocuments(next));
|
|
setProjectSidebarTab('assets');
|
|
setEditorSelection(null);
|
|
setEditorDocument(null);
|
|
editorDraftsRef.current = new Map();
|
|
setEditorDrafts(new Map());
|
|
provisionalMapFilesRef.current.clear();
|
|
viewer.current?.setMapEditorDocument(null);
|
|
placedMapAssetsRef.current = [];
|
|
setPlacedMapAssets([]);
|
|
appliedMapAssetsRef.current = [];
|
|
setAppliedMapAssets([]);
|
|
viewer.current?.setParametricMapAssets([], []);
|
|
activeMapAssetIdRef.current = undefined;
|
|
setActiveMapAssetId(undefined);
|
|
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
|
|
setMapSelection(DEFAULT_MAP_SELECTION);
|
|
setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path);
|
|
setSelectedPolicyPath(next.files.find((file) => /\.onnx$/i.test(file.path))?.path);
|
|
state.setProject(
|
|
next.name,
|
|
next.files.map(({ path, size }) => ({ path, size })),
|
|
next.entries,
|
|
next.selectedEntry,
|
|
);
|
|
const importNotice: WorkbenchNotification = {
|
|
id: ++notificationId.current,
|
|
title: '工程导入完成',
|
|
message: `${next.files.length} files · ${next.entries.length} entries · ${next.maps.length} maps`,
|
|
detail: [
|
|
`工程:${next.name}`,
|
|
`文件:${next.files.length}`,
|
|
`模型入口:${next.entries.length}`,
|
|
`地图:${next.maps.length}`,
|
|
next.selectedEntry ? `默认入口:${next.selectedEntry}` : '默认入口:未选择',
|
|
].join('\n'),
|
|
category: 'import',
|
|
tone: 'success',
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [importNotice, ...items].slice(0, 20));
|
|
setToast(importNotice);
|
|
if (next.selectedEntry) await requestLoadEntry(next.selectedEntry);
|
|
} catch (error) {
|
|
state.setDiagnostic(
|
|
diagnostic(
|
|
error instanceof ProjectImportError && /ZIP/.test(error.message) ? 'ZIP' : '导入',
|
|
error,
|
|
error instanceof ProjectImportError ? error.path : undefined,
|
|
),
|
|
);
|
|
const notice: WorkbenchNotification = {
|
|
id: ++notificationId.current,
|
|
title: '工程导入失败',
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
message: error instanceof ProjectImportError ? error.path : undefined,
|
|
category: 'import',
|
|
tone: 'danger',
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [notice, ...items].slice(0, 20));
|
|
setToast(notice);
|
|
} finally {
|
|
importInFlight.current = false;
|
|
setImportProgress(undefined);
|
|
state.setLoading(false);
|
|
}
|
|
},
|
|
[requestLoadEntry],
|
|
);
|
|
const removeProject = () => {
|
|
if (state.projectName) setRemoveConfirmOpen(true);
|
|
};
|
|
const confirmRemoveProject = () => {
|
|
setMapCommitState('idle');
|
|
void viewer.current?.setVisualMaps([]);
|
|
viewer.current?.attach(null);
|
|
adapter.current.dispose();
|
|
adapter.current = new MainThreadPhysicsAdapter();
|
|
manifest.current = null;
|
|
setGeneratedMjcf(undefined);
|
|
setGeneratedMjcfPath(undefined);
|
|
setPendingUrdfPath(undefined);
|
|
setPendingUrdfMounts([]);
|
|
setSelectedControllerPath(undefined);
|
|
setControllerStatus(undefined);
|
|
setSelectedPolicyPath(undefined);
|
|
setTrainingDeployment(undefined);
|
|
setPolicyStatus(undefined);
|
|
setProjectMaps([]);
|
|
setCommittedEditorDocuments(new Map());
|
|
setProjectSidebarTab('assets');
|
|
setEditorSelection(null);
|
|
setEditorDocument(null);
|
|
editorDraftsRef.current = new Map();
|
|
setEditorDrafts(new Map());
|
|
setEditorSessionStates(new Map());
|
|
provisionalMapFilesRef.current.clear();
|
|
viewer.current?.setMapEditorDocument(null);
|
|
placedMapAssetsRef.current = [];
|
|
setPlacedMapAssets([]);
|
|
appliedMapAssetsRef.current = [];
|
|
setAppliedMapAssets([]);
|
|
viewer.current?.setParametricMapAssets([], []);
|
|
activeMapAssetIdRef.current = undefined;
|
|
setActiveMapAssetId(undefined);
|
|
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
|
|
setMapSelection(DEFAULT_MAP_SELECTION);
|
|
state.clearProject();
|
|
setRemoveConfirmOpen(false);
|
|
};
|
|
const changeUrdfMode = (value: UrdfLoadMode) => {
|
|
setUrdfMode(value);
|
|
urdfModeRef.current = value;
|
|
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
|
|
if (entry?.format !== 'urdf') return;
|
|
if (value === 'mjcf') {
|
|
setPendingUrdfMounts(urdfLinkNames(manifest.current, entry.path));
|
|
setPendingUrdfPath(entry.path);
|
|
} else void loadEntry(entry.path, value);
|
|
};
|
|
const changeBaseMode = (value: UrdfBaseMode) => {
|
|
setBaseMode(value);
|
|
baseModeRef.current = value;
|
|
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
|
|
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf')
|
|
void loadEntry(entry.path, 'mjcf');
|
|
};
|
|
const editorSurfaceHeight = useCallback(
|
|
(position: readonly [number, number, number]): number | null => {
|
|
const selection = mapSelectionRef.current;
|
|
const worldPosition =
|
|
selection.kind === 'project'
|
|
? mapLocalPointToWorld(position, selection)
|
|
: ([...position] as [number, number, number]);
|
|
return mapSceneSurfaceHeightAt(
|
|
placedMapAssetsRef.current,
|
|
worldPosition[0],
|
|
worldPosition[1],
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
const previewEditorDocument = useCallback((document: EditableMapDocument | null) => {
|
|
const selection = mapSelectionRef.current;
|
|
viewer.current?.setMapEditorDocument(
|
|
document,
|
|
selection.kind === 'project' ? mapSelectionTransform(selection) : undefined,
|
|
);
|
|
if (document) {
|
|
adapter.current.setPaused(true);
|
|
state.setPaused(true);
|
|
}
|
|
}, []);
|
|
const updateEditorDraft = useCallback(
|
|
(descriptorPath: string, document: EditableMapDocument, dirty: boolean) => {
|
|
if (loadInFlight.current) return;
|
|
const drafts = new Map(editorDraftsRef.current);
|
|
if (dirty) drafts.set(descriptorPath, structuredClone(document));
|
|
else drafts.delete(descriptorPath);
|
|
editorDraftsRef.current = drafts;
|
|
setEditorDrafts(drafts);
|
|
},
|
|
[],
|
|
);
|
|
const clearEditorDrafts = useCallback(() => {
|
|
editorDraftsRef.current = new Map();
|
|
setEditorDrafts(new Map());
|
|
setEditorSessionStates(new Map());
|
|
}, []);
|
|
const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
|
|
editorInteraction.current = callbacks;
|
|
const pending = pendingMapAsset.current;
|
|
if (callbacks && pending) {
|
|
pendingMapAsset.current = null;
|
|
callbacks.onAddAsset(
|
|
pending.type,
|
|
pending.position,
|
|
pending.placementMode,
|
|
pending.externalSupportTop,
|
|
);
|
|
}
|
|
const objectId = pendingEditorObjectId.current;
|
|
if (callbacks && objectId) {
|
|
pendingEditorObjectId.current = undefined;
|
|
callbacks.onSelect(objectId);
|
|
}
|
|
}, []);
|
|
const selectEditorObject = useCallback((id: string | null) => {
|
|
viewer.current?.selectMapEditorObject(id);
|
|
const mapAssetId = activeMapAssetIdRef.current;
|
|
setEditorSelection(
|
|
mapAssetId
|
|
? id
|
|
? { kind: 'map-object', mapAssetId, objectId: id }
|
|
: { kind: 'map', mapAssetId }
|
|
: null,
|
|
);
|
|
}, []);
|
|
const updateEditorSessionState = useCallback(
|
|
(descriptorPath: string, sessionState: MapEditorSessionState | null) => {
|
|
setEditorSessionStates((current) => {
|
|
const next = new Map(current);
|
|
if (sessionState?.dirty) next.set(descriptorPath, sessionState);
|
|
else next.delete(descriptorPath);
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
const activateMapEditing = useCallback(() => {
|
|
state.setMode('select');
|
|
}, []);
|
|
const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => {
|
|
setMapTransformMode(nextMode);
|
|
state.setMode('select');
|
|
}, []);
|
|
const changeMapPlacementMode = useCallback(
|
|
(placementMode: MapObjectPlacementMode) => {
|
|
setAssetPlacementMode(placementMode);
|
|
if (placementMode === 'locked')
|
|
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
|
|
if (editorSelection?.kind === 'map-object') {
|
|
editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode);
|
|
if (placementMode !== 'locked')
|
|
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
|
|
}
|
|
},
|
|
[editorSelection],
|
|
);
|
|
const alignSelectedMapObject = useCallback(() => {
|
|
if (editorSelection?.kind !== 'map-object') return;
|
|
editorInteraction.current?.onAlignToSurface(editorSelection.objectId);
|
|
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
|
|
}, [editorSelection]);
|
|
const focusSelectedObject = useCallback(() => {
|
|
const bodyId =
|
|
editorSelection?.kind === 'body' || editorSelection?.kind === 'joint'
|
|
? editorSelection.bodyId
|
|
: undefined;
|
|
viewer.current?.focusSelection(bodyId);
|
|
}, [editorSelection]);
|
|
const deleteSelectedMapObject = useCallback(() => {
|
|
if (editorSelection?.kind === 'map-object')
|
|
editorInteraction.current?.onDelete(editorSelection.objectId);
|
|
}, [editorSelection]);
|
|
const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => {
|
|
if (selection.kind !== 'project' || !manifest.current) return null;
|
|
const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
|
|
if (!resolved.authoringPath) return null;
|
|
const file = manifest.current.files.find(
|
|
(candidate) => candidate.path === resolved.authoringPath,
|
|
);
|
|
return file ? decodeEditableMapDocument(file.data) : null;
|
|
}, []);
|
|
const sceneEditorDocuments = useMemo(
|
|
() => new Map([...committedEditorDocuments, ...editorDrafts]),
|
|
[committedEditorDocuments, editorDrafts],
|
|
);
|
|
const mapEditorDraftPreviews = useMemo(
|
|
() =>
|
|
mapEditorDraftPreviewInstances(
|
|
placedMapAssets,
|
|
pendingSceneIds,
|
|
sceneEditorDocuments,
|
|
activeMapAssetId,
|
|
),
|
|
[placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId],
|
|
);
|
|
useEffect(() => {
|
|
let active = true;
|
|
const update = (activeViewer: MuJoCoViewer | null) => {
|
|
if (active) activeViewer?.setMapEditorPreviewInstances(mapEditorDraftPreviews);
|
|
};
|
|
if (viewer.current) update(viewer.current);
|
|
else void viewerReady.current?.then(update);
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [mapEditorDraftPreviews]);
|
|
const selectionName = (selection: PlacedMapSelection): string =>
|
|
selection.kind === 'builtin'
|
|
? PHYSICAL_MAP_PRESET_LABELS[selection.config.preset]
|
|
: (projectMaps.find((map) => map.descriptorPath === selection.descriptorPath)?.name ??
|
|
selection.descriptorPath.split('/').at(-2) ??
|
|
'工程地图');
|
|
const uniqueMapAssetName = (
|
|
base: string,
|
|
assets: readonly PlacedMapAsset[] = placedMapAssetsRef.current,
|
|
excludedId?: string,
|
|
): string => {
|
|
const names = new Set(
|
|
assets.filter((asset) => asset.id !== excludedId).map((asset) => asset.name),
|
|
);
|
|
if (!names.has(base)) return base;
|
|
let index = 2;
|
|
while (names.has(`${base} ${index}`)) index += 1;
|
|
return `${base} ${index}`;
|
|
};
|
|
const setMapScene = useCallback(
|
|
(assets: PlacedMapAsset[], activeId: string | undefined, selection?: MapSelection) => {
|
|
const active = activeId ? assets.find((asset) => asset.id === activeId) : undefined;
|
|
const nextSelection = selection ?? active?.selection ?? DEFAULT_MAP_SELECTION;
|
|
if (activeMapAssetIdRef.current !== active?.id) {
|
|
viewer.current?.selectParametricMapAsset(null);
|
|
viewer.current?.selectMapEditorObject(null);
|
|
editorInteraction.current = null;
|
|
}
|
|
placedMapAssetsRef.current = assets;
|
|
setPlacedMapAssets(assets);
|
|
activeMapAssetIdRef.current = active?.id;
|
|
setActiveMapAssetId(active?.id);
|
|
mapSelectionRef.current = nextSelection;
|
|
setMapSelection(nextSelection);
|
|
if (nextSelection.kind === 'builtin')
|
|
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
|
|
},
|
|
[],
|
|
);
|
|
const focusMapProperties = useCallback(() => {
|
|
setRightOpen(true);
|
|
}, []);
|
|
const previewVisualMapScene = useCallback(
|
|
(assets: readonly PlacedMapAsset[], reload: boolean) => {
|
|
const current = manifest.current;
|
|
const activeViewer = viewer.current;
|
|
if (!current || !activeViewer) return;
|
|
try {
|
|
const visuals = visualMapAssets(current, assets);
|
|
if (reload)
|
|
void activeViewer.setVisualMaps(visuals).catch((error) => {
|
|
console.warn('[MuJoCo] 视觉地图草稿预览失败', error);
|
|
});
|
|
else activeViewer.setVisualMapTransforms(visuals);
|
|
} catch (error) {
|
|
console.warn('[MuJoCo] 无法解析视觉地图草稿', error);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
const stageMapSelectionDraft = useCallback(
|
|
(selection: PlacedMapSelection) => {
|
|
if (useAppStore.getState().loading || loadInFlight.current) return;
|
|
const activeId = activeMapAssetIdRef.current;
|
|
const active = activeId
|
|
? placedMapAssetsRef.current.find((asset) => asset.id === activeId)
|
|
: undefined;
|
|
if (!active || active.selection.kind !== selection.kind) return;
|
|
if (
|
|
active.selection.kind === 'project' &&
|
|
selection.kind === 'project' &&
|
|
active.selection.descriptorPath !== selection.descriptorPath
|
|
)
|
|
return;
|
|
const assets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, selection);
|
|
setMapScene(assets, active.id, selection);
|
|
if (selection.kind === 'project') {
|
|
previewVisualMapScene(assets, false);
|
|
const committed = readEditorDocument(selection);
|
|
setEditorDocument(committed);
|
|
previewEditorDocument(editorDraftsRef.current.get(selection.descriptorPath) ?? committed);
|
|
}
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
},
|
|
[previewEditorDocument, previewVisualMapScene, readEditorDocument, setMapScene],
|
|
);
|
|
const activateMapAsset = useCallback(
|
|
(id: string, objectId?: string) => {
|
|
if (useAppStore.getState().loading || loadInFlight.current) return;
|
|
const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id);
|
|
if (!asset) return;
|
|
pendingEditorObjectId.current = objectId;
|
|
viewer.current?.selectParametricMapAsset(null);
|
|
viewer.current?.selectMapEditorObject(null);
|
|
editorInteraction.current?.onSelect(null);
|
|
setMapScene(placedMapAssetsRef.current, id, asset.selection);
|
|
const committedEditorDocument = readEditorDocument(asset.selection);
|
|
const previewDocument =
|
|
asset.selection.kind === 'project'
|
|
? (editorDraftsRef.current.get(asset.selection.descriptorPath) ?? committedEditorDocument)
|
|
: null;
|
|
setEditorDocument(committedEditorDocument);
|
|
previewEditorDocument(previewDocument);
|
|
if (objectId) {
|
|
viewer.current?.selectMapEditorObject(objectId);
|
|
if (editorInteraction.current) {
|
|
editorInteraction.current.onSelect(objectId);
|
|
pendingEditorObjectId.current = undefined;
|
|
}
|
|
}
|
|
setEditorSelection(
|
|
objectId
|
|
? { kind: 'map-object', mapAssetId: id, objectId }
|
|
: { kind: 'map', mapAssetId: id },
|
|
);
|
|
useAppStore.getState().setSelection(null);
|
|
focusMapProperties();
|
|
},
|
|
[focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene],
|
|
);
|
|
useEffect(() => {
|
|
const interaction = (mapAssetId: string, objectId: string) =>
|
|
activateMapAsset(mapAssetId, objectId);
|
|
mapEditorPreviewInteraction.current = interaction;
|
|
return () => {
|
|
if (mapEditorPreviewInteraction.current === interaction)
|
|
mapEditorPreviewInteraction.current = () => {};
|
|
};
|
|
}, [activateMapAsset]);
|
|
const selectCompiledEditableMapObject = useCallback(
|
|
(bodyName: string): boolean => {
|
|
const current = manifest.current;
|
|
if (!current || useAppStore.getState().loading || loadInFlight.current) return false;
|
|
const target = findCompiledEditableMapPick(bodyName, current, placedMapAssetsRef.current);
|
|
if (!target) return false;
|
|
activateMapAsset(target.mapAssetId, target.objectId);
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
return true;
|
|
},
|
|
[activateMapAsset],
|
|
);
|
|
useEffect(() => {
|
|
compiledMapBodyInteraction.current = selectCompiledEditableMapObject;
|
|
return () => {
|
|
if (compiledMapBodyInteraction.current === selectCompiledEditableMapObject)
|
|
compiledMapBodyInteraction.current = () => false;
|
|
};
|
|
}, [selectCompiledEditableMapObject]);
|
|
const selectParametricMapInViewport = useCallback(
|
|
(id: string | null) => {
|
|
if (!id) return;
|
|
const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id);
|
|
if (!asset || asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none')
|
|
return;
|
|
activateMapAsset(id);
|
|
if (activeMapAssetIdRef.current !== id) return;
|
|
viewer.current?.selectParametricMapAsset(id);
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
useAppStore.getState().setSelection(null);
|
|
setEditorSelection({ kind: 'map', mapAssetId: id });
|
|
setRightOpen(true);
|
|
},
|
|
[activateMapAsset],
|
|
);
|
|
const updateParametricMapTransform = useCallback(
|
|
(id: string, position: [number, number], yawDeg: number) => {
|
|
if (useAppStore.getState().loading || loadInFlight.current) return;
|
|
const next = transformParametricMapAsset(placedMapAssetsRef.current, id, position, yawDeg);
|
|
if (next === placedMapAssetsRef.current) return;
|
|
const assets = [...next];
|
|
const active = assets.find((asset) => asset.id === id);
|
|
if (!active) return;
|
|
setMapScene(assets, id, active.selection);
|
|
setEditorDocument(null);
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
setEditorSelection({ kind: 'map', mapAssetId: id });
|
|
setRightOpen(true);
|
|
},
|
|
[setMapScene],
|
|
);
|
|
useEffect(() => {
|
|
const interaction = {
|
|
onSelect: selectParametricMapInViewport,
|
|
onTransform: updateParametricMapTransform,
|
|
};
|
|
parametricMapInteraction.current = interaction;
|
|
return () => {
|
|
if (parametricMapInteraction.current === interaction) parametricMapInteraction.current = null;
|
|
};
|
|
}, [selectParametricMapInViewport, updateParametricMapTransform]);
|
|
const createEditableScene = async (
|
|
type: EditableMapObjectType,
|
|
position?: [number, number, number],
|
|
placementMode: MapObjectPlacementMode = 'auto_ground',
|
|
externalSupportTop = 0,
|
|
): Promise<boolean> => {
|
|
const current = manifest.current;
|
|
const entryPath = state.selectedEntry;
|
|
const entry = state.entries.find((candidate) => candidate.path === entryPath);
|
|
if (!current || !entryPath || !entry || loadInFlight.current) return false;
|
|
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
|
|
state.setDiagnostic(
|
|
diagnostic('模型编译', new Error('原生 URDF 不能创建 MJCF 场景,请切换为转换模式')),
|
|
);
|
|
return false;
|
|
}
|
|
if (current.files.length + 3 > DEFAULT_IMPORT_LIMITS.maxFiles) {
|
|
state.setDiagnostic(
|
|
diagnostic('文件系统', new Error('工程文件数量已达到上限,无法创建场景')),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
let index = 1;
|
|
while (
|
|
current.maps.some((map) => map.id === `scene_${index}`) ||
|
|
current.files.some((file) => file.path.startsWith(`maps/scene_${index}/`))
|
|
)
|
|
index += 1;
|
|
const mapId = `scene_${index}`;
|
|
const directory = `maps/${mapId}`;
|
|
const descriptorPath = `${directory}/map.json`;
|
|
const physicsPath = `${directory}/physics/world.xml`;
|
|
const authoringPath = `${directory}/authoring/map.scene.json`;
|
|
const document: EditableMapDocument = {
|
|
schemaVersion: 1,
|
|
mapId,
|
|
revision: 0,
|
|
objects: [],
|
|
spawnPoints: [],
|
|
};
|
|
const definition = {
|
|
schemaVersion: 2 as const,
|
|
id: mapId,
|
|
name: `场景 ${index}`,
|
|
coordinateSystem: { units: 'm' as const, up: 'Z' as const, forward: '+X' as const },
|
|
physics: { source: 'physics/world.xml' },
|
|
authoring: { source: 'authoring/map.scene.json' },
|
|
spawnPoints: [],
|
|
};
|
|
const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
|
|
const physicsData = compileEditableMapDocument(document);
|
|
const authoringData = encodeEditableMapDocument(document);
|
|
const source = current.files.find((file) => file.path === entryPath)?.source ?? 'file';
|
|
const files = [
|
|
...current.files,
|
|
{
|
|
path: descriptorPath,
|
|
data: descriptorData,
|
|
size: descriptorData.byteLength,
|
|
source,
|
|
mimeType: 'application/json',
|
|
},
|
|
{
|
|
path: physicsPath,
|
|
data: physicsData,
|
|
size: physicsData.byteLength,
|
|
source,
|
|
mimeType: 'application/xml',
|
|
},
|
|
{
|
|
path: authoringPath,
|
|
data: authoringData,
|
|
size: authoringData.byteLength,
|
|
source,
|
|
mimeType: 'application/json',
|
|
},
|
|
];
|
|
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
|
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) {
|
|
state.setDiagnostic(diagnostic('文件系统', new Error('创建场景后工程总大小超过 512 MiB')));
|
|
return false;
|
|
}
|
|
try {
|
|
const maps = discoverMapEntries(files);
|
|
const candidate: ProjectManifest = { ...current, files, maps, totalBytes };
|
|
const selection: PlacedMapSelection = {
|
|
kind: 'project',
|
|
descriptorPath,
|
|
positionX: 0,
|
|
positionY: 0,
|
|
yawDeg: 0,
|
|
};
|
|
const placed = createPlacedMapAsset(selection, uniqueMapAssetName(definition.name));
|
|
pendingMapAsset.current = { type, position, placementMode, externalSupportTop };
|
|
manifest.current = candidate;
|
|
provisionalMapFilesRef.current.set(descriptorPath, [
|
|
descriptorPath,
|
|
physicsPath,
|
|
authoringPath,
|
|
]);
|
|
setProjectMaps(maps);
|
|
setCommittedEditorDocuments(manifestEditorDocuments(candidate));
|
|
setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection);
|
|
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
|
|
setRightOpen(true);
|
|
setEditorDocument(document);
|
|
previewEditorDocument(document);
|
|
state.setProject(
|
|
candidate.name,
|
|
candidate.files.map((file) => ({ path: file.path, size: file.size })),
|
|
candidate.entries,
|
|
entryPath,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
return true;
|
|
} catch (error) {
|
|
pendingMapAsset.current = null;
|
|
state.setDiagnostic(diagnostic('文件系统', error, descriptorPath));
|
|
return false;
|
|
}
|
|
};
|
|
const addCertifiedMapAsset = async (
|
|
type: EditableMapObjectType,
|
|
position?: [number, number, number],
|
|
placementMode: MapObjectPlacementMode = 'auto_ground',
|
|
) => {
|
|
if (state.loading || loadInFlight.current) return;
|
|
focusMapProperties();
|
|
const worldPosition = position;
|
|
const externalSupportTop =
|
|
position && placementMode === 'gravity'
|
|
? (mapSceneSurfaceHeightAt(placedMapAssetsRef.current, position[0], position[1]) ?? 0)
|
|
: 0;
|
|
const interaction = editorInteraction.current;
|
|
if (interaction) {
|
|
let localPosition = worldPosition;
|
|
const selection = mapSelectionRef.current;
|
|
if (worldPosition && selection.kind === 'project')
|
|
localPosition = mapWorldPointToLocal(worldPosition, selection);
|
|
interaction.onAddAsset(type, localPosition, placementMode, externalSupportTop);
|
|
return;
|
|
}
|
|
await createEditableScene(type, worldPosition, placementMode, externalSupportTop);
|
|
};
|
|
const appendMapSelection = (selection: PlacedMapSelection, requestedName?: string): boolean => {
|
|
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
|
|
if (!entry || state.loading || loadInFlight.current) return false;
|
|
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
|
|
state.setDiagnostic(
|
|
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
|
|
);
|
|
return false;
|
|
}
|
|
const name = uniqueMapAssetName(requestedName ?? selectionName(selection));
|
|
const placed = createPlacedMapAsset(selection, name);
|
|
const assets = [...placedMapAssetsRef.current, placed];
|
|
setMapScene(assets, placed.id, selection);
|
|
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
|
|
setRightOpen(true);
|
|
if (selection.kind === 'project') previewVisualMapScene(assets, true);
|
|
setEditorDocument(readEditorDocument(selection));
|
|
viewer.current?.setMapEditorDocument(null);
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
return true;
|
|
};
|
|
const stageMapAssetRemoval = (id: string) => {
|
|
const previousAssets = placedMapAssetsRef.current;
|
|
const previousActiveId = activeMapAssetIdRef.current;
|
|
const nextAssets = previousAssets.filter((asset) => asset.id !== id);
|
|
if (nextAssets.length === previousAssets.length) return false;
|
|
const nextActive =
|
|
previousActiveId === id
|
|
? (nextAssets.at(-1) ?? undefined)
|
|
: nextAssets.find((asset) => asset.id === previousActiveId);
|
|
setMapScene(nextAssets, nextActive?.id, nextActive?.selection);
|
|
setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null);
|
|
setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null);
|
|
viewer.current?.setMapEditorDocument(null);
|
|
return true;
|
|
};
|
|
const removePlacedMapAsset = (id: string) => {
|
|
if (state.loading || loadInFlight.current) return;
|
|
const target = placedMapAssetsRef.current.find((asset) => asset.id === id);
|
|
if (!target || !stageMapAssetRemoval(id)) return;
|
|
if (target.selection.kind === 'project')
|
|
previewVisualMapScene(placedMapAssetsRef.current, true);
|
|
adapter.current.setPaused(true);
|
|
useAppStore.getState().setPaused(true);
|
|
};
|
|
const performMapSceneCommit = async (
|
|
requestedDrafts: ReadonlyMap<string, EditableMapDocument> = editorDraftsRef.current,
|
|
): Promise<boolean> => {
|
|
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
|
|
const current = manifest.current;
|
|
if (!entry || !current || state.loading || loadInFlight.current) return false;
|
|
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
|
|
state.setDiagnostic(
|
|
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const submittedDrafts = new Map(
|
|
[...requestedDrafts].map(([path, document]) => [path, structuredClone(document)]),
|
|
);
|
|
let candidate: ProjectManifest;
|
|
try {
|
|
candidate = materializeEditableMapDrafts(current, submittedDrafts);
|
|
const referencedDescriptors = new Set(
|
|
placedMapAssetsRef.current.flatMap((asset) =>
|
|
asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [],
|
|
),
|
|
);
|
|
const omittedPaths = new Set<string>();
|
|
for (const [descriptorPath, paths] of provisionalMapFilesRef.current)
|
|
if (!referencedDescriptors.has(descriptorPath))
|
|
for (const path of paths) omittedPaths.add(path);
|
|
candidate = omitManifestFiles(candidate, omittedPaths);
|
|
if (candidate.totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
|
|
throw new Error('应用场景草稿后工程总大小超过 512 MiB');
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('模型编译', error, entry.path));
|
|
return false;
|
|
}
|
|
|
|
manifest.current = candidate;
|
|
const loaded = await loadEntry(entry.path, undefined, placedMapAssetsRef.current);
|
|
if (!loaded) {
|
|
manifest.current = current;
|
|
return false;
|
|
}
|
|
|
|
const loadedManifest = manifest.current ?? candidate;
|
|
const maps = discoverMapEntries(loadedManifest.files);
|
|
const committed: ProjectManifest = { ...loadedManifest, maps };
|
|
manifest.current = committed;
|
|
provisionalMapFilesRef.current.clear();
|
|
const remainingDrafts = new Map(editorDraftsRef.current);
|
|
for (const [path, submitted] of submittedDrafts) {
|
|
const latest = remainingDrafts.get(path);
|
|
if (latest && JSON.stringify(latest) === JSON.stringify(submitted))
|
|
remainingDrafts.delete(path);
|
|
}
|
|
editorDraftsRef.current = remainingDrafts;
|
|
setEditorDrafts(remainingDrafts);
|
|
setEditorSessionStates((currentStates) => {
|
|
const nextStates = new Map(currentStates);
|
|
for (const path of submittedDrafts.keys())
|
|
if (!remainingDrafts.has(path)) nextStates.delete(path);
|
|
return nextStates;
|
|
});
|
|
setProjectMaps(maps);
|
|
setCommittedEditorDocuments(manifestEditorDocuments(committed));
|
|
const committedEditorDocument = readEditorDocument(mapSelectionRef.current);
|
|
const remainingEditorDraft =
|
|
mapSelectionRef.current.kind === 'project'
|
|
? remainingDrafts.get(mapSelectionRef.current.descriptorPath)
|
|
: undefined;
|
|
setEditorDocument(committedEditorDocument);
|
|
previewEditorDocument(remainingEditorDraft ?? committedEditorDocument);
|
|
state.setProject(
|
|
committed.name,
|
|
committed.files.map((file) => ({ path: file.path, size: file.size })),
|
|
committed.entries,
|
|
entry.path,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
const selectedMapAssetId = activeMapAssetIdRef.current;
|
|
setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null);
|
|
return true;
|
|
};
|
|
const commitMapScene = async (
|
|
requestedDrafts: ReadonlyMap<string, EditableMapDocument> = editorDraftsRef.current,
|
|
): Promise<boolean> => {
|
|
if (state.loading || loadInFlight.current) return false;
|
|
setMapCommitState('submitting');
|
|
try {
|
|
const applied = await performMapSceneCommit(requestedDrafts);
|
|
setMapCommitState(applied ? 'idle' : 'failed');
|
|
return applied;
|
|
} catch (error) {
|
|
setMapCommitState('failed');
|
|
throw error;
|
|
}
|
|
};
|
|
const discardMapSceneDraft = () => {
|
|
if (state.loading || loadInFlight.current) return;
|
|
setMapCommitState('idle');
|
|
editorInteraction.current?.onDiscard();
|
|
clearEditorDrafts();
|
|
const omittedPaths = new Set<string>();
|
|
for (const paths of provisionalMapFilesRef.current.values())
|
|
for (const path of paths) omittedPaths.add(path);
|
|
if (manifest.current && omittedPaths.size) {
|
|
const restoredManifest = omitManifestFiles(manifest.current, omittedPaths);
|
|
manifest.current = restoredManifest;
|
|
provisionalMapFilesRef.current.clear();
|
|
setProjectMaps(restoredManifest.maps);
|
|
setCommittedEditorDocuments(manifestEditorDocuments(restoredManifest));
|
|
state.setProject(
|
|
restoredManifest.name,
|
|
restoredManifest.files.map((file) => ({ path: file.path, size: file.size })),
|
|
restoredManifest.entries,
|
|
state.selectedEntry,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
}
|
|
const assets = restoreAppliedMapScene(appliedMapAssetsRef.current);
|
|
const active =
|
|
assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1);
|
|
setMapScene(assets, active?.id, active?.selection);
|
|
setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null);
|
|
previewVisualMapScene(assets, true);
|
|
setEditorDocument(active ? readEditorDocument(active.selection) : null);
|
|
viewer.current?.setMapEditorDocument(null);
|
|
};
|
|
const applyMapSelection = (value: MapSelection) => {
|
|
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
|
|
if (!entry) return;
|
|
const activeId = activeMapAssetIdRef.current;
|
|
const active = activeId
|
|
? placedMapAssetsRef.current.find((asset) => asset.id === activeId)
|
|
: undefined;
|
|
if (value.kind === 'none') {
|
|
if (!active) return;
|
|
removePlacedMapAsset(active.id);
|
|
void commitMapScene();
|
|
return;
|
|
}
|
|
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
|
|
state.setDiagnostic(
|
|
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
|
|
);
|
|
return;
|
|
}
|
|
if (!active) {
|
|
if (appendMapSelection(value)) void commitMapScene();
|
|
return;
|
|
}
|
|
const nextName = uniqueMapAssetName(
|
|
selectionName(value),
|
|
placedMapAssetsRef.current,
|
|
active.id,
|
|
);
|
|
const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName);
|
|
setMapScene(nextAssets, active.id, value);
|
|
setEditorSelection({ kind: 'map', mapAssetId: active.id });
|
|
setEditorDocument(readEditorDocument(value));
|
|
viewer.current?.setMapEditorDocument(null);
|
|
void commitMapScene();
|
|
};
|
|
const selectTerrainAsset = (preset: SystemTerrainPreset, position?: [number, number, number]) => {
|
|
const positionX = position ? Math.round(position[0] * 10) / 10 : 0;
|
|
const positionY = position ? Math.round(position[1] * 10) / 10 : 0;
|
|
void appendMapSelection(
|
|
{
|
|
kind: 'builtin',
|
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, positionX, positionY },
|
|
},
|
|
PHYSICAL_MAP_PRESET_LABELS[preset],
|
|
);
|
|
};
|
|
const addProjectMapAsset = (descriptorPath: string, position?: [number, number, number]) => {
|
|
const map = projectMaps.find((candidate) => candidate.descriptorPath === descriptorPath);
|
|
if (!map) return;
|
|
appendMapSelection(
|
|
{
|
|
kind: 'project',
|
|
descriptorPath,
|
|
positionX: position ? Math.round(position[0] * 10) / 10 : 0,
|
|
positionY: position ? Math.round(position[1] * 10) / 10 : 0,
|
|
yawDeg: 0,
|
|
},
|
|
map.name,
|
|
);
|
|
};
|
|
const applyEditorDocument = async (document: EditableMapDocument): Promise<boolean> => {
|
|
const selection = mapSelectionRef.current;
|
|
if (selection.kind !== 'project' || !manifest.current || !state.selectedEntry) return false;
|
|
updateEditorDraft(selection.descriptorPath, document, true);
|
|
return commitMapScene(new Map(editorDraftsRef.current));
|
|
};
|
|
const convertSelectedMap = async (): Promise<boolean> => {
|
|
const selection = mapSelectionRef.current;
|
|
const current = manifest.current;
|
|
const entryPath = state.selectedEntry;
|
|
if (selection.kind !== 'project' || !current || !entryPath) return false;
|
|
let diagnosticPath = selection.descriptorPath;
|
|
try {
|
|
const resolved = resolveProjectMap(current, selection.descriptorPath);
|
|
if (resolved.authoringPath) {
|
|
setEditorDocument(readEditorDocument(selection));
|
|
return true;
|
|
}
|
|
if (!resolved.physicsPath)
|
|
throw new Error('只有包含 physics.source 的静态 MJCF 地图可以转换');
|
|
diagnosticPath = resolved.physicsPath;
|
|
const physicsFile = current.files.find((file) => file.path === resolved.physicsPath);
|
|
const descriptorFile = current.files.find((file) => file.path === resolved.descriptorPath);
|
|
if (!physicsFile || !descriptorFile) throw new Error('地图物理层或描述文件不存在');
|
|
const document = importEditableMapDocument(physicsFile.data, resolved.definition);
|
|
const authoringReference = 'authoring/map.scene.json';
|
|
const authoringPath = resolveProjectAssetPath(resolved.descriptorPath, authoringReference);
|
|
if (current.files.some((file) => file.path === authoringPath))
|
|
throw new Error(`目标创作层已存在但未被地图引用:${authoringPath}`);
|
|
if (current.files.length >= DEFAULT_IMPORT_LIMITS.maxFiles)
|
|
throw new Error('工程文件数量已达到上限,无法创建创作层');
|
|
|
|
const definition = {
|
|
...resolved.definition,
|
|
schemaVersion: 2 as const,
|
|
authoring: { source: authoringReference },
|
|
spawnPoints: document.spawnPoints,
|
|
};
|
|
const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
|
|
const physicsData = compileEditableMapDocument(document);
|
|
const authoringData = encodeEditableMapDocument(document);
|
|
const files = current.files.map((file) => {
|
|
if (file.path === resolved.descriptorPath)
|
|
return { ...file, data: descriptorData, size: descriptorData.byteLength };
|
|
if (file.path === resolved.physicsPath)
|
|
return { ...file, data: physicsData, size: physicsData.byteLength };
|
|
return file;
|
|
});
|
|
files.push({
|
|
path: authoringPath,
|
|
data: authoringData,
|
|
size: authoringData.byteLength,
|
|
source: descriptorFile.source,
|
|
mimeType: 'application/json',
|
|
});
|
|
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
|
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
|
|
throw new Error('创建创作层后工程总大小超过 512 MiB');
|
|
const candidate: ProjectManifest = {
|
|
...current,
|
|
files,
|
|
maps: discoverMapEntries(files),
|
|
totalBytes,
|
|
};
|
|
manifest.current = candidate;
|
|
const loaded = await loadEntry(entryPath);
|
|
if (!loaded) {
|
|
manifest.current = current;
|
|
return false;
|
|
}
|
|
const loadedManifest = manifest.current ?? candidate;
|
|
const maps = discoverMapEntries(loadedManifest.files);
|
|
const committed = { ...loadedManifest, maps };
|
|
manifest.current = committed;
|
|
setProjectMaps(maps);
|
|
setCommittedEditorDocuments(manifestEditorDocuments(committed));
|
|
setEditorDocument(document);
|
|
state.setProject(
|
|
committed.name,
|
|
committed.files.map((file) => ({ path: file.path, size: file.size })),
|
|
committed.entries,
|
|
entryPath,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
const selectedMapAssetId = activeMapAssetIdRef.current;
|
|
setEditorSelection(
|
|
selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null,
|
|
);
|
|
notify('已创建可编辑地图副本', authoringPath);
|
|
return true;
|
|
} catch (error) {
|
|
manifest.current = current;
|
|
state.setDiagnostic(diagnostic('模型编译', error, diagnosticPath));
|
|
return false;
|
|
}
|
|
};
|
|
const exportSelectedMap = async () => {
|
|
const selection = mapSelectionRef.current;
|
|
if (selection.kind !== 'project' || !manifest.current) return;
|
|
try {
|
|
const { exportMapPackage } = await import('../map/editor/MapPackageExporter');
|
|
const entry = projectMaps.find((map) => map.descriptorPath === selection.descriptorPath);
|
|
downloadBytes(
|
|
exportMapPackage(manifest.current, selection.descriptorPath),
|
|
`${entry?.id ?? 'map'}-map.zip`,
|
|
'application/zip',
|
|
);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('文件系统', error, selection.descriptorPath));
|
|
}
|
|
};
|
|
const changeFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
|
void ingest(Array.from(event.target.files ?? []));
|
|
event.target.value = '';
|
|
};
|
|
const resetDragState = () => {
|
|
dragDepth.current = 0;
|
|
setDragActive(false);
|
|
setMapAssetDropTarget(undefined);
|
|
};
|
|
const dragEnter = (event: DragEvent) => {
|
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
|
const bounds = viewportShell.current?.getBoundingClientRect();
|
|
if (bounds)
|
|
setMapAssetDropTarget({
|
|
left: event.clientX - bounds.left,
|
|
top: event.clientY - bounds.top,
|
|
position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null,
|
|
});
|
|
return;
|
|
}
|
|
if (hasTransferType(event.dataTransfer, 'Files')) {
|
|
dragDepth.current += 1;
|
|
if (!state.loading) setDragActive(true);
|
|
}
|
|
};
|
|
const dragLeave = (event: DragEvent) => {
|
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
|
setMapAssetDropTarget(undefined);
|
|
return;
|
|
}
|
|
if (!hasTransferType(event.dataTransfer, 'Files')) return;
|
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
if (dragDepth.current === 0) setDragActive(false);
|
|
};
|
|
const dragOver = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
|
const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null;
|
|
const bounds = viewportShell.current?.getBoundingClientRect();
|
|
if (bounds)
|
|
setMapAssetDropTarget({
|
|
left: event.clientX - bounds.left,
|
|
top: event.clientY - bounds.top,
|
|
position,
|
|
});
|
|
event.dataTransfer.dropEffect = position ? 'copy' : 'none';
|
|
return;
|
|
}
|
|
if (hasTransferType(event.dataTransfer, 'Files'))
|
|
event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy';
|
|
};
|
|
const drop = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
resetDragState();
|
|
const mapAsset = decodeMapLibraryDragPayload(event.dataTransfer.getData(MAP_LIBRARY_DRAG_MIME));
|
|
if (mapAsset) {
|
|
event.stopPropagation();
|
|
const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY);
|
|
if (!position) return;
|
|
if (mapAsset.kind === 'certified')
|
|
void addCertifiedMapAsset(mapAsset.type, position, mapAsset.placementMode);
|
|
else if (mapAsset.kind === 'terrain') selectTerrainAsset(mapAsset.preset, position);
|
|
else addProjectMapAsset(mapAsset.descriptorPath, position);
|
|
return;
|
|
}
|
|
if (state.loading || importInFlight.current) return;
|
|
// 必须在 drop 用户手势仍有效时读取句柄;Chromium 随后会清空 DataTransfer。
|
|
const filesPromise = filesFromDrop(event.dataTransfer.items, event.dataTransfer.files);
|
|
importInFlight.current = true;
|
|
state.setLoading(true);
|
|
setImportProgress({
|
|
title: '正在导入工程',
|
|
label: '扫描拖放的文件与文件夹',
|
|
value: 0.02,
|
|
});
|
|
void (async () => {
|
|
try {
|
|
const files = await filesPromise;
|
|
await ingest(files, true);
|
|
} catch (error) {
|
|
importInFlight.current = false;
|
|
setImportProgress(undefined);
|
|
state.setLoading(false);
|
|
state.setDiagnostic(diagnostic('导入', error));
|
|
}
|
|
})();
|
|
};
|
|
const togglePause = () => {
|
|
const value = !state.paused;
|
|
state.setPaused(value);
|
|
adapter.current.setPaused(value);
|
|
};
|
|
const reset = () => {
|
|
adapter.current.setPaused(true);
|
|
adapter.current.reset();
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
state.setPaused(true);
|
|
};
|
|
const singleStep = () => {
|
|
adapter.current.singleStep();
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const changeSpeed = (value: number) => {
|
|
state.setSpeed(value);
|
|
adapter.current.setSpeed(value);
|
|
};
|
|
const mode = (value: InteractionMode) => state.setMode(value);
|
|
const resetJoints = () => {
|
|
adapter.current.resetJoints();
|
|
state.setPaused(true);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const toggleJointLimits = () => {
|
|
const next = !ignoreJointLimits;
|
|
adapter.current.setIgnoreJointLimits(next);
|
|
setIgnoreJointLimits(next);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const setActuator = (id: number, value: number) => {
|
|
adapter.current.setActuator(id, value);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const setActuatorParameters = (id: number, parameters: ActuatorParameters) => {
|
|
if (!adapter.current.setActuatorParameters(id, parameters)) return;
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
try {
|
|
setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
|
|
} catch (error) {
|
|
console.warn('[MuJoCo] 无法刷新驱动器参数源码', error);
|
|
}
|
|
};
|
|
const setJoint = (id: number, value: number) => {
|
|
adapter.current.setJointPosition(id, value);
|
|
state.setPaused(true);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const loadControllerSource = async (source: string, path: string) => {
|
|
state.setLoading(true);
|
|
setImportProgress({
|
|
title: '正在加载控制器',
|
|
label: '初始化 Python 运行时',
|
|
detail: path,
|
|
value: 0.5,
|
|
});
|
|
state.setDiagnostic(undefined);
|
|
try {
|
|
const status = await adapter.current.loadPythonController(source, path);
|
|
setControllerStatus(status);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
notify('Python 控制器已加载', `${status.name} · ${status.controlHz} Hz`);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, path));
|
|
} finally {
|
|
setImportProgress(undefined);
|
|
state.setLoading(false);
|
|
}
|
|
};
|
|
const loadControllerPath = (path: string) => {
|
|
const file = manifest.current?.files.find((candidate) => candidate.path === path);
|
|
if (!file) {
|
|
state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到控制脚本'), path));
|
|
return;
|
|
}
|
|
setSelectedControllerPath(path);
|
|
void loadControllerSource(new TextDecoder().decode(file.data), path);
|
|
};
|
|
const importController = (file: File) => {
|
|
void (async () => {
|
|
try {
|
|
if (!/\.py$/i.test(file.name)) throw new Error('请选择 .py 文件');
|
|
if (file.size > 1024 * 1024) throw new Error('Python 控制脚本不能超过 1 MiB');
|
|
const path = normalizeProjectPath(file.name),
|
|
data = new Uint8Array(await file.arrayBuffer());
|
|
if (manifest.current) {
|
|
const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
|
|
files = manifest.current.files.slice(),
|
|
entry = {
|
|
path,
|
|
data,
|
|
size: data.byteLength,
|
|
source: 'file' as const,
|
|
mimeType: file.type || 'text/x-python',
|
|
};
|
|
if (index >= 0) files[index] = entry;
|
|
else files.push(entry);
|
|
manifest.current = {
|
|
...manifest.current,
|
|
files,
|
|
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
|
};
|
|
state.setProject(
|
|
manifest.current.name,
|
|
files.map(({ path: filePath, size }) => ({ path: filePath, size })),
|
|
manifest.current.entries,
|
|
manifest.current.selectedEntry,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
}
|
|
setSelectedControllerPath(path);
|
|
await loadControllerSource(new TextDecoder().decode(data), path);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, file.name));
|
|
}
|
|
})();
|
|
};
|
|
const toggleController = (enabled: boolean) => {
|
|
adapter.current.setControllerEnabled(enabled);
|
|
const snapshot = adapter.current.snapshot() ?? undefined;
|
|
setControllerStatus(snapshot?.controller);
|
|
setPolicyStatus(snapshot?.rlPolicy);
|
|
state.setSnapshot(snapshot);
|
|
};
|
|
const sendControllerCommand = (command: ControllerCommand) => {
|
|
try {
|
|
adapter.current.sendControllerCommand(command);
|
|
const snapshot = adapter.current.snapshot() ?? undefined;
|
|
setControllerStatus(snapshot?.controller);
|
|
state.setSnapshot(snapshot);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, selectedControllerPath));
|
|
}
|
|
};
|
|
const removeController = () => {
|
|
adapter.current.removeController();
|
|
setControllerStatus(undefined);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const loadPolicyBytes = async (data: Uint8Array, path: string, expected?: PolicyDeployment) => {
|
|
if (policyLoadInFlight.current || loadInFlight.current)
|
|
throw new Error('模型/策略正在加载,请稍后重试');
|
|
viewer.current?.setNavigationTargetMode(false);
|
|
const previousPaused = useAppStore.getState().paused;
|
|
const previousSession = adapter.current.session;
|
|
policyLoadInFlight.current = true;
|
|
state.setLoading(true);
|
|
state.setDiagnostic(undefined);
|
|
adapter.current.setPaused(true);
|
|
state.setPaused(true);
|
|
try {
|
|
const deployment = resolvePolicyDeployment(data, expected);
|
|
if (deployment?.terrain) {
|
|
const entry = useAppStore.getState().selectedEntry;
|
|
if (!entry || !manifest.current) throw new Error('请先导入并加载Go2机器人');
|
|
if (!(await loadEntry(entry, 'mjcf', undefined, deployment, { data, path })))
|
|
throw new Error(
|
|
useAppStore.getState().diagnostic?.detail ?? '配套训练地图加载失败,策略未启用',
|
|
);
|
|
}
|
|
setImportProgress({
|
|
title: '正在加载强化学习策略',
|
|
label: '初始化 ONNX Runtime',
|
|
detail: path,
|
|
value: 0.55,
|
|
});
|
|
state.setLoading(true);
|
|
const session = adapter.current.session;
|
|
const status = deployment?.terrain
|
|
? adapter.current.snapshot()!.rlPolicy!
|
|
: await adapter.current.loadRLPolicy(data, path, deployment);
|
|
if (session !== adapter.current.session) throw new Error('模型已切换,策略加载取消');
|
|
if (deployment?.terrain) {
|
|
adapter.current.setPaused(false);
|
|
state.setPaused(false);
|
|
setShowSensorCamera(true);
|
|
viewer.current?.setShowSensorCamera(true);
|
|
}
|
|
setPolicyStatus(adapter.current.snapshot()?.rlPolicy ?? status);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
notify(
|
|
'ONNX 策略已加载',
|
|
`${status.taskName} · ${status.observationSize} → ${status.actionSize}`,
|
|
);
|
|
} catch (error) {
|
|
if (adapter.current.session === previousSession) {
|
|
adapter.current.setPaused(previousPaused);
|
|
state.setPaused(previousPaused);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
setPolicyStatus(adapter.current.snapshot()?.rlPolicy);
|
|
}
|
|
state.setDiagnostic(diagnostic('仿真', error, path));
|
|
throw error;
|
|
} finally {
|
|
policyLoadInFlight.current = false;
|
|
setImportProgress(undefined);
|
|
state.setLoading(false);
|
|
}
|
|
};
|
|
const loadPolicyPath = (path: string) => {
|
|
const file = manifest.current?.files.find((candidate) => candidate.path === path);
|
|
if (!file) {
|
|
state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到 ONNX 策略'), path));
|
|
return;
|
|
}
|
|
setSelectedPolicyPath(path);
|
|
void loadPolicyBytes(file.data, path).catch(() => {});
|
|
};
|
|
const importPolicy = async (file: File, expected?: PolicyDeployment) => {
|
|
try {
|
|
if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
|
|
if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
|
|
const project = manifest.current;
|
|
const path = normalizeProjectPath(file.name),
|
|
data = new Uint8Array(await file.arrayBuffer());
|
|
if (project !== manifest.current) throw new Error('工程已切换,策略导入取消');
|
|
const projectedBytes =
|
|
(project?.files
|
|
.filter((item) => item.path !== path)
|
|
.reduce((total, item) => total + item.size, 0) ?? 0) + data.byteLength;
|
|
if (projectedBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
|
|
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
|
|
await loadPolicyBytes(data, path, expected);
|
|
if (project && manifest.current?.id === project.id) {
|
|
const files = manifest.current.files.filter((candidate) => candidate.path !== path);
|
|
files.push({
|
|
path,
|
|
data,
|
|
size: data.byteLength,
|
|
source: 'file',
|
|
mimeType: file.type || 'application/octet-stream',
|
|
});
|
|
const totalBytes = files.reduce((total, item) => total + item.size, 0);
|
|
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
|
|
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
|
|
manifest.current = { ...manifest.current, files, totalBytes };
|
|
state.setProject(
|
|
manifest.current.name,
|
|
files.map(({ path, size }) => ({ path, size })),
|
|
manifest.current.entries,
|
|
useAppStore.getState().selectedEntry,
|
|
);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
}
|
|
setSelectedPolicyPath(path);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, file.name));
|
|
throw error;
|
|
}
|
|
};
|
|
const togglePolicy = (enabled: boolean) => {
|
|
adapter.current.setRLPolicyEnabled(enabled);
|
|
const snapshot = adapter.current.snapshot() ?? undefined;
|
|
setPolicyStatus(snapshot?.rlPolicy);
|
|
setControllerStatus(snapshot?.controller);
|
|
state.setSnapshot(snapshot);
|
|
};
|
|
const setPolicyCommand = (command: RLCommand) => {
|
|
adapter.current.setRLCommand(command);
|
|
const snapshot = adapter.current.snapshot() ?? undefined;
|
|
setPolicyStatus(snapshot?.rlPolicy);
|
|
state.setSnapshot(snapshot);
|
|
};
|
|
const removePolicy = () => {
|
|
adapter.current.removeRLPolicy();
|
|
setPolicyStatus(undefined);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const configureDataRecorder = (patch: Partial<DataRecorderConfig>) => {
|
|
try {
|
|
adapter.current.configureDataRecorder(patch);
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
|
|
}
|
|
};
|
|
const startDataRecording = () => {
|
|
adapter.current.startDataRecording();
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const stopDataRecording = () => {
|
|
adapter.current.stopDataRecording();
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const clearDataRecording = () => {
|
|
adapter.current.clearDataRecording();
|
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
|
};
|
|
const exportDataRecording = (format: 'csv' | 'json') => {
|
|
try {
|
|
const stem =
|
|
(manifest.current?.name ?? 'simulation')
|
|
.replace(/\.(?:zip|xml|urdf)$/i, '')
|
|
.replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'simulation';
|
|
downloadBytes(
|
|
adapter.current.exportDataRecording(format),
|
|
`${stem}-telemetry.${format}`,
|
|
format === 'csv' ? 'text/csv' : 'application/json',
|
|
);
|
|
notify(`遥测 ${format.toUpperCase()} 已导出`, `${stem}-telemetry.${format}`);
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
|
|
}
|
|
};
|
|
const notify = (
|
|
title: string,
|
|
detail: string,
|
|
tone: WorkbenchNotification['tone'] = 'success',
|
|
) => {
|
|
const notice: WorkbenchNotification = {
|
|
id: ++notificationId.current,
|
|
title,
|
|
detail,
|
|
tone,
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [notice, ...items].slice(0, 20));
|
|
setToast(notice);
|
|
};
|
|
const saveCachedSource = async (path: string, text: string) => {
|
|
if (!manifest.current) return;
|
|
manifest.current = upsertCachedMjcf(manifest.current, path, text);
|
|
state.setProject(
|
|
manifest.current.name,
|
|
manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
|
|
manifest.current.entries,
|
|
path,
|
|
);
|
|
notify('转换后的 MJCF 已保存到缓存', path);
|
|
await loadEntry(path);
|
|
};
|
|
const exportUrdf = () => {
|
|
if (!manifest.current || selectedFormat !== 'urdf' || !state.selectedEntry) return;
|
|
const text = readCachedText(manifest.current, state.selectedEntry);
|
|
downloadBytes(new TextEncoder().encode(text), exportedFileName(manifest.current.name, 'urdf'));
|
|
notify('URDF 已导出', state.selectedEntry);
|
|
};
|
|
const exportMjcf = () => {
|
|
try {
|
|
const data = adapter.current.exportMjcf();
|
|
downloadBytes(data, exportedFileName(manifest.current?.name ?? 'model', 'xml'));
|
|
notify('MJCF 已导出', '导出内容来自当前已编译模型');
|
|
} catch (error) {
|
|
state.setDiagnostic(diagnostic('模型编译', error, state.selectedEntry));
|
|
}
|
|
};
|
|
const toggleFullscreen = () => {
|
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
|
else if (root.current) void root.current.requestFullscreen().catch(() => {});
|
|
};
|
|
const showWorkspaceTool = (tool: WorkspaceTool) => {
|
|
setWorkspaceTool(tool);
|
|
setRightOpen(true);
|
|
};
|
|
const applyLayoutPreset = (preset: LayoutPreset) => {
|
|
if (preset === 'viewport') {
|
|
setLeftOpen(false);
|
|
setRightOpen(false);
|
|
dispatchLayoutWidths(288, 288);
|
|
} else if (preset === 'project') {
|
|
setLeftOpen(true);
|
|
setRightOpen(false);
|
|
dispatchLayoutWidths(384, 288);
|
|
} else if (preset === 'control') {
|
|
setLeftOpen(false);
|
|
setRightOpen(true);
|
|
setWorkspaceTool('controls');
|
|
dispatchLayoutWidths(288, 384);
|
|
} else {
|
|
setLeftOpen(true);
|
|
setRightOpen(true);
|
|
dispatchLayoutWidths(288, 288);
|
|
}
|
|
};
|
|
useMapEditorShortcuts({
|
|
enabled: Boolean(state.snapshot),
|
|
mapEditing: mapEditingActive,
|
|
dirty: mapSceneDirty,
|
|
loading: state.loading,
|
|
hasSelection: Boolean(editorSelection ?? state.selection),
|
|
canDelete: editorSelection?.kind === 'map-object',
|
|
canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked',
|
|
onTransformMode: changeMapTransformMode,
|
|
onFocusSelection: focusSelectedObject,
|
|
onDeleteSelection: deleteSelectedMapObject,
|
|
onToggleSnapping: () => setMapSnapping((value) => !value),
|
|
onSave: () => void commitMapScene(),
|
|
});
|
|
useEffect(() => {
|
|
const key = (event: KeyboardEvent) => {
|
|
if (event.defaultPrevented) return;
|
|
if (
|
|
document.activeElement instanceof HTMLElement &&
|
|
document.activeElement.closest('[role="dialog"]')
|
|
)
|
|
return;
|
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
|
if (isTextEditingTarget(target)) return;
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
|
|
event.preventDefault();
|
|
setCommandOpen(true);
|
|
return;
|
|
}
|
|
if (target?.closest('button')) return;
|
|
if (event.code === 'Space') {
|
|
event.preventDefault();
|
|
togglePause();
|
|
}
|
|
if (event.key === 'r') reset();
|
|
if (event.key === '1') mode('select');
|
|
if (event.key === '2') mode('joint');
|
|
if (event.key === '3') mode('force');
|
|
};
|
|
window.addEventListener('keydown', key);
|
|
return () => window.removeEventListener('keydown', key);
|
|
});
|
|
const selectedFormat = state.entries.find((entry) => entry.path === state.selectedEntry)?.format;
|
|
const commands: WorkbenchCommand[] = [
|
|
{
|
|
id: 'play',
|
|
label: state.paused ? '播放仿真' : '暂停仿真',
|
|
group: '仿真',
|
|
icon: state.paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />,
|
|
shortcut: 'Space',
|
|
disabled: !state.snapshot,
|
|
run: togglePause,
|
|
},
|
|
{
|
|
id: 'reset',
|
|
label: '重置仿真',
|
|
group: '仿真',
|
|
icon: <RotateCcw className="h-4 w-4" />,
|
|
shortcut: 'R',
|
|
disabled: !state.snapshot,
|
|
run: reset,
|
|
},
|
|
{
|
|
id: 'select',
|
|
label: '切换到选择模式',
|
|
group: '视口',
|
|
icon: <MousePointer2 className="h-4 w-4" />,
|
|
shortcut: '1',
|
|
run: () => mode('select'),
|
|
},
|
|
{
|
|
id: 'joint',
|
|
label: '切换到关节拖动',
|
|
group: '视口',
|
|
icon: <Hand className="h-4 w-4" />,
|
|
shortcut: '2',
|
|
run: () => mode('joint'),
|
|
},
|
|
{
|
|
id: 'force',
|
|
label: '切换到外力施加',
|
|
group: '视口',
|
|
icon: <Crosshair className="h-4 w-4" />,
|
|
shortcut: '3',
|
|
run: () => mode('force'),
|
|
},
|
|
{
|
|
id: 'camera',
|
|
label: '复位相机',
|
|
group: '视口',
|
|
icon: <Camera className="h-4 w-4" />,
|
|
run: () => viewer.current?.resetCamera(),
|
|
},
|
|
{
|
|
id: 'source',
|
|
label: '查看和修改缓存源代码',
|
|
group: '工程',
|
|
icon: <Code2 className="h-4 w-4" />,
|
|
disabled: !generatedMjcf,
|
|
run: () => setSourceOpen(true),
|
|
},
|
|
{
|
|
id: 'export-urdf',
|
|
label: '导出 URDF 文件',
|
|
group: '工程',
|
|
icon: <Download className="h-4 w-4" />,
|
|
disabled: selectedFormat !== 'urdf',
|
|
run: exportUrdf,
|
|
},
|
|
{
|
|
id: 'export-mjcf',
|
|
label: '导出 MJCF 文件',
|
|
group: '工程',
|
|
icon: <Download className="h-4 w-4" />,
|
|
disabled: !state.snapshot,
|
|
run: exportMjcf,
|
|
},
|
|
{
|
|
id: 'workspace-controls',
|
|
label: '在右侧打开控制台',
|
|
group: '工具',
|
|
icon: <SlidersHorizontal className="h-4 w-4" />,
|
|
disabled: !state.snapshot,
|
|
run: () => showWorkspaceTool('controls'),
|
|
},
|
|
{
|
|
id: 'workspace-data',
|
|
label: '在右侧打开数据录制',
|
|
group: '工具',
|
|
icon: <Database className="h-4 w-4" />,
|
|
disabled: !state.snapshot,
|
|
run: () => showWorkspaceTool('data'),
|
|
},
|
|
{
|
|
id: 'left',
|
|
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
|
|
group: '布局',
|
|
icon: leftOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
|
|
run: () => setLeftOpen((value) => !value),
|
|
},
|
|
{
|
|
id: 'right',
|
|
label: rightOpen ? '隐藏右侧面板' : '显示右侧面板',
|
|
group: '布局',
|
|
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
|
|
run: () => setRightOpen((value) => !value),
|
|
},
|
|
{
|
|
id: 'theme',
|
|
label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题',
|
|
group: '外观',
|
|
icon: <SunMoon className="h-4 w-4" />,
|
|
run: () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')),
|
|
},
|
|
{
|
|
id: 'fullscreen',
|
|
label: fullscreen ? '退出全屏' : '进入全屏',
|
|
group: '布局',
|
|
icon: <Maximize className="h-4 w-4" />,
|
|
run: toggleFullscreen,
|
|
},
|
|
{
|
|
id: 'help',
|
|
label: '查看快捷键帮助',
|
|
group: '帮助',
|
|
icon: <CircleHelp className="h-4 w-4" />,
|
|
run: () => setHelpOpen(true),
|
|
},
|
|
];
|
|
const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false);
|
|
if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true);
|
|
const workspaceTools =
|
|
workspaceToolsVisited || workspaceTool ? (
|
|
<Suspense
|
|
fallback={
|
|
<div
|
|
role="status"
|
|
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
|
|
>
|
|
正在加载工作区工具…
|
|
</div>
|
|
}
|
|
>
|
|
<WorkspaceToolsPanel
|
|
active={workspaceTool ?? 'controls'}
|
|
snapshot={state.snapshot}
|
|
loading={state.loading}
|
|
ignoreJointLimits={ignoreJointLimits}
|
|
jointAdvanced={jointAdvanced}
|
|
angleUnit={angleUnit}
|
|
forceScale={forceScale}
|
|
controllerPaths={state.files
|
|
.filter((file) => /\.py$/i.test(file.path))
|
|
.map((file) => file.path)}
|
|
selectedControllerPath={selectedControllerPath}
|
|
controllerStatus={controllerStatus}
|
|
policyPaths={state.files
|
|
.filter((file) => /\.onnx$/i.test(file.path))
|
|
.map((file) => file.path)}
|
|
selectedPolicyPath={selectedPolicyPath}
|
|
policyStatus={policyStatus}
|
|
onResetJoints={resetJoints}
|
|
onToggleJointLimits={toggleJointLimits}
|
|
onToggleAdvanced={() => setJointAdvanced((value) => !value)}
|
|
onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
|
|
onActuator={setActuator}
|
|
onActuatorParameters={setActuatorParameters}
|
|
onJoint={setJoint}
|
|
onForceScale={setForceScale}
|
|
onSelectControllerPath={setSelectedControllerPath}
|
|
onLoadControllerPath={loadControllerPath}
|
|
onImportController={importController}
|
|
onToggleController={toggleController}
|
|
onControllerCommand={sendControllerCommand}
|
|
onRemoveController={removeController}
|
|
onSelectPolicyPath={setSelectedPolicyPath}
|
|
onLoadPolicyPath={loadPolicyPath}
|
|
onImportPolicy={importPolicy}
|
|
compileTrainingScene={(coordinates) => {
|
|
if (
|
|
mapSceneDirty ||
|
|
trainingDeployment ||
|
|
useAppStore.getState().loading ||
|
|
loadInFlight.current
|
|
)
|
|
throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步');
|
|
return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates);
|
|
}}
|
|
trainingSceneMaps={appliedMapAssets}
|
|
trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)}
|
|
onTogglePolicy={togglePolicy}
|
|
onPolicyCommand={setPolicyCommand}
|
|
navigationTargetMode={navigationTargetMode}
|
|
onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)}
|
|
onResetNavigationTarget={() => {
|
|
viewer.current?.setNavigationTargetMode(false);
|
|
adapter.current.resetNavigationTarget();
|
|
const snapshot = adapter.current.snapshot();
|
|
state.setSnapshot(snapshot ?? undefined);
|
|
setPolicyStatus(snapshot?.rlPolicy);
|
|
}}
|
|
onRemovePolicy={removePolicy}
|
|
onDataRecorderConfigure={configureDataRecorder}
|
|
onDataRecordingStart={startDataRecording}
|
|
onDataRecordingStop={stopDataRecording}
|
|
onDataRecordingClear={clearDataRecording}
|
|
onDataRecordingExport={exportDataRecording}
|
|
/>
|
|
</Suspense>
|
|
) : undefined;
|
|
return (
|
|
<div
|
|
ref={root}
|
|
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} cyber-workspace flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
|
|
onDragEnter={dragEnter}
|
|
onDragLeave={dragLeave}
|
|
onDragOver={dragOver}
|
|
onDragEnd={resetDragState}
|
|
onDrop={drop}
|
|
>
|
|
<WorkbenchHeader
|
|
loading={state.loading}
|
|
leftOpen={leftOpen}
|
|
rightOpen={rightOpen}
|
|
hasProject={Boolean(generatedMjcf)}
|
|
onFiles={changeFiles}
|
|
onFolder={changeFiles}
|
|
onOpenSource={() => setSourceOpen(true)}
|
|
onToggleLeft={() => setLeftOpen((value) => !value)}
|
|
onToggleRight={() => setRightOpen((value) => !value)}
|
|
endActions={
|
|
<>
|
|
<NotificationCenter
|
|
items={notifications}
|
|
onDismiss={(id) =>
|
|
setNotifications((items) => items.filter((item) => item.id !== id))
|
|
}
|
|
onClear={() => setNotifications([])}
|
|
onOpenLog={() => setDiagnosticsOpen(true)}
|
|
/>
|
|
<IconButton
|
|
tooltip="布局设置"
|
|
aria-label="布局设置"
|
|
onClick={() => setLayoutOpen(true)}
|
|
>
|
|
<PanelsTopLeft className="h-4 w-4" />
|
|
</IconButton>
|
|
</>
|
|
}
|
|
compactMenu={
|
|
<ToolbarOverflowMenu
|
|
fullscreen={fullscreen}
|
|
onCommands={() => setCommandOpen(true)}
|
|
onLayout={() => setLayoutOpen(true)}
|
|
onSettings={() => setSettingsOpen(true)}
|
|
onFullscreen={toggleFullscreen}
|
|
onHelp={() => setHelpOpen(true)}
|
|
onTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
|
|
/>
|
|
}
|
|
onCommands={() => setCommandOpen(true)}
|
|
/>
|
|
<PanelWidthBudgetContext
|
|
value={
|
|
leftOpen && rightOpen && workspaceWidth >= 1024 ? (workspaceWidth - 480) / 2 : undefined
|
|
}
|
|
>
|
|
<div
|
|
className="workbench-body relative flex min-h-0 flex-1"
|
|
data-both-sidebars={leftOpen && rightOpen}
|
|
>
|
|
<ProjectSidebar
|
|
visible={leftOpen}
|
|
projectName={state.projectName}
|
|
files={state.files}
|
|
entries={state.entries}
|
|
selectedEntry={state.selectedEntry}
|
|
snapshot={state.snapshot}
|
|
selection={editorSelection}
|
|
loading={state.loading}
|
|
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
|
|
mapSelection={mapSelection}
|
|
maps={projectMaps}
|
|
placedMaps={placedMapAssets}
|
|
pendingSceneChangeCount={sceneDraftChangeCount}
|
|
pendingSceneIds={pendingSceneIds}
|
|
editorDocuments={sceneEditorDocuments}
|
|
assetPlacementMode={assetPlacementMode}
|
|
activeTab={projectSidebarTab}
|
|
onActiveTabChange={setProjectSidebarTab}
|
|
onRemove={removeProject}
|
|
onSelectEntry={requestLoadEntry}
|
|
onSelectBody={(bodyId) => {
|
|
state.setSelection(null);
|
|
setEditorSelection({ kind: 'body', bodyId });
|
|
viewer.current?.selectMapEditorObject(null);
|
|
viewer.current?.selectParametricMapAsset(null);
|
|
viewer.current?.highlightJoint(null);
|
|
setRightOpen(true);
|
|
}}
|
|
onSelectJoint={(jointId, bodyId) => {
|
|
state.setSelection(null);
|
|
setEditorSelection({ kind: 'joint', jointId, bodyId });
|
|
viewer.current?.selectMapEditorObject(null);
|
|
viewer.current?.selectParametricMapAsset(null);
|
|
viewer.current?.highlightJoint(jointId);
|
|
setRightOpen(true);
|
|
}}
|
|
onJointHover={(jointId) =>
|
|
viewer.current?.highlightJoint(
|
|
jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null),
|
|
)
|
|
}
|
|
onAddMapAsset={(type, placementMode) =>
|
|
addCertifiedMapAsset(type, undefined, placementMode)
|
|
}
|
|
onAddProjectMap={addProjectMapAsset}
|
|
onSelectTerrain={selectTerrainAsset}
|
|
onAssetPlacementModeChange={setAssetPlacementMode}
|
|
onApplyScene={() => void commitMapScene()}
|
|
onDiscardScene={discardMapSceneDraft}
|
|
onSelectMap={activateMapAsset}
|
|
onRemoveMap={removePlacedMapAsset}
|
|
onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
|
|
/>
|
|
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
|
|
<div ref={viewerHost} className="absolute inset-0" />
|
|
<ViewportOverlayLayout
|
|
status={<ViewportHUD />}
|
|
orientation={<div ref={orientationHost} />}
|
|
view={
|
|
<div
|
|
aria-label="视图工具"
|
|
className="flex items-center gap-1 rounded-lg border border-border bg-panel/90 p-1"
|
|
>
|
|
<ViewerDisplayPopover value={displayOptions} onChange={setDisplayOptions} />
|
|
<IconButton
|
|
tooltip="相机复位"
|
|
aria-label="相机复位"
|
|
onClick={() => viewer.current?.resetCamera()}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</IconButton>
|
|
</div>
|
|
}
|
|
controls={
|
|
<div className="viewport-control-surface">
|
|
<SimulationControls
|
|
paused={state.paused}
|
|
ready={Boolean(state.snapshot)}
|
|
speed={state.speed}
|
|
loading={state.loading}
|
|
onTogglePause={togglePause}
|
|
onStep={singleStep}
|
|
onReset={reset}
|
|
onSpeed={changeSpeed}
|
|
/>
|
|
<ViewerToolDock mode={state.mode} onModeChange={mode} />
|
|
<MapViewportToolbar
|
|
visible={mapEditingActive && Boolean(state.snapshot)}
|
|
interactionActive={state.mode === 'select'}
|
|
mode={mapTransformMode}
|
|
snapping={mapSnapping}
|
|
placementMode={activePlacementMode}
|
|
hasSelectedObject={Boolean(selectedMapObject)}
|
|
allowScale={
|
|
mapSelection.kind === 'project' &&
|
|
selectedMapObject?.placementMode !== 'locked'
|
|
}
|
|
loading={state.loading}
|
|
onActivate={activateMapEditing}
|
|
onModeChange={changeMapTransformMode}
|
|
onSnappingChange={setMapSnapping}
|
|
onPlacementModeChange={changeMapPlacementMode}
|
|
onAlignToSurface={alignSelectedMapObject}
|
|
/>
|
|
</div>
|
|
}
|
|
draft={
|
|
<MapDraftStatusOverlay
|
|
visible={Boolean(state.snapshot)}
|
|
changeCount={sceneDraftChangeCount}
|
|
loading={mapCommitState === 'submitting'}
|
|
error={
|
|
mapCommitState === 'failed'
|
|
? (state.diagnostic?.summary ?? '请检查诊断后重试;草稿已保留')
|
|
: undefined
|
|
}
|
|
onCommit={() => void commitMapScene()}
|
|
onDiscard={discardMapSceneDraft}
|
|
/>
|
|
}
|
|
notices={
|
|
<div className="flex max-h-[32vh] flex-col gap-2 overflow-auto">
|
|
{state.diagnostic && (
|
|
<ErrorRecoveryPanel
|
|
key={state.diagnostic.at}
|
|
value={state.diagnostic}
|
|
onClose={() => state.setDiagnostic(undefined)}
|
|
onRetry={
|
|
state.diagnostic.category === '模型编译' && state.diagnostic.path
|
|
? () => void loadEntry(state.diagnostic!.path!)
|
|
: undefined
|
|
}
|
|
onOpenProject={() => {
|
|
setLeftOpen(true);
|
|
state.setDiagnostic(undefined);
|
|
}}
|
|
/>
|
|
)}
|
|
{!state.diagnostic && (
|
|
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
|
|
)}
|
|
</div>
|
|
}
|
|
context={
|
|
trainingDeployment && (
|
|
<div className="rounded border border-warning-border bg-panel p-2 text-xs">
|
|
训练配套物理地图(编辑器地图未更改,重载模型恢复)。
|
|
{trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
|
|
{(policyStatus?.observationSize === 81 ||
|
|
policyStatus?.observationSize === 97) && (
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={showPerceptionRays}
|
|
onChange={(e) => setShowPerceptionRays(e.target.checked)}
|
|
/>
|
|
显示避障射线
|
|
</label>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
camera={
|
|
Boolean(state.snapshot?.model.ncam) &&
|
|
(showSensorCamera ? (
|
|
<div
|
|
aria-label="摄像头画面"
|
|
ref={(node) => {
|
|
sensorCameraFrame.current = node;
|
|
viewer.current?.setSensorCameraViewportElement(node);
|
|
}}
|
|
className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl"
|
|
>
|
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-xs font-medium text-white">
|
|
<span className="flex items-center gap-1">
|
|
<Camera className="h-3 w-3" />
|
|
摄像头
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
|
onClick={() => setShowSensorCamera(false)}
|
|
>
|
|
隐藏画面
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
icon={<Camera className="h-3.5 w-3.5" />}
|
|
onClick={() => setShowSensorCamera(true)}
|
|
>
|
|
显示摄像头画面
|
|
</Button>
|
|
))
|
|
}
|
|
/>
|
|
<MapAssetDropIndicator target={mapAssetDropTarget} />
|
|
<WorkspaceOverlays
|
|
loading={state.loading}
|
|
hasSnapshot={Boolean(state.snapshot)}
|
|
dragActive={dragActive}
|
|
progress={importProgress}
|
|
/>
|
|
{state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
|
|
<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry} />
|
|
)}{' '}
|
|
</main>
|
|
<ModelControlsSidebar
|
|
visible={rightOpen}
|
|
snapshot={state.snapshot}
|
|
selection={editorSelection}
|
|
viewerSelection={state.selection}
|
|
selectedFormat={selectedFormat}
|
|
loading={state.loading}
|
|
urdfMode={urdfMode}
|
|
baseMode={baseMode}
|
|
showCollision={showCollision}
|
|
ignoreJointLimits={ignoreJointLimits}
|
|
jointAdvanced={jointAdvanced}
|
|
angleUnit={angleUnit}
|
|
mapSelection={mapSelection}
|
|
activeMapAssetId={activeMapAssetId}
|
|
activeMapAssetName={
|
|
placedMapAssets.find((asset) => asset.id === activeMapAssetId)?.name
|
|
}
|
|
mapSceneDirty={mapSceneDirty}
|
|
maps={projectMaps}
|
|
showVisualMap={showVisualMap}
|
|
showMapCollision={showMapCollision}
|
|
editorDocument={editorDocument}
|
|
editorDraftDocument={
|
|
mapSelection.kind === 'project'
|
|
? editorDrafts.get(mapSelection.descriptorPath)
|
|
: undefined
|
|
}
|
|
workspaceTool={workspaceTool}
|
|
workspaceTools={workspaceTools}
|
|
onWorkspaceToolChange={(tool) => {
|
|
setWorkspaceTool(tool);
|
|
if (tool) setRightOpen(true);
|
|
}}
|
|
onSelectJoint={(jointId, bodyId) => {
|
|
setEditorSelection({ kind: 'joint', jointId, bodyId });
|
|
viewer.current?.highlightJoint(jointId);
|
|
}}
|
|
onUrdfMode={changeUrdfMode}
|
|
onBaseMode={changeBaseMode}
|
|
onShowCollision={setShowCollision}
|
|
onResetJoints={resetJoints}
|
|
onToggleJointLimits={toggleJointLimits}
|
|
onToggleAdvanced={() => setJointAdvanced((value) => !value)}
|
|
onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
|
|
onActuator={setActuator}
|
|
onActuatorParameters={setActuatorParameters}
|
|
onJoint={setJoint}
|
|
onApplyMap={applyMapSelection}
|
|
onMapDraft={stageMapSelectionDraft}
|
|
onEditorPreview={previewEditorDocument}
|
|
onEditorDraftChange={updateEditorDraft}
|
|
onEditorApply={applyEditorDocument}
|
|
onEditorExport={exportSelectedMap}
|
|
onEditorConvert={convertSelectedMap}
|
|
onEditorBindInteraction={bindEditorInteraction}
|
|
onEditorSelect={selectEditorObject}
|
|
onEditorSessionStateChange={updateEditorSessionState}
|
|
onEditorSurfaceHeight={editorSurfaceHeight}
|
|
onMapDisplay={(visual, collision) => {
|
|
setShowVisualMap(visual);
|
|
setShowMapCollision(collision);
|
|
}}
|
|
/>
|
|
</div>
|
|
</PanelWidthBudgetContext>
|
|
{pendingUrdfPath && (
|
|
<UrdfImportOptionsDialog
|
|
open
|
|
path={pendingUrdfPath}
|
|
mountBodies={pendingUrdfMounts}
|
|
onConfirm={confirmUrdfOptions}
|
|
onSkip={skipUrdfOptions}
|
|
/>
|
|
)}
|
|
{sourceOpen && generatedMjcf && generatedMjcfPath && (
|
|
<Suspense
|
|
fallback={
|
|
<div
|
|
role="status"
|
|
className="fixed inset-0 z-[390] grid place-items-center bg-app/60 text-sm text-text-secondary backdrop-blur-sm"
|
|
>
|
|
正在加载源码编辑器…
|
|
</div>
|
|
}
|
|
>
|
|
<SourceEditorDialog
|
|
open
|
|
code={generatedMjcf}
|
|
filePath={generatedMjcfPath}
|
|
theme={theme}
|
|
onClose={() => setSourceOpen(false)}
|
|
onSave={saveCachedSource}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
<ShortcutHelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
|
<DiagnosticsDrawer
|
|
open={diagnosticsOpen}
|
|
items={notifications}
|
|
onClose={() => setDiagnosticsOpen(false)}
|
|
onClear={() => setNotifications([])}
|
|
/>
|
|
<SettingsDialog
|
|
open={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
theme={theme}
|
|
angleUnit={angleUnit}
|
|
showCollision={showCollision}
|
|
jointAdvanced={jointAdvanced}
|
|
forceScale={forceScale}
|
|
onTheme={setTheme}
|
|
onAngleUnit={setAngleUnit}
|
|
onShowCollision={setShowCollision}
|
|
onJointAdvanced={setJointAdvanced}
|
|
onForceScale={setForceScale}
|
|
/>
|
|
<LayoutSettingsDialog
|
|
open={layoutOpen}
|
|
onClose={() => setLayoutOpen(false)}
|
|
leftOpen={leftOpen}
|
|
rightOpen={rightOpen}
|
|
onLeftOpen={setLeftOpen}
|
|
onRightOpen={setRightOpen}
|
|
onPreset={applyLayoutPreset}
|
|
onReset={() => applyLayoutPreset('default')}
|
|
/>
|
|
<CommandPalette
|
|
open={commandOpen}
|
|
onClose={() => setCommandOpen(false)}
|
|
commands={commands}
|
|
/>
|
|
<ConfirmDialog
|
|
open={removeConfirmOpen}
|
|
title="移除当前工程"
|
|
confirmLabel="移除工程"
|
|
danger
|
|
onConfirm={confirmRemoveProject}
|
|
onClose={() => setRemoveConfirmOpen(false)}
|
|
>
|
|
<p className="text-sm text-text-secondary">
|
|
确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>
|
|
”吗?
|
|
</p>
|
|
<p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p>
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
}
|