feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-04 15:34:24 +08:00
parent 63d67a645b
commit fa5485049a
60 changed files with 4494 additions and 2073 deletions
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
属性面板
右侧面板
</Button>
</div>
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
@@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools';
const noop = () => {};
describe('MapViewportToolbar', () => {
it('统一联动变换、吸附与贴地检测命令', () => {
const onModeChange = vi.fn();
const onSnappingChange = vi.fn();
const onPlacementModeChange = vi.fn();
const onAlignToSurface = vi.fn();
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping
placementMode="auto_ground"
hasSelectedObject
allowScale
loading={false}
onActivate={noop}
onModeChange={onModeChange}
onSnappingChange={onSnappingChange}
onPlacementModeChange={onPlacementModeChange}
onAlignToSurface={onAlignToSurface}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
fireEvent.click(screen.getByRole('button', { name: '网格吸附' }));
fireEvent.change(screen.getByLabelText('贴地检测模式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' }));
expect(onModeChange).toHaveBeenCalledWith('rotate');
expect(onSnappingChange).toHaveBeenCalledWith(false);
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
expect(onAlignToSurface).toHaveBeenCalledOnce();
});
it('参数地形或未选中对象时不提供缩放与贴合动作', () => {
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping={false}
placementMode="auto_ground"
hasSelectedObject={false}
allowScale={false}
loading={false}
onActivate={noop}
onModeChange={noop}
onSnappingChange={noop}
onPlacementModeChange={noop}
onAlignToSurface={noop}
/>,
);
expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled();
});
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
<MapDraftStatusOverlay
visible
changeCount={3}
loading={false}
onCommit={onCommit}
onDiscard={onDiscard}
/>,
);
expect(screen.getByText('3 项未保存改动')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' }));
fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' }));
expect(onCommit).toHaveBeenCalledOnce();
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
render(
<MapDraftStatusOverlay
visible
changeCount={0}
loading={false}
onCommit={noop}
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
});
});
@@ -0,0 +1,202 @@
import {
ArrowDownToLine,
Check,
Grid3X3,
LockKeyhole,
MapPinned,
Move3d,
Rotate3d,
Scaling,
Trash2,
} from 'lucide-react';
import {
Button,
IconButton,
Select,
ToolbarToggleGroup,
type ToolbarItem,
} from '../../components/ui';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
{ value: 'translate', label: '移动工具 W', icon: Move3d },
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
];
export function MapViewportToolbar({
visible,
interactionActive,
mode,
snapping,
placementMode,
hasSelectedObject,
allowScale,
loading,
onActivate,
onModeChange,
onSnappingChange,
onPlacementModeChange,
onAlignToSurface,
}: {
visible: boolean;
interactionActive: boolean;
mode: MapEditorTransformMode;
snapping: boolean;
placementMode: MapObjectPlacementMode;
hasSelectedObject: boolean;
allowScale: boolean;
loading: boolean;
onActivate: () => void;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
onAlignToSurface: () => void;
}) {
if (!visible) return null;
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
>
<button
type="button"
aria-label="激活地图编辑"
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
地图编辑
</button>
<ToolbarToggleGroup
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
value={mode}
onChange={onModeChange}
label="地图变换模式"
/>
<IconButton
active={snapping}
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
aria-label="网格吸附"
aria-pressed={snapping}
disabled={loading}
onClick={() => onSnappingChange(!snapping)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline">贴地</span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
value={placementMode}
disabled={loading}
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
<option key={value} value={value}>
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
</option>
))}
</Select>
</label>
<IconButton
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
aria-label="立即贴合承载面"
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
onClick={onAlignToSurface}
>
{placementMode === 'locked' ? (
<LockKeyhole className="h-3.5 w-3.5" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5" />
)}
</IconButton>
</div>
);
}
export function MapDraftStatusOverlay({
visible,
changeCount,
loading,
onCommit,
onDiscard,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
</span>
<Button
variant="ghost"
aria-label="丢弃地图草稿"
disabled={loading || !dirty}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline">丢弃</span>
</Button>
<Button
variant="primary"
aria-label="提交地图草稿"
disabled={loading || !dirty}
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline">提交</span>
</Button>
</div>
);
}
export interface MapAssetDropTarget {
left: number;
top: number;
position: readonly [number, number, number] | null;
}
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
if (!target) return null;
const valid = Boolean(target.position);
return (
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
style={{ left: target.left, top: target.top }}
>
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
</span>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { ModelControlsSidebar } from './ModelControlsSidebar';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0.25,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
actuators: [],
model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 },
} as unknown as SimulationSnapshot;
const noop = () => {};
const asyncTrue = async () => true;
function props(
patch: Partial<ComponentProps<typeof ModelControlsSidebar>> = {},
): ComponentProps<typeof ModelControlsSidebar> {
return {
snapshot,
selection: null,
viewerSelection: null,
loading: false,
urdfMode: 'mjcf',
baseMode: 'floating',
showCollision: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
mapSelection: { kind: 'none' },
mapSceneDirty: false,
maps: [],
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
workspaceTool: null,
onWorkspaceToolChange: noop,
onSelectJoint: noop,
onUrdfMode: noop,
onBaseMode: noop,
onShowCollision: noop,
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onApplyMap: noop,
onMapDraft: noop,
onEditorPreview: noop,
onEditorDraftChange: noop,
onEditorApply: asyncTrue,
onEditorExport: noop,
onEditorConvert: asyncTrue,
onEditorBindInteraction: noop,
onEditorSelect: noop,
onEditorSessionStateChange: noop,
onMapDisplay: noop,
...patch,
};
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'body', bodyId: 2 },
workspaceTool: 'controls',
workspaceTools: (
<div role="tabpanel" aria-label="控制台">
内嵌控制工具
</div>
),
onWorkspaceToolChange,
})}
/>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible();
expect(screen.getByText('内嵌控制工具')).toBeVisible();
const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]');
expect(inspector).not.toBeNull();
expect(inspector).not.toBeVisible();
const controlsTab = screen.getByRole('tab', { name: '控制台' });
expect(controlsTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(controlsTab, { key: 'ArrowRight' });
expect(onWorkspaceToolChange).toHaveBeenCalledWith('data');
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
expect(onWorkspaceToolChange).toHaveBeenCalledWith(null);
});
it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'map', mapAssetId: 'terrain-a' },
activeMapAssetId: 'terrain-a',
activeMapAssetName: '随机粗糙地形',
mapSelection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' },
},
onWorkspaceToolChange,
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
});
});
@@ -1,38 +1,31 @@
import { useState } from 'react';
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
import type { ReactNode } from 'react';
import { Box, Database, MapPinned, PanelRight, 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, PlacedMapSelection } from '../../map/types';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapEditorSessionState,
} 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 {
BodyInspector,
InspectorIdentity,
JointInspector,
ModelSummaryInspector,
} from '../../simulation/RobotInspector';
import type { EditorSelection } from '../editorSelection';
import { SidebarPanel } from './SidebarPanel';
import type { WorkspaceTool } from './WorkspaceToolsPanel';
import { RightSidebarTabs } from './RightSidebarTabs';
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selection: EditorSelection | null;
viewerSelection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
@@ -42,23 +35,19 @@ interface ModelControlsProps {
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
activeMapAssetId?: string;
activeMapAssetName?: string;
mapSceneDirty: boolean;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
editorDraftDocument?: EditableMapDocument;
activeTab?: 'properties' | 'controls' | 'data' | 'map';
onActiveTabChange?: (value: 'properties' | 'controls' | 'data' | 'map') => void;
workspaceTool: WorkspaceTool | null;
workspaceTools?: ReactNode;
onWorkspaceToolChange: (tool: WorkspaceTool | null) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
@@ -69,19 +58,6 @@ interface ModelControlsProps {
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;
onMapDraft: (value: PlacedMapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
@@ -95,320 +71,183 @@ interface ModelControlsProps {
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;
onEditorSessionStateChange?: (
descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
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 [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
'properties',
),
tab = props.activeTab ?? internalTab,
s = props.snapshot;
if (!s)
const snapshot = props.snapshot;
const activeView = props.workspaceTool ?? 'inspector';
const title =
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
const icon =
activeView === 'controls' ? (
<SlidersHorizontal />
) : activeView === 'data' ? (
<Database />
) : (
<PanelRight />
);
const tabs = (
<RightSidebarTabs
value={activeView}
onChange={(value) => props.onWorkspaceToolChange(value === 'inspector' ? null : value)}
/>
);
if (!snapshot)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
{props.workspaceTool ? (
props.workspaceTools
) : (
<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 selection = props.selection;
const selectedBody =
selection?.kind === 'body'
? snapshot.bodies.find((body) => body.id === selection.bodyId)
: undefined;
const selectedJoint =
selection?.kind === 'joint'
? snapshot.joints.find((joint) => joint.id === selection.jointId)
: undefined;
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === props.activeMapAssetId;
const selectedMapObject =
selection?.kind === 'map-object' && mapSelected
? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
(object) => object.id === selection.objectId,
)
: undefined;
const mapPanel = (
<PhysicalMapPanel
key={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
value={props.mapSelection}
maps={props.maps}
rootBodies={snapshot.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}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
selectedEditorObjectId={
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorSessionStateChange={props.onEditorSessionStateChange}
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap}
/>
);
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) => {
setInternalTab(value);
props.onActiveTabChange?.(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={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
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}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap}
/>
),
},
]}
let inspector: ReactNode;
if (selectedBody) {
inspector = (
<BodyInspector
body={selectedBody}
snapshot={snapshot}
viewerSelection={props.viewerSelection}
showCollision={props.showCollision}
onShowCollision={props.onShowCollision}
onSelectJoint={props.onSelectJoint}
/>
);
} else if (selectedJoint) {
inspector = (
<JointInspector
joint={selectedJoint}
snapshot={snapshot}
loading={props.loading}
ignoreJointLimits={props.ignoreJointLimits}
jointAdvanced={props.jointAdvanced}
angleUnit={props.angleUnit}
onResetJoints={props.onResetJoints}
onToggleJointLimits={props.onToggleJointLimits}
onToggleAdvanced={props.onToggleAdvanced}
onToggleAngleUnit={props.onToggleAngleUnit}
onJoint={props.onJoint}
onActuator={props.onActuator}
onActuatorParameters={props.onActuatorParameters}
/>
);
} else if (mapSelected) {
const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例';
inspector = (
<>
<InspectorIdentity
icon={selectedMapObject ? <Box /> : <MapPinned />}
eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'}
name={mapName}
meta={
selectedMapObject
? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}`
: props.mapSelection.kind === 'builtin'
? '参数化地形'
: '工程地图包实例'
}
/>
{mapPanel}
</>
);
} else {
inspector = (
<>
<ModelSummaryInspector
snapshot={snapshot}
selectedFormat={props.selectedFormat}
loading={props.loading}
urdfMode={props.urdfMode}
baseMode={props.baseMode}
showCollision={props.showCollision}
onUrdfMode={props.onUrdfMode}
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
return (
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
<div
hidden={props.workspaceTool !== null}
role="tabpanel"
aria-label="检查器"
className="min-h-0 flex-1 overflow-auto panel-scroll"
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
</SidebarPanel>
);
}
+141 -122
View File
@@ -1,17 +1,13 @@
import { useState } from 'react';
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
import { FolderTree, Library, Search } 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 { SimulationSnapshot } from '../../simulation/SimulationSession';
import { Button, Tabs } from '../../components/ui';
import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
@@ -24,35 +20,42 @@ import type {
MapObjectPlacementMode,
} from '../../map/editor/types';
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
import type { EditorSelection } from '../editorSelection';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
import { SidebarPanel } from './SidebarPanel';
import { TreeSearchField } from './TreeSearchField';
export type ProjectResourceTab = 'assets' | 'files';
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
selection,
loading,
visible = true,
nativeUrdf,
mapSelection,
maps,
placedMaps,
activeMapId,
pendingSceneChangeCount,
pendingSceneIds,
editorDocument,
editorDocuments,
assetPlacementMode,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onSelectBody,
onSelectJoint,
onJointHover,
onAddMapAsset,
onAddProjectMap,
onSelectTerrain,
onAssetPlacementModeChange,
onApplyScene,
onDiscardScene,
onSelectMap,
@@ -64,21 +67,23 @@ export function ProjectSidebar({
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
selection: EditorSelection | null;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
maps: MapEntry[];
placedMaps: PlacedMapAsset[];
activeMapId?: string;
pendingSceneChangeCount: number;
pendingSceneIds: string[];
editorDocument: EditableMapDocument | null;
editorDocuments?: ReadonlyMap<string, EditableMapDocument>;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
assetPlacementMode?: MapObjectPlacementMode;
activeTab?: ProjectResourceTab;
onActiveTabChange?: (value: ProjectResourceTab) => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
@@ -86,22 +91,22 @@ export function ProjectSidebar({
) => void | Promise<void>;
onAddProjectMap: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
onSelectMap: (id: string) => void;
onRemoveMap: (id: string) => void;
onSelectMapObject: (mapId: string, objectId: 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;
const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
const [fileQuery, setFileQuery] = useState('');
const [sceneQuery, setSceneQuery] = useState('');
const tab = activeTab ?? internalTab;
const fileMatches = countProjectSearchResults(files, fileQuery);
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -109,115 +114,129 @@ export function ProjectSidebar({
<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 className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} 个地图实例 · {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}
<VerticalSplitPane
storageKey="mujoco-project-sidebar-scene-ratio"
separatorLabel="调整场景大纲与资源面板高度"
defaultRatio={0.42}
minFirstSize={160}
minSecondSize={160}
firstClassName="flex flex-col"
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
场景大纲
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
resultCount={sceneMatches}
label="搜索场景对象"
placeholder="搜索 Body、关节或地图物体…"
/>
<SceneOutliner
snapshot={snapshot}
maps={placedMaps}
documents={editorDocuments}
selection={selection}
query={sceneQuery}
loading={loading}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
onJointHover={onJointHover}
onSelectMap={onSelectMap}
onSelectMapObject={onSelectMapObject}
onRemoveMap={onRemoveMap}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
/>
</>
}
second={
<Tabs
label="资产与工程文件"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'assets',
label: '资产库',
icon: <Library 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
}
maps={maps}
placementMode={assetPlacementMode}
onPlacementModeChange={onAssetPlacementModeChange}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
/>
</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}
documents={editorDocuments}
maps={maps}
placedMaps={placedMaps}
activeMapId={activeMapId}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
onSelectMap={onSelectMap}
onRemoveMap={onRemoveMap}
onSelectObject={onSelectMapObject}
/>
),
},
]}
),
},
{
value: 'files',
label: '工程文件',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<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>
</>
),
},
]}
/>
}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示场景与资产</div>
)}
</SidebarPanel>
);
@@ -0,0 +1,65 @@
import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react';
export type RightSidebarView = 'inspector' | 'controls' | 'data';
const VIEWS: ReadonlyArray<{
value: RightSidebarView;
label: string;
icon: LucideIcon;
}> = [
{ value: 'inspector', label: '检查器', icon: PanelRight },
{ value: 'controls', label: '控制台', icon: SlidersHorizontal },
{ value: 'data', label: '数据录制', icon: Database },
];
export function RightSidebarTabs({
value,
onChange,
}: {
value: RightSidebarView;
onChange: (value: RightSidebarView) => void;
}) {
return (
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
const selected = value === view.value;
return (
<button
key={view.value}
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
onKeyDown={(event) => {
let nextIndex: number | undefined;
if (event.key === 'ArrowLeft') nextIndex = (index + VIEWS.length - 1) % VIEWS.length;
else if (event.key === 'ArrowRight') nextIndex = (index + 1) % VIEWS.length;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = VIEWS.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
onChange(VIEWS[nextIndex].value);
const tabs =
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[nextIndex]?.focus();
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{view.label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
model: { nbody: 3 },
} as SimulationSnapshot;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
objects: [
{
id: 'crate-1',
name: '木箱',
type: 'box',
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001],
rgba: [0.5, 0.6, 0.7, 1],
placementMode: 'auto_ground',
enabled: true,
},
],
spawnPoints: [],
};
const maps = [
{
id: 'map-a',
name: '仓库',
selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' },
},
];
const documents = new Map([['maps/warehouse/map.json', document]]);
function renderOutliner(overrides: Record<string, unknown> = {}) {
const props = {
snapshot,
maps,
documents,
selection: null,
query: '',
loading: false,
pendingSceneChangeCount: 0,
pendingSceneIds: [],
onSelectBody: vi.fn(),
onSelectJoint: vi.fn(),
onJointHover: vi.fn(),
onSelectMap: vi.fn(),
onSelectMapObject: vi.fn(),
onRemoveMap: vi.fn(),
onApplyScene: vi.fn(),
onDiscardScene: vi.fn(),
...overrides,
};
render(<SceneOutliner {...props} />);
return props;
}
describe('SceneOutliner', () => {
it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => {
renderOutliner();
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人');
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境');
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible();
expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2);
});
it('把所有选择统一上报为稳定 id', () => {
const props = renderOutliner();
fireEvent.click(screen.getByRole('treeitem', { name: 'base' }));
fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ }));
expect(props.onSelectBody).toHaveBeenCalledWith(1);
expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2);
expect(props.onSelectMap).toHaveBeenCalledWith('map-a');
expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1');
});
it('在大纲中集中处理场景草稿与实例删除', () => {
const props = renderOutliner({
pendingSceneChangeCount: 2,
pendingSceneIds: ['map-a'],
});
expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用');
fireEvent.click(screen.getByRole('button', { name: '应用场景' }));
fireEvent.click(screen.getByRole('button', { name: '放弃更改' }));
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
expect(props.onApplyScene).toHaveBeenCalledOnce();
expect(props.onDiscardScene).toHaveBeenCalledOnce();
expect(props.onRemoveMap).toHaveBeenCalledWith('map-a');
});
});
@@ -0,0 +1,275 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import type { EditorSelection } from '../editorSelection';
interface VisibleMap {
asset: PlacedMapAsset;
objects: EditableMapDocument['objects'];
}
function mapDocument(
asset: PlacedMapAsset,
documents: ReadonlyMap<string, EditableMapDocument>,
): EditableMapDocument | undefined {
return asset.selection.kind === 'project'
? documents.get(asset.selection.descriptorPath)
: undefined;
}
// eslint-disable-next-line react-refresh/only-export-components
export function countSceneSearchResults(
snapshot: SimulationSnapshot | undefined,
maps: readonly PlacedMapAsset[],
documents: ReadonlyMap<string, EditableMapDocument>,
query: string,
): number {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies =
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[];
const robotBodyIds = new Set(robotBodies.map((body) => body.id));
const robotCount = snapshot
? countModelStructureSearchResults(
robotBodies,
snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)),
normalized,
)
: 0;
if (!normalized)
return (
robotCount +
maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0)
);
return (
robotCount +
maps.reduce((count, map) => {
const document = mapDocument(map, documents);
if (map.name.toLocaleLowerCase().includes(normalized))
return count + 1 + (document?.objects.length ?? 0);
const objectCount =
document?.objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
).length ?? 0;
return count + (objectCount ? 1 + objectCount : 0);
}, 0)
);
}
export function SceneOutliner({
snapshot,
maps,
documents,
selection,
query,
loading,
pendingSceneChangeCount,
pendingSceneIds,
onSelectBody,
onSelectJoint,
onJointHover,
onSelectMap,
onSelectMapObject,
onRemoveMap,
onApplyScene,
onDiscardScene,
}: {
snapshot?: SimulationSnapshot;
maps: readonly PlacedMapAsset[];
documents: ReadonlyMap<string, EditableMapDocument>;
selection: EditorSelection | null;
query: string;
loading: boolean;
pendingSceneChangeCount: number;
pendingSceneIds: readonly string[];
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onSelectMap: (mapId: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
onRemoveMap: (mapId: string) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
}) {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies = useMemo(
() =>
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[],
[snapshot?.bodies],
);
const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]);
const robotJoints = useMemo(
() => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [],
[snapshot?.joints, robotBodyIds],
);
const visibleMaps = useMemo<VisibleMap[]>(
() =>
maps.flatMap((asset) => {
const objects = mapDocument(asset, documents)?.objects ?? [];
if (!normalized || asset.name.toLocaleLowerCase().includes(normalized))
return [{ asset, objects }];
const matches = objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
);
return matches.length ? [{ asset, objects: matches }] : [];
}),
[documents, maps, normalized],
);
const hasRobotMatches = snapshot
? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0
: false;
const pending = new Set(pendingSceneIds);
return (
<section
aria-label="场景资产树"
className="min-h-0 flex-1 overflow-auto px-2 pb-2 panel-scroll"
>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSceneChangeCount} 项场景更改待应用
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="primary" disabled={loading} onClick={onApplyScene}>
应用场景
</Button>
<Button variant="ghost" disabled={loading} onClick={onDiscardScene}>
放弃更改
</Button>
</div>
</div>
)}
{snapshot && hasRobotMatches && (
<details open className="group/robot">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">机器人</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
<div className="ml-2 border-l border-border pl-1">
<ModelStructureTree
bodies={robotBodies}
joints={robotJoints}
query={query}
selectedBodyId={selection?.kind === 'body' ? selection.bodyId : undefined}
selectedJointId={selection?.kind === 'joint' ? selection.jointId : undefined}
onSelectBody={onSelectBody}
onSelectJoint={(joint) => onSelectJoint(joint.id, joint.bodyId)}
onJointHover={onJointHover}
/>
</div>
</details>
)}
{visibleMaps.length > 0 && (
<details open className="mt-1">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">地图与环境</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{maps.length} 实例
</span>
</summary>
<ul role="tree" aria-label="地图物体树" className="ml-2 border-l border-border pl-1">
{visibleMaps.map(({ asset, objects }) => {
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === asset.id;
return (
<li key={asset.id} role="none">
<div
className={`flex items-center rounded ${
selection?.kind === 'map' && mapSelected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
>
<button
type="button"
role="treeitem"
aria-selected={selection?.kind === 'map' && mapSelected}
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left text-xs"
disabled={loading}
onClick={() => onSelectMap(asset.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
待应用
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
{objects.map((object) => {
const selected =
selection?.kind === 'map-object' &&
selection.mapAssetId === asset.id &&
selection.objectId === object.id;
return (
<li role="none" key={object.id}>
<button
type="button"
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectMapObject(asset.id, object.id)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</details>
)}
{!hasRobotMatches && !visibleMaps.length && (
<EmptySearchState label={normalized ? '没有匹配的场景对象' : '加载模型后显示场景层级'} />
)}
</section>
);
}
@@ -1,10 +1,18 @@
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Space', '播放 / 暂停'],
['R', '重置仿真'],
['R', '重置仿真(非地图编辑)'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
['W / E / R', '地图移动 / 旋转 / 缩放'],
['F', '聚焦当前对象'],
['Delete', '删除当前地图对象'],
['Ctrl / Cmd + S', '保存并编译地图草稿'],
['Ctrl / Cmd + Z', '撤销地图编辑'],
['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'],
['G', '切换地图网格吸附'],
['Esc', '取消选择或关闭浮层'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
<li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li>
<li>选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦</li>
<li>从左侧资产库拖动几何障碍物到视口地面即可放置</li>
</ul>
</section>
</Dialog>
@@ -7,20 +7,22 @@ export function SidebarPanel({
side,
children,
visible = true,
icon,
}: {
title: string;
side: 'left' | 'right';
children: ReactNode;
visible?: boolean;
icon?: ReactNode;
}) {
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" />
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span>
{title}
</h2>
@@ -1,4 +1,4 @@
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
export function ViewerToolDock({
mode,
display,
mapEditContext,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
@@ -24,6 +30,18 @@ export function ViewerToolDock({
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
@@ -16,11 +16,13 @@ export function ViewportHUD({
mode,
selection,
ready,
mapEditing = false,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
mapEditing?: boolean;
}) {
if (!ready) return null;
return (
@@ -45,7 +47,7 @@ export function ViewportHUD({
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
@@ -57,14 +59,28 @@ export function ViewportHUD({
·
</span>
<span>滚轮缩放</span>
{mode !== 'select' && (
{mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span>变换</span>
<Kbd>F</Kbd>
<span>聚焦</span>
</>
) : (
mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)
)}
</div>
</>
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
onToggleFullscreen: () => void;
}) {
return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
const snapshot = {
actuators: [],
joints: [],
controller: { enabled: true },
rlPolicy: { enabled: false },
} as unknown as SimulationSnapshot;
const noop = () => {};
function props(): ComponentProps<typeof WorkspaceToolsPanel> {
return {
active: 'controls',
snapshot,
loading: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
forceScale: 50,
controllerPaths: [],
controllerStatus: { enabled: true } as ComponentProps<
typeof WorkspaceToolsPanel
>['controllerStatus'],
policyPaths: [],
policyStatus: { enabled: false } as ComponentProps<typeof WorkspaceToolsPanel>['policyStatus'],
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onForceScale: noop,
onSelectControllerPath: noop,
onLoadControllerPath: noop,
onImportController: noop,
onToggleController: noop,
onControllerCommand: noop,
onRemoveController: noop,
onSelectPolicyPath: noop,
onLoadPolicyPath: noop,
onImportPolicy: noop,
onTogglePolicy: noop,
onPolicyCommand: noop,
onRemovePolicy: noop,
onDataRecorderConfigure: noop,
onDataRecordingStart: noop,
onDataRecordingStop: noop,
onDataRecordingClear: noop,
onDataRecordingExport: noop,
};
}
describe('WorkspaceToolsPanel', () => {
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
const sections = screen
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
];
expect(sections).toHaveLength(expected.length);
sections.forEach((section, index) => {
expect(section).toHaveAttribute('aria-expanded', 'false');
expected[index].forEach((label) => expect(section).toHaveTextContent(label));
});
});
});
@@ -0,0 +1,295 @@
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
import { ActuatorControl, ControlSlider } from '../../simulation/ActuatorControl';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
);
}
export function WorkspaceToolsPanel({
active,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
forceScale,
controllerPaths,
selectedControllerPath,
controllerStatus,
policyPaths,
selectedPolicyPath,
policyStatus,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onActuator,
onActuatorParameters,
onJoint,
onForceScale,
onSelectControllerPath,
onLoadControllerPath,
onImportController,
onToggleController,
onControllerCommand,
onRemoveController,
onSelectPolicyPath,
onLoadPolicyPath,
onImportPolicy,
onTogglePolicy,
onPolicyCommand,
onRemovePolicy,
onDataRecorderConfigure,
onDataRecordingStart,
onDataRecordingStop,
onDataRecordingClear,
onDataRecordingExport,
}: {
active: WorkspaceTool;
snapshot?: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
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;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
<>
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}>重置关节</Button>
<Button
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
高级
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{snapshot.joints.map((joint) => {
const scale = joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
const unit =
joint.type === 3 ? (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={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
? '运行'
: '停止'
: undefined
}
running={Boolean(resolvedControllerStatus?.enabled)}
/>
}
>
<PythonControllerPanel
paths={controllerPaths}
selectedPath={selectedControllerPath}
status={resolvedControllerStatus}
loading={loading}
onSelectPath={onSelectControllerPath}
onLoadPath={onLoadControllerPath}
onImport={onImportController}
onToggle={onToggleController}
onCommand={onControllerCommand}
onRemove={onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
running={Boolean(resolvedPolicyStatus?.enabled)}
/>
}
>
<RLPolicyPanel
paths={policyPaths}
selectedPath={selectedPolicyPath}
status={resolvedPolicyStatus}
loading={loading}
onSelectPath={onSelectPolicyPath}
onLoadPath={onLoadPolicyPath}
onImport={onImportPolicy}
onToggle={onTogglePolicy}
onCommand={onPolicyCommand}
onRemove={onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
>
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
</CollapsibleSection>
</>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示控制工具</p>
);
return (
<div
role="tabpanel"
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
)}
</div>
);
}