Files
Mujoco_WASM/web_platform/src/map/MapAssetLibrary.tsx
T
chenlin a9b07e0abf
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(web-platform): release V0.8.1 常规地图优化
2026-09-03 13:18:35 +08:00

502 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import {
BadgeCheck,
CheckCircle2,
Layers3,
MapPinned,
Mountain,
Plus,
RotateCcw,
Trash2,
Zap,
} from 'lucide-react';
import { Button, Select } from '../components/ui';
import {
CERTIFIED_MAP_ASSETS,
encodeMapLibraryDragPayload,
MAP_LIBRARY_DRAG_MIME,
} from './editor/assetCatalog';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from './editor/types';
import type { MapEntry } from '../project/types';
import {
PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS,
type PlacedMapAsset,
type SystemTerrainPreset,
} from './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,
documents,
maps = [],
placedMaps = [],
activeMapId,
pendingSceneChangeCount = 0,
pendingSceneIds = [],
onAdd,
onAddProjectMap,
onSelectTerrain,
onApplyScene,
onDiscardScene,
onSelectMap,
onRemoveMap,
onSelectObject,
}: {
disabled: boolean;
terrainSize: number;
document?: EditableMapDocument | null;
documents?: ReadonlyMap<string, EditableMapDocument>;
maps?: MapEntry[];
placedMaps?: PlacedMapAsset[];
activeMapId?: string;
pendingSceneChangeCount?: number;
pendingSceneIds?: string[];
onAdd: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onAddProjectMap?: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onApplyScene?: () => void;
onDiscardScene?: () => void;
onSelectMap?: (id: string) => void;
onRemoveMap?: (id: string) => void;
onSelectObject?: (mapId: string, objectId: string) => void;
}) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
const pendingSceneIdSet = new Set(pendingSceneIds);
const placedObjectCount = placedMaps.reduce(
(total, map) =>
total +
(map.selection.kind === 'project'
? (documents?.get(map.selection.descriptorPath)?.objects.length ?? 0)
: 0),
0,
);
return (
<section aria-label="地图资产库" className="space-y-3 p-3">
<header>
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
<MapPinned 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>
</header>
<div
className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5"
aria-label="场景资产树"
>
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span className="flex items-center gap-1.5">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
已放置地图
</span>
<span className="technical-value text-[10px] text-text-tertiary">
{placedMaps.length} 个实例
{placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
</span>
</div>
{placedMaps.length ? (
<div className="mt-2 max-h-52 space-y-0.5 overflow-auto panel-scroll" role="tree">
{placedMaps.map((map) => {
const active = map.id === activeMapId;
const mapDocument =
map.selection.kind === 'project'
? (documents?.get(map.selection.descriptorPath) ?? (active ? document : null))
: null;
return (
<div key={map.id} role="treeitem" aria-selected={active}>
<div
className={`flex items-center gap-1 rounded ${active ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-[11px] disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`选择地图实例 ${map.name}`}
disabled={disabled}
onClick={() => onSelectMap?.(map.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{map.name}</span>
<span className="text-[9px] text-text-tertiary">
{pendingSceneIdSet.has(map.id)
? '待应用'
: map.selection.kind === 'builtin'
? '参数地形'
: '工程地图'}
</span>
</button>
<button
type="button"
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
aria-label={`删除地图实例 ${map.name}`}
disabled={disabled}
onClick={() => onRemoveMap?.(map.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{mapDocument?.objects.length ? (
<div className="ml-4 border-l border-border pl-1" role="group">
{mapDocument.objects.map((object) => (
<button
key={object.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px] hover:bg-element-hover hover:text-text-primary ${active ? 'text-text-secondary' : 'text-text-tertiary'}`}
onClick={() => onSelectObject?.(map.id, 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>
) : null}
</div>
);
})}
</div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
尚未放置地图。从下方工程地图、认证资产或参数地形添加,实例会在这里统一管理。
</p>
)}
</div>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="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>
<p className="mt-1 leading-relaxed">
工程地图、认证资产与参数地形共享同一草稿;继续编辑不会触发 MuJoCo 编译。
</p>
<div className="mt-2 flex gap-2">
<Button
variant="primary"
className="min-w-0 flex-1"
disabled={disabled}
onClick={onApplyScene}
>
一次编译应用
</Button>
<Button
variant="ghost"
aria-label="放弃场景更改"
disabled={disabled}
onClick={onDiscardScene}
>
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
放弃
</Button>
</div>
</div>
)}
{maps.length > 0 && (
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="text-xs font-medium text-text-primary">工程地图源</div>
<p className="mt-1 text-[10px] text-text-tertiary">
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
</p>
<div className="mt-2 space-y-1">
{maps.map((map) => (
<div
key={map.descriptorPath}
draggable={!disabled}
data-project-map={map.descriptorPath}
className={`flex items-center gap-2 rounded border border-border-subtle px-2 py-1.5 ${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_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({
kind: 'project',
descriptorPath: map.descriptorPath,
}),
);
event.dataTransfer.setData('text/plain', map.name);
}}
>
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
{map.name}
</span>
<Button
disabled={disabled}
aria-label={`放置工程地图 ${map.name}`}
onClick={() => onAddProjectMap?.(map.descriptorPath)}
>
添加
</Button>
</div>
))}
</div>
</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_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({
kind: 'certified',
type: asset.type,
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}
draggable={!disabled}
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' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(
MAP_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({ kind: 'terrain', preset }),
);
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
}}
>
<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>
);
}