feat(web-platform): release V0.7 地图模块
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-01 14:23:49 +08:00
parent 60d3a6d68c
commit 3b4ced6437
58 changed files with 7287 additions and 269 deletions
@@ -23,12 +23,31 @@ describe('工作台反馈组件', () => {
fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('加载态与空态互斥', () => {
it('加载态与空态互斥,并展示真实阶段进度', () => {
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
rerender(<WorkspaceOverlays loading hasSnapshot={false} />);
rerender(
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{
title: '正在导入工程',
label: '读取工程文件',
detail: 'robot.zip',
value: 0.42,
}}
/>,
);
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeVisible();
expect(screen.getByRole('status')).toHaveTextContent('正在导入工程');
expect(screen.getByRole('progressbar', { name: '读取工程文件' })).toHaveAttribute(
'aria-valuenow',
'42',
);
});
it('拖入文件时显示明确落点反馈', () => {
render(<WorkspaceOverlays loading={false} hasSnapshot={false} dragActive />);
expect(screen.getByText('松开即可导入工程')).toBeVisible();
});
it('展示格式化状态数据', () => {
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = (
onAdd: (type: string, placementMode: string) => void = () => {},
onSelectTerrain: (preset: string) => void = () => {},
) =>
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
onAdd={onAdd}
onSelectTerrain={onSelectTerrain}
/>,
);
describe('MapAssetLibrary', () => {
it('按所选放置方式添加认证资产', () => {
const onAdd = vi.fn();
renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
});
it('拖动资产时写入类型和放置方式', () => {
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
renderLibrary();
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'locked' },
});
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
});
it('以示意图卡片选择系统参数化地形', () => {
const onSelectTerrain = vi.fn();
renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
});
});
@@ -0,0 +1,298 @@
import { useState } from 'react';
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../../components/ui';
import {
CERTIFIED_MAP_ASSETS,
MAP_ASSET_DRAG_MIME,
MAP_ASSET_PLACEMENT_MIME,
} from '../../map/editor/assetCatalog';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from '../../map/editor/types';
import {
PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS,
type SystemTerrainPreset,
} from '../../map/types';
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
const shape =
type === 'cylinder'
? 'h-9 w-9 rounded-full'
: type === 'capsule'
? 'h-10 w-6 rounded-full'
: type === 'ramp'
? 'h-0 w-0 border-b-[34px] border-l-[48px] border-l-transparent'
: type === 'stairs'
? 'h-9 w-11 [clip-path:polygon(0_100%,0_66%,34%_66%,34%_33%,67%_33%,67%_0,100%_0,100%_100%)]'
: 'h-9 w-9 rounded-sm';
return (
<div className="flex h-16 items-center justify-center rounded bg-black/10" aria-hidden="true">
<div
className={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
/>
</div>
);
}
function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
const grid = (
<g stroke="currentColor" strokeWidth="0.45" opacity="0.18">
<path d="M14 50 60 23l46 27-46 27z" />
<path d="m26 57 46-27m-34 34 46-27m-58 6 46 27m-34-34 46 27" />
</g>
);
let shape;
if (preset === 'discrete_obstacles')
shape = (
<g fill="currentColor" fillOpacity="0.16" stroke="currentColor">
<path d="m31 48 9-5 9 5-9 5zM40 53V39l9-5v14M40 39l-9-5v14" />
<path d="m55 39 8-5 8 5-8 5zM63 44V27l8-5v17M63 27l-8-5v17" />
<path d="m77 51 10-6 10 6-10 6zM87 57V38l10-6v19M87 38l-10-6v19" />
</g>
);
else if (preset === 'gap')
shape = (
<g fill="currentColor" fillOpacity="0.12" stroke="currentColor">
<path d="m21 48 31-18 15 9-31 18zM69 40l15 9 17-10-15-9z" />
<path d="m36 57 31-18v8L36 65zM69 40l17-10v8L69 48z" fillOpacity="0.24" />
</g>
);
else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
shape = (
<g fill="currentColor" fillOpacity="0.08" stroke="currentColor">
<path d="m22 47 38-22 38 22-38 22z" />
<path d="m32 47 28-16 28 16-28 16z" />
<path d="m43 47 17-10 17 10-17 10z" fillOpacity="0.2" />
{preset === 'inverted_pyramid_stairs' && (
<path d="M32 47v7m11-7v10m17-10v16m17-16v10m11-10v7" />
)}
</g>
);
else if (preset === 'pyramid_stairs')
shape = (
<g fill="currentColor" stroke="currentColor" strokeLinejoin="round">
<path d="m25 52 12 7 52-30-12-7z" fillOpacity="0.1" />
<path d="m37 45 12 7 40-23-12-7z" fillOpacity="0.17" />
<path d="m49 38 12 7 28-16-12-7z" fillOpacity="0.24" />
<path d="m61 31 12 7 16-9-12-7z" fillOpacity="0.32" />
</g>
);
else if (preset === 'rails')
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="m26 58 53-31m15 40 12-7" strokeWidth="2.3" opacity="0.25" />
<path d="m26 58 53-31m15 40 12-7M33 58V45m14 5V37m30 12V27m20 39V53" strokeWidth="1.2" />
</g>
);
else if (preset === 'rough')
shape = (
<path
d="m20 54 10-18 9 9 10-22 11 17 10-13 10 20 10-8 10 15-40 18z"
fill="currentColor"
fillOpacity="0.16"
stroke="currentColor"
strokeLinejoin="round"
/>
);
else if (preset === 'stepping_stones')
shape = (
<g fill="currentColor" fillOpacity="0.2" stroke="currentColor">
<path d="m27 52 10-6 10 6-10 6zM46 39l10-6 10 6-10 6zM66 54l10-6 10 6-10 6zM83 36l10-6 10 6-10 6z" />
</g>
);
else
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="5" opacity="0.16" />
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="1.4" />
<path d="M25 59c10-17 20-17 30 0s20 17 30 0 15-13 23-7" opacity="0.55" />
</g>
);
return (
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
{grid}
{shape}
</svg>
);
}
function AddAction({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
{label}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</span>
);
}
export function MapAssetLibrary({
disabled,
terrainSize,
document,
onAdd,
onSelectTerrain,
onSelectObject,
}: {
disabled: boolean;
terrainSize: number;
document?: EditableMapDocument | null;
onAdd: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectObject?: (id: string) => void;
}) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
return (
<section aria-label="认证资产" className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span>场景结构</span>
<span className="technical-value text-[10px] text-text-tertiary">
{document?.objects.length ?? 0} 个对象
</span>
</div>
{document?.objects.length ? (
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
{document.objects.map((object) => (
<button
key={object.id}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
onClick={() => onSelectObject?.(object.id)}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<span className="min-w-0 flex-1 truncate">{object.name}</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
))}
</div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
</p>
)}
</div>
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
认证资产
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
点击添加,或按住资产拖到画布落位。
</p>
</div>
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
放置方式
<Select
aria-label="新增资产放置方式"
className="mt-1 w-full"
value={placementMode}
disabled={disabled}
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
))}
</Select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
{CERTIFIED_MAP_ASSETS.map((asset) => (
<article
key={asset.type}
draggable={!disabled}
data-map-asset={asset.type}
className={`overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
event.dataTransfer.setData('text/plain', asset.name);
}}
>
<div className="p-2 pb-0">
<AssetPreview type={asset.type} color={asset.color} />
</div>
<div className="border-t border-border-subtle p-2">
<div className="truncate text-[11px] font-semibold text-text-primary">
{asset.name}
</div>
<div className="mt-0.5 truncate text-[9px] text-text-tertiary">
{asset.description}
</div>
<Button
className="mt-2 w-full"
disabled={disabled}
aria-label={`添加${asset.name}`}
onClick={() => void onAdd(asset.type, placementMode)}
>
<Plus className="mr-1 h-3 w-3" aria-hidden="true" />
添加
</Button>
</div>
</article>
))}
</div>
<div className="border-t border-border-subtle pt-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
系统参数化地形
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
<article
key={preset}
data-system-terrain={preset}
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
>
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<div className="px-1 pt-1">
<TerrainPreview preset={preset} />
</div>
<div className="border-t border-border-subtle px-2 pb-2 pt-1.5">
<div className="truncate text-[11px] font-semibold text-text-primary">
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</div>
<div className="mt-0.5 text-[9px] text-text-tertiary">
{terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
</div>
<button
type="button"
className="mt-1 flex w-full justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
disabled={disabled}
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
onClick={() => onSelectTerrain(preset)}
>
<AddAction label="添加" />
</button>
</div>
</article>
))}
</div>
</section>
);
}
@@ -0,0 +1,106 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = {
onConvert: async () => true,
canConvert: true,
onBindInteraction: () => {},
onSelectPreview: () => {},
onTransformMode: () => {},
onSnapping: () => {},
};
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'map',
revision: 0,
objects: [],
spawnPoints: [],
};
describe('MapEditorPanel', () => {
it('新增并编辑对象后应用递增 revision', async () => {
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
preview = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={preview}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({
revision: 1,
objects: [
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
],
}),
);
});
it('锁定放置方式后禁用位姿编辑', () => {
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
});
it('编辑出生点并包含在应用文档中', async () => {
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
const name = screen.getByLabelText(/出生点名称/);
fireEvent.change(name, { target: { value: '装卸区' } });
const yaw = screen.getByLabelText(/出生点.*朝向/);
fireEvent.change(yaw, { target: { value: '90' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
});
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
const convert = vi.fn(async () => true);
render(
<MapEditorPanel
{...interactionProps}
onConvert={convert}
document={null}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
});
});
@@ -0,0 +1,597 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../../components/ui';
import { MapEditSession } from '../../map/editor/MapEditSession';
import {
editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
cylinder: '圆柱',
capsule: '胶囊',
ramp: '坡道',
stairs: '楼梯',
};
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function scaledParameters(
object: EditableMapObject,
scale: [number, number, number],
): Record<string, number> {
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
number,
number,
number,
];
const p = object.parameters;
if (object.type === 'box')
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
if (object.type === 'cylinder')
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
if (object.type === 'capsule')
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
if (object.type === 'ramp')
return {
length: p.length * sx,
width: p.width * sy,
rise: p.rise * sz,
thickness: p.thickness * sz,
};
return {
stepDepth: p.stepDepth * sx,
width: p.width * sy,
stepHeight: p.stepHeight * sz,
count: p.count,
};
}
export function MapEditorPanel({
document,
loading,
onPreview,
onApply,
onExport,
onConvert,
canConvert,
onBindInteraction,
onSelectPreview,
onTransformMode,
onSnapping,
}: {
document: EditableMapDocument | null;
loading: boolean;
onPreview: (document: EditableMapDocument | null) => void;
onApply: (document: EditableMapDocument) => Promise<boolean>;
onExport: () => void;
onConvert: () => Promise<boolean>;
canConvert: boolean;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
}) {
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
const [, render] = useState(0),
[selected, setSelected] = useState<string>(),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const refresh = () => {
render((value) => value + 1);
onPreview(session?.document ?? null);
};
useEffect(() => {
onPreview(session?.document ?? null);
return () => onPreview(null);
}, [session, onPreview]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
return;
}
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onTransform: ({ id, position, quaternion, scale }) => {
const object = session.document.objects.find((item) => item.id === id);
if (!object) return;
session.update(id, {
pose: { position, quaternion },
parameters: scaledParameters(object, scale),
});
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
},
onAddAsset: (type, position, placementMode) => {
const object = session.addAsset(type, position, placementMode);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(object.id);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
}, [session, onBindInteraction, onPreview, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
}, [transformMode, snapping, onTransformMode, onSnapping]);
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) {
setSelected(undefined);
onSelectPreview(null);
}
render((value) => value + 1);
onPreview(session.document);
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
else if (event.key === 'Escape') {
setSelected(undefined);
onSelectPreview(null);
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
event.preventDefault();
session.remove(selected);
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [session, selected, onPreview, onSelectPreview]);
if (!session)
return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
<p>当前地图没有 V3 authoring.source,保持只读。</p>
<p className="text-[10px] leading-relaxed">
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
disabled={loading || converting || !canConvert}
onClick={() => {
setConverting(true);
void onConvert().finally(() => setConverting(false));
}}
>
{converting ? '正在转换…' : '创建可编辑副本'}
</Button>
<Button disabled={loading || converting} onClick={onExport}>
导出原地图 ZIP
</Button>
</div>
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
</div>
);
const current = session.document;
const active = current.objects.find((object) => object.id === selected);
const poseLocked = active?.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!active || !Number.isFinite(value)) return;
const position = [...active.pose.position] as [number, number, number];
position[index] = value;
session.update(active.id, { pose: { ...active.pose, position } });
refresh();
};
const updateParameter = (key: string, value: number) => {
if (!active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateSpawnPosition = (
id: string,
position: [number, number, number],
index: number,
value: number,
) => {
if (!Number.isFinite(value)) return;
const next = [...position] as [number, number, number];
next[index] = value;
session.updateSpawn(id, { position: next });
refresh();
};
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
<Button
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'translate'}
disabled={loading}
onClick={() => setTransformMode('translate')}
>
移动工具 W
</Button>
<Button
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'rotate'}
disabled={loading}
onClick={() => setTransformMode('rotate')}
>
旋转工具 E
</Button>
<Button
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'scale'}
disabled={loading}
onClick={() => setTransformMode('scale')}
>
缩放工具 S
</Button>
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
<span>吸附(移动 0.1 m / 旋转 5°)</span>
<input
aria-label="地图编辑吸附"
type="checkbox"
checked={snapping}
disabled={loading}
onChange={(event) => setSnapping(event.target.checked)}
/>
</label>
<p className="col-span-3 text-[10px] text-text-tertiary">
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
</p>
</div>
<div className="flex gap-2">
<Select
aria-label="新增地图对象类型"
className="min-w-0 flex-1"
value={selectedType}
disabled={loading}
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
>
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
<option key={type} value={type}>
{labels[type]}
</option>
))}
</Select>
<Button
disabled={loading}
onClick={() => {
const object = session.add(selectedType);
setSelected(object.id);
refresh();
onSelectPreview(object.id);
}}
>
新增
</Button>
</div>
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
{current.objects.map((object) => (
<button
key={object.id}
type="button"
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
onClick={() => {
setSelected(object.id);
onSelectPreview(object.id);
}}
>
{object.name} · {labels[object.type]}
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
</button>
))}
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
</div>
{active && (
<div className="space-y-2 rounded border border-border-subtle p-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
value={active.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.update(active.id, { name: event.target.value });
refresh();
}}
/>
</label>
<label className="block text-[11px] text-text-secondary">
放置方式
<Select
aria-label="对象放置方式"
className="mt-1 w-full"
value={active.placementMode}
disabled={loading}
onChange={(event) => {
session.update(active.id, {
placementMode: event.target.value as MapObjectPlacementMode,
});
refresh();
}}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
(mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
),
)}
</Select>
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
{active.placementMode === 'auto_ground'
? '始终将对象底部对齐 z=0。'
: active.placementMode === 'gravity'
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
: '保持当前位姿,并禁用表单和视口变换。'}
</span>
</label>
<div className="grid grid-cols-3 gap-2">
{active.pose.position.map((value, index) => (
<label key={index} className="text-[11px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
disabled={loading || poseLocked || index === 2}
onChange={(event) => updatePosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="block text-[11px] text-text-secondary">
绕 Z 旋转(°)
<input
aria-label="对象绕Z旋转"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={Number(yawDegrees(active).toFixed(4))}
disabled={loading || poseLocked}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
const half = (value * Math.PI) / 360;
session.update(active.id, {
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
});
refresh();
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(active.parameters).map(([key, value]) => (
<label key={key} className="text-[11px] text-text-secondary">
{key}
<input
aria-label={`对象参数${key}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0.001"
step={key === 'count' ? 1 : 0.1}
value={value}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用对象</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
onChange={(event) => {
session.update(active.id, { enabled: event.target.checked });
refresh();
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={loading || poseLocked}
onClick={() => {
session.update(active.id, {
pose: {
...active.pose,
position: [
active.pose.position[0],
active.pose.position[1],
editableObjectGroundHeight(active),
],
},
});
refresh();
}}
>
对齐地面
</Button>
<Button
disabled={loading}
onClick={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
删除对象
</Button>
</div>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span>出生点</span>
<Button
disabled={loading}
onClick={() => {
session.addSpawn();
refresh();
}}
>
新增出生点
</Button>
</div>
{current.spawnPoints.map((spawn) => (
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
<input
aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value });
refresh();
}}
/>
<div className="grid grid-cols-4 gap-1">
{spawn.position.map((value, index) => (
<label key={index} className="text-[10px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
onChange={(event) =>
updateSpawnPosition(
spawn.id,
spawn.position,
index,
Number(event.target.value),
)
}
/>
</label>
))}
<label className="text-[10px] text-text-secondary">
Yaw°
<input
aria-label={`出生点${spawn.id}朝向`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={spawn.yawDeg}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
session.updateSpawn(spawn.id, { yawDeg: value });
refresh();
}}
/>
</label>
</div>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
refresh();
}}
>
删除出生点
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={loading || !session.canUndo}
onClick={() => {
session.undo();
refresh();
}}
>
撤销
</Button>
<Button
disabled={loading || !session.canRedo}
onClick={() => {
session.redo();
refresh();
}}
>
重做
</Button>
<Button
disabled={loading || !session.dirty}
onClick={() => {
session.discard();
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
放弃草稿
</Button>
<Button disabled={loading} onClick={onExport}>
导出地图 ZIP
</Button>
</div>
<Button
className="w-full"
disabled={loading || !session.dirty}
onClick={async () => {
const latest = session.document;
const candidate = { ...latest, revision: latest.revision + 1 };
if (await onApply(candidate)) onPreview(null);
else onPreview(session.document);
}}
>
应用并重新编译
</Button>
{session.dirty && (
<div role="status" className="text-xs text-warning">
地图草稿尚未应用
</div>
)}
</div>
);
}
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import { PhysicalMapPanel } from './PhysicalMapPanel';
const common = {
maps: [],
rootBodies: ['robot'],
loading: false,
nativeUrdf: false,
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
onEditorPreview: () => {},
onEditorApply: async () => true,
onEditorExport: () => {},
onEditorConvert: async () => true,
onEditorBindInteraction: () => {},
onEditorSelect: () => {},
onEditorTransformMode: () => {},
onEditorSnapping: () => {},
onMapDisplay: () => {},
};
describe('PhysicalMapPanel', () => {
it('选择楼梯参数后请求重新编译', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
});
});
it('在右侧参数面板调整系统地形并提交', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({
preset: 'rough',
terrainDifficulty: 0.8,
seed: 9,
}),
});
});
it('可以移动并旋转资产地图', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
});
});
it('显示工程地图、出生点和根 Body', () => {
const onApply = vi.fn();
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'none' }}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1 as const,
id: 'warehouse',
name: '仓库',
spawnPoints: [{ id: 'door', name: '入口' }],
},
]}
rootBodies={['robot', 'payload']}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('地图来源'), {
target: { value: 'project:maps/warehouse/map.json' },
});
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'door',
robotRootBody: 'robot',
}),
);
});
it('原生 URDF 模式只允许移除地图', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
nativeUrdf
onApply={() => {}}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
});
});
@@ -0,0 +1,474 @@
import { useState } from 'react';
import { Button, Select } from '../../components/ui';
import type { MapEntry } from '../../project/types';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS,
isSystemTerrainPreset,
type MapSelection,
type PhysicalMapConfig,
type PhysicalMapPreset,
} from '../../map/types';
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
} from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
function NumberField({
label,
value,
min,
max,
step = 1,
disabled,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
return (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">{label}</span>
<input
type="number"
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
function normalizeSelection(selection: MapSelection): MapSelection {
if (selection.kind === 'project') {
const friction = selection.frictionOverride;
return {
...selection,
frictionOverride:
friction === undefined || !Number.isFinite(friction)
? undefined
: Math.min(5, Math.max(0.05, friction)),
};
}
if (selection.kind !== 'builtin') return selection;
const config = normalizePhysicalMapConfig(selection.config);
return config.preset === 'none' ? { kind: 'none' } : { kind: 'builtin', config };
}
export function PhysicalMapPanel({
value,
maps,
rootBodies,
loading,
nativeUrdf,
showVisualMap,
showMapCollision,
editorDocument,
onEditorPreview,
onEditorApply,
onEditorExport,
onEditorConvert,
onEditorBindInteraction,
onEditorSelect,
onEditorTransformMode,
onEditorSnapping,
onMapDisplay,
onApply,
}: {
value: MapSelection;
maps: MapEntry[];
rootBodies: string[];
loading: boolean;
nativeUrdf: boolean;
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onApply: (value: MapSelection) => void;
}) {
const [draft, setDraft] = useState<MapSelection>(value);
const normalized = normalizeSelection(draft);
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
const sourceValue =
draft.kind === 'none'
? 'none'
: draft.kind === 'builtin'
? 'builtin'
: `project:${draft.descriptorPath}`;
const projectMap =
draft.kind === 'project'
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
: undefined;
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
if (draft.kind !== 'builtin') return;
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
};
const changeSource = (source: string) => {
if (source === 'none') setDraft({ kind: 'none' });
else if (source === 'builtin')
setDraft({
kind: 'builtin',
config: {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: value.kind === 'builtin' ? value.config.preset : 'flat',
},
});
else {
const descriptorPath = source.slice('project:'.length);
const map = maps.find((entry) => entry.descriptorPath === descriptorPath);
setDraft({
kind: 'project',
descriptorPath,
spawnPointId: map?.spawnPoints[0]?.id,
});
}
};
return (
<div className="space-y-4 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="text-xs font-semibold text-text-primary">地图参数</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
</p>
</div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图来源</span>
<Select
aria-label="地图来源"
className="w-full"
value={sourceValue}
disabled={loading}
onChange={(event) => changeSource(event.target.value)}
>
<option value="none">不使用地图</option>
<option value="builtin">内置程序地图</option>
{maps.map((map) => (
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
{map.name}
</option>
))}
</Select>
</label>
{draft.kind === 'builtin' && (
<>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图预设</span>
<Select
aria-label="物理地图预设"
className="w-full"
value={draft.config.preset}
disabled={loading}
onChange={(event) => updateBuiltin('preset', event.target.value as PhysicalMapPreset)}
>
{(Object.keys(PHYSICAL_MAP_PRESET_LABELS) as PhysicalMapPreset[])
.filter((preset) => preset !== 'none')
.map((preset) => (
<option key={preset} value={preset}>
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</option>
))}
</Select>
</label>
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary">地图变换</span>
<Button
disabled={
loading ||
(draft.config.positionX === 0 &&
draft.config.positionY === 0 &&
draft.config.yawDeg === 0)
}
onClick={() =>
setDraft({
kind: 'builtin',
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
})
}
>
复位
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 X(m)"
value={draft.config.positionX}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionX', next)}
/>
<NumberField
label="位置 Y(m)"
value={draft.config.positionY}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionY', next)}
/>
<NumberField
label="旋转 Z(°)"
value={draft.config.yawDeg}
min={-180}
max={180}
step={1}
disabled={loading}
onChange={(next) => updateBuiltin('yawDeg', next)}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
调整资产地图在世界坐标中的位置和朝向,应用后同步更新物理碰撞。
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<NumberField
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m)' : '地图半径(m)'}
value={draft.config.size}
min={4}
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
disabled={loading}
onChange={(next) => updateBuiltin('size', next)}
/>
<NumberField
label="摩擦系数"
value={draft.config.friction}
min={0.05}
max={5}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('friction', next)}
/>
{draft.config.preset === 'slope' && (
<NumberField
label="坡度(°)"
value={draft.config.slopeAngle}
min={5}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('slopeAngle', next)}
/>
)}
{draft.config.preset === 'stairs' && (
<NumberField
label="台阶数量"
value={draft.config.stairCount}
min={2}
max={20}
disabled={loading}
onChange={(next) => updateBuiltin('stairCount', next)}
/>
)}
{draft.config.preset === 'obstacles' && (
<NumberField
label="障碍物数量"
value={draft.config.obstacleCount}
min={1}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('obstacleCount', next)}
/>
)}
{(draft.config.preset === 'obstacles' ||
isSystemTerrainPreset(draft.config.preset)) && (
<NumberField
label="随机种子"
value={draft.config.seed}
min={0}
max={2_147_483_647}
disabled={loading}
onChange={(next) => updateBuiltin('seed', next)}
/>
)}
{isSystemTerrainPreset(draft.config.preset) && (
<NumberField
label="地形难度(0–1)"
value={draft.config.terrainDifficulty}
min={0}
max={1}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
/>
)}
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
<>
<NumberField
label="水平采样间距(m)"
value={draft.config.terrainHorizontalScale}
min={0.03}
max={1}
step={0.01}
disabled={loading}
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
/>
<NumberField
label="垂直量化(m)"
value={draft.config.terrainVerticalScale}
min={0.001}
max={0.1}
step={0.001}
disabled={loading}
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
/>
</>
)}
</div>
</>
)}
{draft.kind === 'project' && projectMap && (
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
<div className="break-all text-[11px] text-text-tertiary">
{projectMap.descriptorPath}
</div>
{projectMap.spawnPoints.length > 0 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">出生点</span>
<Select
aria-label="地图出生点"
className="w-full"
value={draft.spawnPointId ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
}
>
<option value="">不改变机器人位置</option>
{projectMap.spawnPoints.map((spawn) => (
<option key={spawn.id} value={spawn.id}>
{spawn.name}
</option>
))}
</Select>
</label>
)}
{rootBodies.length > 1 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">机器人根 Body</span>
<Select
aria-label="机器人根 Body"
className="w-full"
value={draft.robotRootBody ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
}
>
<option value="">自动识别</option>
{rootBodies.map((body) => (
<option key={body} value={body}>
{body}
</option>
))}
</Select>
</label>
)}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">覆盖摩擦系数(留空则使用地图值)</span>
<input
aria-label="地图摩擦系数覆盖"
type="number"
min={0.05}
max={5}
step={0.05}
value={draft.frictionOverride ?? ''}
disabled={loading}
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
onChange={(event) =>
setDraft({
...draft,
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
})
}
/>
</label>
</div>
)}
{draft.kind === 'project' &&
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
<div className="space-y-2 rounded border border-border-subtle p-3">
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
<MapEditorPanel
document={editorDocument}
loading={loading}
onPreview={onEditorPreview}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onTransformMode={onEditorTransformMode}
onSnapping={onEditorSnapping}
/>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
<label className="flex items-center justify-between gap-3">
<span>显示视觉地图</span>
<input
type="checkbox"
checked={showVisualMap}
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>显示地图碰撞层</span>
<input
type="checkbox"
checked={showMapCollision}
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
/>
</label>
</div>
{nativeUrdf && normalized.kind !== 'none' && (
<p
role="status"
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
>
原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
</p>
)}
<Button
variant="primary"
className="w-full"
disabled={compileDisabled || !changed}
onClick={() => onApply(normalized)}
>
应用并重新编译
</Button>
</div>
);
}
+125 -12
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import { Box, FolderTree, Info, Map as MapIcon, Settings2, SlidersHorizontal } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
@@ -34,6 +34,20 @@ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
export function SidebarPanel({
title,
@@ -51,8 +65,10 @@ export function SidebarPanel({
<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 px-3 text-sm font-semibold text-text-primary">
<Settings2 aria-hidden="true" className="h-4 w-4 text-accent" />
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
</span>
{title}
</h2>
{children}
@@ -69,9 +85,17 @@ export function ProjectSidebar({
snapshot,
loading,
visible = true,
nativeUrdf,
mapSelection,
editorDocument,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onJointHover,
onAddMapAsset,
onSelectTerrain,
onSelectMapObject,
}: {
projectName?: string;
files: ProjectTreeFile[];
@@ -80,19 +104,31 @@ export function ProjectSidebar({
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) {
const [tab, setTab] = useState<'project' | 'structure'>('project'),
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const fileMatches = countProjectSearchResults(files, fileQuery),
const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="工程资源" side="left" visible={visible}>
<SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -116,7 +152,10 @@ export function ProjectSidebar({
<Tabs
label="工程侧栏"
value={tab}
onValueChange={setTab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'project',
@@ -170,6 +209,26 @@ export function ProjectSidebar({
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]}
/>
</>
@@ -199,6 +258,11 @@ interface ModelControlsProps {
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
@@ -222,13 +286,24 @@ interface ModelControlsProps {
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls'>('properties'),
const [tab, setTab] = useState<'properties' | 'controls' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
</SidebarPanel>
);
@@ -439,11 +514,14 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
</>
);
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={setTab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
@@ -457,6 +535,41 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]}
/>
</SidebarPanel>
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Kbd } from '../../components/ui';
import { useAppStore } from '../../stores/useAppStore';
import { PerformancePopover } from './PerformancePopover';
export interface StatusBarProps {
time?: number;
@@ -49,3 +51,18 @@ export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: S
</footer>
);
}
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
export function StoreStatusBar() {
const metrics = useAppStore(
useShallow((state) => ({
time: state.snapshot?.time,
fps: state.fps,
stepMs: state.stepMs,
memoryMb: state.memoryMb,
loaded: Boolean(state.snapshot),
overBudget: state.overBudget,
})),
);
return <StatusBar {...metrics} />;
}
@@ -6,7 +6,8 @@ describe('WorkbenchHeader', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn();
speed = vi.fn(),
openSource = vi.fn();
render(
<WorkbenchHeader
paused
@@ -14,12 +15,14 @@ describe('WorkbenchHeader', () => {
speed={1}
theme="dark"
loading={false}
hasProject
leftOpen
rightOpen
fullscreen={false}
center={<span>工具</span>}
onFiles={fn}
onFolder={fn}
onOpenSource={openSource}
onTogglePause={pause}
onStep={step}
onReset={reset}
@@ -32,10 +35,14 @@ describe('WorkbenchHeader', () => {
onToggleFullscreen={fn}
/>,
);
const sourceButton = screen.getByRole('button', { name: '源代码' });
expect(sourceButton).toHaveTextContent('源代码');
fireEvent.click(sourceButton);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(openSource).toHaveBeenCalledTimes(1);
expect(pause).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1);
@@ -1,5 +1,6 @@
import type { ChangeEvent, ReactNode } from 'react';
import {
Boxes,
CircleHelp,
Code2,
Expand,
@@ -18,7 +19,7 @@ import {
} from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline';
export function WorkbenchHeader({
paused,
@@ -74,11 +75,16 @@ 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 px-2.5">
<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">
<div className="flex min-w-0 items-center gap-1">
<h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">
MuJoCo Web 仿真平台
</h1>
<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">
<Boxes aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<h1 className="truncate text-sm font-semibold tracking-tight text-text-primary">
MuJoCo Web 仿真平台
</h1>
</div>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
@@ -113,14 +119,16 @@ export function WorkbenchHeader({
onChange={onFolder}
/>
</label>
<IconButton
tooltip="查看和修改缓存源代码"
aria-label="源代码"
<button
type="button"
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
title="查看和修改缓存源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
>
<Code2 className="h-4 w-4" />
</IconButton>
<Code2 className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>源代码</span>
</button>
</div>
<div className="flex items-center justify-center">{center}</div>
<div className="flex min-w-0 items-center justify-end gap-0.5">
@@ -1,23 +1,31 @@
import {
Box,
CheckCircle2,
FileArchive,
FolderOpen,
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Sparkles,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress {
title?: string;
label: string;
detail?: string;
value: number;
}
const workflow = [
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
];
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
if (compact)
return (
@@ -29,65 +37,131 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
</div>
);
return (
<section
aria-label="导入模型工程"
className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"
className="workspace-welcome relative w-[min(600px,calc(100vw-32px))] overflow-hidden rounded-[22px] border border-border-strong bg-panel/90 px-6 py-6 text-center shadow-2xl backdrop-blur-md sm:px-7"
>
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<UploadCloud className="h-5 w-5" />
</span>
<h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
<div className="mt-4 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover focus-within:ring-2 focus-within:ring-accent/40"
<div aria-hidden="true" className="welcome-glow" />
<div className="relative">
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
<Sparkles className="h-3.5 w-3.5" />
Local Simulation Workspace
</div>
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent shadow-[0_12px_32px_rgba(53,199,146,0.16)]">
<UploadCloud className="h-6 w-6" />
</span>
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
拖放模型工程到此处
</h2>
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
本地解析、编译并运行机器人模型,无需上传资源
</p>
<div
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
aria-label="支持格式"
>
<Upload className="h-3.5 w-3.5" />
选择文件
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
选择文件夹
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 text-left">
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-medium text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
<span
key={format}
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
>
{format}
</span>
</li>
))}
</ol>
<p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3 w-3 text-success" />
模型与资源仅在当前浏览器会话中处理
</p>
))}
</div>
<div className="mt-5 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3.5 text-xs font-semibold text-white shadow-[0_8px_22px_rgba(22,131,95,0.22)] transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-accent-hover hover:shadow-[0_10px_26px_rgba(22,131,95,0.28)] focus-within:ring-2 focus-within:ring-accent/40"
>
<Upload className="h-3.5 w-3.5" />
选择文件
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-surface px-3.5 text-xs font-semibold text-text-primary shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
选择文件夹
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border/80 pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li
key={item.label}
className="rounded-xl border border-border/70 bg-surface/70 px-3 py-3 text-left"
>
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</li>
))}
</ol>
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
模型与资源仅在当前浏览器会话中处理
</p>
</div>
</section>
);
}
function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) {
const title = progress?.title ?? '正在加载 MuJoCo 与模型';
return (
<div
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-2xl border border-border-strong bg-panel/95 shadow-2xl backdrop-blur-md`}
>
<div className="h-0.5 bg-gradient-to-r from-transparent via-accent to-transparent opacity-90" />
<div className={compact ? 'px-4 py-3.5' : 'px-5 py-5'}>
<div className="flex items-start gap-3">
<span className="relative mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin" />
<span className="absolute inset-0 animate-ping rounded-xl border border-accent/20" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-text-primary">{title}</div>
<div className="mt-0.5 truncate text-[11px] text-text-tertiary">
{progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
</div>
</div>
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
</div>
</div>
);
}
export function WorkspaceOverlays({
loading,
hasSnapshot,
dragActive = false,
progress,
}: {
loading: boolean;
hasSnapshot: boolean;
dragActive?: boolean;
progress?: ImportProgress;
}) {
return (
@@ -104,23 +178,26 @@ export function WorkspaceOverlays({
role="status"
aria-live="polite"
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"
className={
hasSnapshot
? 'pointer-events-none absolute right-4 top-4 z-30'
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
}
>
<div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl">
<div className="flex items-center gap-3">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent" />
正在加载 MuJoCo 与模型…
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
<LoadingCard progress={progress} compact={hasSnapshot} />
</div>
)}
{dragActive && !loading && (
<div className="pointer-events-none absolute inset-3 z-40 grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent-soft/20 shadow-[inset_0_0_80px_rgba(53,199,146,0.08)] backdrop-blur-[2px]">
<div className="rounded-2xl border border-accent/30 bg-panel/95 px-7 py-5 text-center shadow-2xl">
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<FileArchive className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-text-primary">松开即可导入工程</p>
<p className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-text-tertiary">
<CheckCircle2 className="h-3 w-3 text-success" />
文件、文件夹与 ZIP 都可以直接解析
</p>
</div>
</div>
)}