refactor(web-platform): release V0.7.2 项目常规优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-01 16:39:04 +08:00
parent 89d1c8cb57
commit 8ba83a8d0a
28 changed files with 993 additions and 932 deletions
+9
View File
@@ -2,6 +2,15 @@
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。 本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
## [0.7.2] - 2026-09-01
### 变更
- 前端改为“应用壳 + 业务域 + 共享组件”的目录结构,并补充架构与依赖边界文档。
- 拆分工程侧栏、模型控制侧栏、执行器控制和通用侧栏容器,降低单文件职责和跨域耦合。
- 将地图、控制器、强化学习、本地训练和遥测面板及测试与各自业务域共置。
- 将遥测模块从仿真调度目录独立到 `telemetry/`,保留通过显式接口与 `SimulationSession` 集成。
## [0.7.1] - 2026-09-01 ## [0.7.1] - 2026-09-01
### 新增 ### 新增
+14 -7
View File
@@ -62,16 +62,23 @@ npm run lint:python
└── LICENSE Apache License 2.0 └── LICENSE Apache License 2.0
``` ```
核心前端分层: 核心前端采用“应用壳 + 业务域 + 共享组件”分层:
- `web_platform/src/app/`:工作区、工具栏、面板和对话框 - `web_platform/src/app/`:工作区编排、侧栏外壳、工具栏和全局对话框
- `web_platform/src/project/`:工程导入、路径规范化和 MEMFS - `web_platform/src/components/ui/`:无业务依赖的共享 UI 基础组件
- `web_platform/src/simulation/`MuJoCo WASM 会话与仿真调度 - `web_platform/src/project/`:工程导入、路径规范化、项目树和 MEMFS
- `web_platform/src/simulation/`MuJoCo WASM 会话、仿真调度及执行器控制 UI
- `web_platform/src/viewer/`:Three.js 渲染、选择和物理交互 - `web_platform/src/viewer/`:Three.js 渲染、选择和物理交互
- `web_platform/src/controller/`Pyodide 控制器运行时 - `web_platform/src/map/`:地图加载、创作、校验及对应 UI
- `web_platform/src/rl/`ONNX 策略运行时 - `web_platform/src/controller/`:Pyodide 控制器运行时及控制面板
- `web_platform/src/rl/`:ONNX 策略运行时、任务绑定及策略面板
- `web_platform/src/training/`:本地训练服务客户端、类型及训练面板
- `web_platform/src/telemetry/`:遥测数据源、记录器、导出与数据面板
- `web_platform/src/stores/`:跨业务域的应用状态
详细使用说明见 [`web_platform/README.md`](web_platform/README.md),训练服务说明见 [`training_server/README.md`](training_server/README.md) 组件和测试优先与所属业务域共置;`app/components/` 仅保留跨域编排和应用壳组件,避免业务逻辑继续集中到单一侧栏文件
详细使用说明见 [`web_platform/README.md`](web_platform/README.md),前端依赖边界见 [`web_platform/ARCHITECTURE.md`](web_platform/ARCHITECTURE.md),训练服务说明见 [`training_server/README.md`](training_server/README.md)。
## 数据与安全边界 ## 数据与安全边界
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.1", "version": "0.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.1", "version": "0.7.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.1", "version": "0.7.2",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
+41
View File
@@ -0,0 +1,41 @@
# 前端架构
`src/` 采用“应用壳 + 业务域 + 共享基础设施”的组织方式。
## 目录职责
```text
src/
├── app/ 应用入口、跨域编排、工作区壳和全局反馈
├── components/ui/ 无业务依赖的共享 UI 基础组件
├── controller/ Python 控制器运行时、类型和面板
├── map/ 地图加载、生成、编辑和面板
├── project/ 工程导入、转换、文件树和工作区
├── rl/ ONNX 策略运行时、任务绑定、类型和面板
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
├── telemetry/ 数据源抽象、记录器、导出和数据面板
├── training/ 本地训练客户端、类型和面板
├── viewer/ Three.js 场景、渲染和交互
├── stores/ 跨域应用状态
└── test/ 全局测试初始化
```
## 依赖规则
1. `components/ui` 不依赖任何业务域。
2. 业务组件、类型和测试与所属业务域共置。
3. `app` 负责组合业务域,不承载可复用的业务算法或运行时。
4. `simulation` 不依赖 React 应用状态;遥测通过 `TelemetrySource` 接口读取仿真数据。
5. 业务域之间通过显式类型和门面交互,避免从其他域读取内部状态。
6. 新文件优先放入已有业务域;只有跨域复用且不含业务语义的组件才进入 `components/ui`
## 主要运行链路
```text
项目文件 → project → PhysicsAdapter → SimulationSession → MuJoCo WASM
├→ controller / rl
└→ telemetry
SimulationSession snapshot → app → viewer / 各业务面板
```
测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`
+3 -2
View File
@@ -43,7 +43,7 @@ import {
type UrdfLoadMode, type UrdfLoadMode,
} from '../simulation/PhysicsAdapter'; } from '../simulation/PhysicsAdapter';
import type { ActuatorParameters } from '../simulation/SimulationSession'; import type { ActuatorParameters } from '../simulation/SimulationSession';
import type { DataRecorderConfig } from '../simulation/DataRecorder'; import type { DataRecorderConfig } from '../telemetry/DataRecorder';
import type { ControllerCommand, ControllerStatus } from '../controller/types'; import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types'; import type { RLCommand, RLPolicyStatus } from '../rl/types';
import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer'; import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
@@ -54,7 +54,8 @@ import {
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore'; import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
import { WorkbenchHeader } from './components/WorkbenchHeader'; import { WorkbenchHeader } from './components/WorkbenchHeader';
import { ViewerToolDock } from './components/ViewerToolDock'; import { ViewerToolDock } from './components/ViewerToolDock';
import { ProjectSidebar, ModelControlsSidebar } from './components/SidebarPanel'; import { ModelControlsSidebar } from './components/ModelControlsSidebar';
import { ProjectSidebar } from './components/ProjectSidebar';
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays'; import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
import { EntrySelectionDialog } from './components/EntrySelectionDialog'; import { EntrySelectionDialog } from './components/EntrySelectionDialog';
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel'; import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
@@ -0,0 +1,389 @@
import { useState } from 'react';
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
Select,
Tabs,
} from '../../components/ui';
import type { MapSelection } from '../../map/types';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
} from '../../map/editor/types';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { ActuatorControl, Check, ControlSlider } from '../../simulation/ActuatorControl';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
import { SidebarPanel } from './SidebarPanel';
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary"></div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf"> MJCF</option>
<option value="native">MuJoCo URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">Free Joint</option>
<option value="fixed"></option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF visual mesh z=0
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary"></p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}></Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]}
/>
</SidebarPanel>
);
}
@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { Button, Tabs } from '../../components/ui';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapObjectPlacementMode,
} from '../../map/editor/types';
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SidebarPanel } from './SidebarPanel';
import { TreeSearchField } from './TreeSearchField';
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
loading,
visible = true,
nativeUrdf,
mapSelection,
editorDocument,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onJointHover,
onAddMapAsset,
onSelectTerrain,
onSelectMapObject,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) {
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} </div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary"></p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary"></div>
)}
</SidebarPanel>
);
}
@@ -1,63 +1,6 @@
import { useState, type ReactNode } from 'react'; import type { ReactNode } from 'react';
import { import { Settings2 } from 'lucide-react';
Box, import { ResizablePanel } from '../../components/ui';
Database,
FolderTree,
Info,
Map as MapIcon,
Settings2,
SlidersHorizontal,
} from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type {
ActuatorInfo,
ActuatorParameters,
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
ResizablePanel,
Select,
Tabs,
} from '../../components/ui';
import { TreeSearchField } from './TreeSearchField';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { DataRecordingPanel } from './DataRecordingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
export function SidebarPanel({ export function SidebarPanel({
title, title,
@@ -86,828 +29,3 @@ export function SidebarPanel({
</ResizablePanel> </ResizablePanel>
); );
} }
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
loading,
visible = true,
nativeUrdf,
mapSelection,
editorDocument,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onJointHover,
onAddMapAsset,
onSelectTerrain,
onSelectMapObject,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) {
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} </div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary"></p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary"></div>
)}
</SidebarPanel>
);
}
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary"></div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf"> MJCF</option>
<option value="native">MuJoCo URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">Free Joint</option>
<option value="fixed"></option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF visual mesh z=0
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary"></p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}></Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]}
/>
</SidebarPanel>
);
}
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
{actuator.controlCount} MJCF
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kpMJCF stiffness${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kvMJCF damping${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `${forceUnit}` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
MJCF
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
motor/position MJCF
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span> {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning"></span>}
<span> {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
}
@@ -10,8 +10,8 @@ import {
RotateCw, RotateCw,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import type { ControllerCommand, ControllerStatus } from '../../controller/types'; import type { ControllerCommand, ControllerStatus } from './types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui'; import { Badge, Button, PropertyRow, Select } from '../components/ui';
export interface PythonControllerPanelProps { export interface PythonControllerPanelProps {
paths: string[]; paths: string[];
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog'; import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from './editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary'; import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = ( const renderLibrary = (
@@ -1,22 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react'; import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../../components/ui'; import { Button, Select } from '../components/ui';
import { import {
CERTIFIED_MAP_ASSETS, CERTIFIED_MAP_ASSETS,
MAP_ASSET_DRAG_MIME, MAP_ASSET_DRAG_MIME,
MAP_ASSET_PLACEMENT_MIME, MAP_ASSET_PLACEMENT_MIME,
} from '../../map/editor/assetCatalog'; } from './editor/assetCatalog';
import { import {
MAP_OBJECT_PLACEMENT_LABELS, MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument, type EditableMapDocument,
type EditableMapObjectType, type EditableMapObjectType,
type MapObjectPlacementMode, type MapObjectPlacementMode,
} from '../../map/editor/types'; } from './editor/types';
import { import {
PHYSICAL_MAP_PRESET_LABELS, PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS, SYSTEM_TERRAIN_PRESETS,
type SystemTerrainPreset, type SystemTerrainPreset,
} from '../../map/types'; } from './types';
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) { function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
const shape = const shape =
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types'; import type { EditableMapDocument } from './editor/types';
import { MapEditorPanel } from './MapEditorPanel'; import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = { const interactionProps = {
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../../components/ui'; import { Button, Select } from '../components/ui';
import { MapEditSession } from '../../map/editor/MapEditSession'; import { MapEditSession } from './editor/MapEditSession';
import { import {
editableObjectGroundHeight, editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS, MAP_OBJECT_PLACEMENT_LABELS,
@@ -10,7 +10,7 @@ import {
type MapEditorInteractionCallbacks, type MapEditorInteractionCallbacks,
type MapEditorTransformMode, type MapEditorTransformMode,
type MapObjectPlacementMode, type MapObjectPlacementMode,
} from '../../map/editor/types'; } from './editor/types';
const labels: Record<EditableMapObjectType, string> = { const labels: Record<EditableMapObjectType, string> = {
box: '方盒', box: '方盒',
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types'; import { DEFAULT_PHYSICAL_MAP_CONFIG } from './types';
import { PhysicalMapPanel } from './PhysicalMapPanel'; import { PhysicalMapPanel } from './PhysicalMapPanel';
const common = { const common = {
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Select } from '../../components/ui'; import { Button, Select } from '../components/ui';
import type { MapEntry } from '../../project/types'; import type { MapEntry } from '../project/types';
import { import {
DEFAULT_PHYSICAL_MAP_CONFIG, DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS, PHYSICAL_MAP_PRESET_LABELS,
@@ -8,13 +8,13 @@ import {
type MapSelection, type MapSelection,
type PhysicalMapConfig, type PhysicalMapConfig,
type PhysicalMapPreset, type PhysicalMapPreset,
} from '../../map/types'; } from './types';
import { normalizePhysicalMapConfig } from '../../map/physicalMap'; import { normalizePhysicalMapConfig } from './physicalMap';
import type { import type {
EditableMapDocument, EditableMapDocument,
MapEditorInteractionCallbacks, MapEditorInteractionCallbacks,
MapEditorTransformMode, MapEditorTransformMode,
} from '../../map/editor/types'; } from './editor/types';
import { MapEditorPanel } from './MapEditorPanel'; import { MapEditorPanel } from './MapEditorPanel';
function NumberField({ function NumberField({
@@ -1,7 +1,7 @@
import { useRef, type ChangeEvent } from 'react'; import { useRef, type ChangeEvent } from 'react';
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react'; import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type { RLCommand, RLPolicyStatus } from '../../rl/types'; import type { RLCommand, RLPolicyStatus } from './types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui'; import { Badge, Button, PropertyRow, Select } from '../components/ui';
export interface RLPolicyPanelProps { export interface RLPolicyPanelProps {
paths: string[]; paths: string[];
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { ActuatorControl } from './SidebarPanel'; import { ActuatorControl } from './ActuatorControl';
import type { ActuatorInfo } from '../../simulation/SimulationSession'; import type { ActuatorInfo } from './SimulationSession';
const actuator: ActuatorInfo = { const actuator: ActuatorInfo = {
id: 0, id: 0,
@@ -0,0 +1,307 @@
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
import { Badge } from '../components/ui';
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
{actuator.controlCount} MJCF
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kpMJCF stiffness${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kvMJCF damping${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'}`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `${forceUnit}` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
MJCF
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
motor/position MJCF
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
export function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
export function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span> {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning"></span>}
<span> {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
}
@@ -16,7 +16,11 @@ import {
} from './SimulationSession'; } from './SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types'; import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types'; import type { RLCommand, RLPolicyStatus } from '../rl/types';
import type { DataRecorderConfig, DataRecorderStatus, TelemetryChannel } from './DataRecorder'; import type {
DataRecorderConfig,
DataRecorderStatus,
TelemetryChannel,
} from '../telemetry/DataRecorder';
import { composePhysicalMap } from '../map/physicalMap'; import { composePhysicalMap } from '../map/physicalMap';
import { composeProjectMap } from '../map/MapComposer'; import { composeProjectMap } from '../map/MapComposer';
import { resolveProjectMap } from '../map/MapLoader'; import { resolveProjectMap } from '../map/MapLoader';
@@ -11,7 +11,7 @@ import {
type DataRecorderStatus, type DataRecorderStatus,
type TelemetryBody, type TelemetryBody,
type TelemetryChannel, type TelemetryChannel,
} from './DataRecorder'; } from '../telemetry/DataRecorder';
export interface ActuatorParameters { export interface ActuatorParameters {
gear: number; gear: number;
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { DataRecordingPanel } from './DataRecordingPanel'; import { DataRecordingPanel } from './DataRecordingPanel';
import type { DataRecorderStatus } from '../../simulation/DataRecorder'; import type { DataRecorderStatus } from './DataRecorder';
const status: DataRecorderStatus = { const status: DataRecorderStatus = {
recording: false, recording: false,
@@ -1,7 +1,7 @@
import { Circle, Download, Square, Trash2 } from 'lucide-react'; import { Circle, Download, Square, Trash2 } from 'lucide-react';
import type { BodyInfo } from '../../simulation/SimulationSession'; import type { BodyInfo } from '../simulation/SimulationSession';
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder'; import type { DataRecorderConfig, DataRecorderStatus } from './DataRecorder';
import { Badge, Button, PropertyRow, Select } from '../../components/ui'; import { Badge, Button, PropertyRow, Select } from '../components/ui';
const SAMPLE_RATES = [10, 20, 50, 100, 200]; const SAMPLE_RATES = [10, 20, 50, 100, 200];
const radiansToDegrees = 180 / Math.PI; const radiansToDegrees = 180 / Math.PI;
@@ -1,13 +1,8 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { Download, Link, Play, Server, Square } from 'lucide-react'; import { Download, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui'; import { Badge, Button, ProgressBar, PropertyRow, Select } from '../components/ui';
import { LocalTrainingClient } from '../../training/LocalTrainingClient'; import { LocalTrainingClient } from './LocalTrainingClient';
import type { import type { TrainingDevice, TrainingJob, TrainingServerInfo, WandbMode } from './types';
TrainingDevice,
TrainingJob,
TrainingServerInfo,
WandbMode,
} from '../../training/types';
const ENDPOINT_KEY = 'mujoco-local-training-endpoint', const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
JOB_KEY = 'mujoco-local-training-job', JOB_KEY = 'mujoco-local-training-job',