502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|