Files
Mujoco_WASM/web_platform/src/app/components/MapEditorPanel.tsx
T
chenlin 3b4ced6437
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.7 地图模块
2026-09-01 14:23:49 +08:00

598 lines
21 KiB
TypeScript

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>
);
}