refactor(web-platform): release V0.7.2 项目常规优化
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
## 数据与安全边界
|
## 数据与安全边界
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
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={`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,
|
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
-1
@@ -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 = (
|
||||||
+4
-4
@@ -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
-1
@@ -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 = {
|
||||||
+3
-3
@@ -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
-1
@@ -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 = {
|
||||||
+5
-5
@@ -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({
|
||||||
+2
-2
@@ -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[];
|
||||||
+2
-2
@@ -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={`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';
|
} 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
-1
@@ -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,
|
||||||
+3
-3
@@ -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;
|
||||||
+3
-8
@@ -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',
|
||||||
Reference in New Issue
Block a user