883 lines
32 KiB
TypeScript
883 lines
32 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { Box, 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 { 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 { 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({
|
||
title,
|
||
side,
|
||
children,
|
||
visible = true,
|
||
}: {
|
||
title: string;
|
||
side: 'left' | 'right';
|
||
children: ReactNode;
|
||
visible?: boolean;
|
||
}) {
|
||
return (
|
||
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
|
||
<aside
|
||
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
|
||
>
|
||
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
|
||
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
|
||
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||
</span>
|
||
{title}
|
||
</h2>
|
||
{children}
|
||
</aside>
|
||
</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;
|
||
}
|
||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||
const [tab, setTab] = useState<'properties' | 'controls' | '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: '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>
|
||
);
|
||
}
|