refactor(web-platform): release V0.7.2 项目常规优化
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
|
||||
|
||||
## [0.7.2] - 2026-09-01
|
||||
|
||||
### 变更
|
||||
|
||||
- 前端改为“应用壳 + 业务域 + 共享组件”的目录结构,并补充架构与依赖边界文档。
|
||||
- 拆分工程侧栏、模型控制侧栏、执行器控制和通用侧栏容器,降低单文件职责和跨域耦合。
|
||||
- 将地图、控制器、强化学习、本地训练和遥测面板及测试与各自业务域共置。
|
||||
- 将遥测模块从仿真调度目录独立到 `telemetry/`,保留通过显式接口与 `SimulationSession` 集成。
|
||||
|
||||
## [0.7.1] - 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -62,16 +62,23 @@ npm run lint:python
|
||||
└── LICENSE Apache License 2.0
|
||||
```
|
||||
|
||||
核心前端分层:
|
||||
核心前端采用“应用壳 + 业务域 + 共享组件”分层:
|
||||
|
||||
- `web_platform/src/app/`:工作区、工具栏、面板和对话框
|
||||
- `web_platform/src/project/`:工程导入、路径规范化和 MEMFS
|
||||
- `web_platform/src/simulation/`:MuJoCo WASM 会话与仿真调度
|
||||
- `web_platform/src/app/`:工作区编排、侧栏外壳、工具栏和全局对话框
|
||||
- `web_platform/src/components/ui/`:无业务依赖的共享 UI 基础组件
|
||||
- `web_platform/src/project/`:工程导入、路径规范化、项目树和 MEMFS
|
||||
- `web_platform/src/simulation/`:MuJoCo WASM 会话、仿真调度及执行器控制 UI
|
||||
- `web_platform/src/viewer/`:Three.js 渲染、选择和物理交互
|
||||
- `web_platform/src/controller/`:Pyodide 控制器运行时
|
||||
- `web_platform/src/rl/`:ONNX 策略运行时
|
||||
- `web_platform/src/map/`:地图加载、创作、校验及对应 UI
|
||||
- `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)。
|
||||
|
||||
## 数据与安全边界
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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/`。
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
type UrdfLoadMode,
|
||||
} from '../simulation/PhysicsAdapter';
|
||||
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 { RLCommand, RLPolicyStatus } from '../rl/types';
|
||||
import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
|
||||
@@ -54,7 +54,8 @@ import {
|
||||
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
|
||||
import { WorkbenchHeader } from './components/WorkbenchHeader';
|
||||
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 { EntrySelectionDialog } from './components/EntrySelectionDialog';
|
||||
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 {
|
||||
Box,
|
||||
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';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { ResizablePanel } from '../../components/ui';
|
||||
|
||||
export function SidebarPanel({
|
||||
title,
|
||||
@@ -86,828 +29,3 @@ export function SidebarPanel({
|
||||
</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={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp}
|
||||
onCommit={(kp) => update({ kp })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`kv(MJCF 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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
RotateCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
import type { ControllerCommand, ControllerStatus } from './types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../components/ui';
|
||||
|
||||
export interface PythonControllerPanelProps {
|
||||
paths: string[];
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const renderLibrary = (
|
||||
+4
-4
@@ -1,22 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
} from '../../map/editor/assetCatalog';
|
||||
} from './editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
} from './editor/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
} from './types';
|
||||
|
||||
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
|
||||
const shape =
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const interactionProps = {
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import { MapEditSession } from '../../map/editor/MapEditSession';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import { MapEditSession } from './editor/MapEditSession';
|
||||
import {
|
||||
editableObjectGroundHeight,
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type MapEditorInteractionCallbacks,
|
||||
type MapEditorTransformMode,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
} from './editor/types';
|
||||
|
||||
const labels: Record<EditableMapObjectType, string> = {
|
||||
box: '方盒',
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const common = {
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import type { MapEntry } from '../../project/types';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
type MapSelection,
|
||||
type PhysicalMapConfig,
|
||||
type PhysicalMapPreset,
|
||||
} from '../../map/types';
|
||||
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
|
||||
} from './types';
|
||||
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
} from '../../map/editor/types';
|
||||
} from './editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
function NumberField({
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
import type { RLCommand, RLPolicyStatus } from './types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../components/ui';
|
||||
|
||||
export interface RLPolicyPanelProps {
|
||||
paths: string[];
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ActuatorControl } from './SidebarPanel';
|
||||
import type { ActuatorInfo } from '../../simulation/SimulationSession';
|
||||
import { ActuatorControl } from './ActuatorControl';
|
||||
import type { ActuatorInfo } from './SimulationSession';
|
||||
|
||||
const actuator: ActuatorInfo = {
|
||||
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={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp}
|
||||
onCommit={(kp) => update({ kp })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`kv(MJCF 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';
|
||||
import type { ControllerCommand, ControllerStatus } from '../controller/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 { composeProjectMap } from '../map/MapComposer';
|
||||
import { resolveProjectMap } from '../map/MapLoader';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type DataRecorderStatus,
|
||||
type TelemetryBody,
|
||||
type TelemetryChannel,
|
||||
} from './DataRecorder';
|
||||
} from '../telemetry/DataRecorder';
|
||||
|
||||
export interface ActuatorParameters {
|
||||
gear: number;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
import type { DataRecorderStatus } from './DataRecorder';
|
||||
|
||||
const status: DataRecorderStatus = {
|
||||
recording: false,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
||||
import type { BodyInfo } from '../../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
import type { BodyInfo } from '../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig, DataRecorderStatus } from './DataRecorder';
|
||||
import { Badge, Button, PropertyRow, Select } from '../components/ui';
|
||||
|
||||
const SAMPLE_RATES = [10, 20, 50, 100, 200];
|
||||
const radiansToDegrees = 180 / Math.PI;
|
||||
+3
-8
@@ -1,13 +1,8 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Download, Link, Play, Server, Square } from 'lucide-react';
|
||||
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
|
||||
import { LocalTrainingClient } from '../../training/LocalTrainingClient';
|
||||
import type {
|
||||
TrainingDevice,
|
||||
TrainingJob,
|
||||
TrainingServerInfo,
|
||||
WandbMode,
|
||||
} from '../../training/types';
|
||||
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../components/ui';
|
||||
import { LocalTrainingClient } from './LocalTrainingClient';
|
||||
import type { TrainingDevice, TrainingJob, TrainingServerInfo, WandbMode } from './types';
|
||||
|
||||
const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
|
||||
JOB_KEY = 'mujoco-local-training-job',
|
||||
Reference in New Issue
Block a user