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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user