331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
|
||
import { Button, ScrubbableNumberInput, Select, Tooltip } from '../components/ui';
|
||
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
|
||
|
||
const PARAMETER_LABELS: Record<string, string> = {
|
||
sizeX: '尺寸 X(m)',
|
||
sizeY: '尺寸 Y(m)',
|
||
sizeZ: '尺寸 Z(m)',
|
||
radius: '半径(m)',
|
||
height: '高度(m)',
|
||
length: '长度(m)',
|
||
width: '宽度(m)',
|
||
rise: '抬升(m)',
|
||
thickness: '厚度(m)',
|
||
stepDepth: '踏步深度(m)',
|
||
stepHeight: '踏步高度(m)',
|
||
count: '踏步数量',
|
||
};
|
||
|
||
const SURFACE_PRESETS = {
|
||
standard: {
|
||
label: '标准防滑材质',
|
||
rgb: [0.55, 0.6, 0.68] as const,
|
||
friction: [1, 0.005, 0.0001] as const,
|
||
},
|
||
concrete: {
|
||
label: '混凝土',
|
||
rgb: [0.55, 0.58, 0.62] as const,
|
||
friction: [1, 0.005, 0.0001] as const,
|
||
},
|
||
rubber: {
|
||
label: '橡胶',
|
||
rgb: [0.12, 0.14, 0.16] as const,
|
||
friction: [1.5, 0.008, 0.0002] as const,
|
||
},
|
||
metal: {
|
||
label: '金属',
|
||
rgb: [0.42, 0.48, 0.54] as const,
|
||
friction: [0.45, 0.003, 0.0001] as const,
|
||
},
|
||
ice: {
|
||
label: '低摩擦冰面',
|
||
rgb: [0.58, 0.82, 0.94] as const,
|
||
friction: [0.08, 0.001, 0.00001] as const,
|
||
},
|
||
} as const;
|
||
|
||
type SurfacePreset = keyof typeof SURFACE_PRESETS;
|
||
|
||
function close(a: number, b: number): boolean {
|
||
return Math.abs(a - b) < 0.015;
|
||
}
|
||
|
||
function selectedSurfacePreset(object: EditableMapObject): SurfacePreset | 'custom' {
|
||
for (const [key, preset] of Object.entries(SURFACE_PRESETS) as [
|
||
SurfacePreset,
|
||
(typeof SURFACE_PRESETS)[SurfacePreset],
|
||
][]) {
|
||
if (
|
||
preset.rgb.every((channel, index) => close(channel, object.rgba[index])) &&
|
||
close(preset.friction[0], object.friction[0])
|
||
)
|
||
return key;
|
||
}
|
||
return 'custom';
|
||
}
|
||
|
||
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 colorValue(object: EditableMapObject): string {
|
||
return `#${object.rgba
|
||
.slice(0, 3)
|
||
.map((channel) =>
|
||
Math.round(Math.min(1, Math.max(0, channel)) * 255)
|
||
.toString(16)
|
||
.padStart(2, '0'),
|
||
)
|
||
.join('')}`;
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="border-b border-border-subtle pb-3">
|
||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||
{title}
|
||
</h3>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function MapObjectInspector({
|
||
object,
|
||
loading,
|
||
onUpdate,
|
||
onDuplicate,
|
||
onDelete,
|
||
}: {
|
||
object: EditableMapObject;
|
||
loading: boolean;
|
||
onUpdate: (patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>) => void;
|
||
onDuplicate: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const poseLocked = object.placementMode === 'locked';
|
||
const updatePosition = (index: number, value: number) => {
|
||
if (!Number.isFinite(value)) return;
|
||
const position = [...object.pose.position] as [number, number, number];
|
||
position[index] = value;
|
||
onUpdate({ pose: { ...object.pose, position } });
|
||
};
|
||
const updateParameter = (key: string, value: number) => {
|
||
if (!Number.isFinite(value) || value <= 0) return;
|
||
onUpdate({
|
||
parameters: {
|
||
...object.parameters,
|
||
[key]: key === 'count' ? Math.round(value) : value,
|
||
},
|
||
});
|
||
};
|
||
const updateFriction = (index: number, value: number) => {
|
||
if (!Number.isFinite(value) || value < 0) return;
|
||
const friction = [...object.friction] as [number, number, number];
|
||
friction[index] = value;
|
||
onUpdate({ friction });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2" aria-label="地图物体检查器">
|
||
<Section title="对象">
|
||
<div className="space-y-2">
|
||
<label className="block text-xs text-text-secondary">
|
||
名称
|
||
<input
|
||
aria-label="对象名称"
|
||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-2"
|
||
value={object.name}
|
||
disabled={loading}
|
||
onChange={(event) => {
|
||
if (event.target.value.trim()) onUpdate({ name: event.target.value });
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between text-xs text-text-secondary">
|
||
<span>启用碰撞与渲染</span>
|
||
<input
|
||
aria-label="启用地图对象"
|
||
type="checkbox"
|
||
checked={object.enabled}
|
||
disabled={loading}
|
||
onChange={(event) => onUpdate({ enabled: event.target.checked })}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="位姿">
|
||
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-xs text-text-secondary">
|
||
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
|
||
<span className="flex items-center gap-1 text-text-tertiary">
|
||
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
|
||
<Tooltip content="在视口底部切换放置方式;Z 随贴地或支撑面计算,锁定时不可移动。">
|
||
<button type="button" className="min-h-7 underline decoration-dotted">
|
||
放置说明
|
||
</button>
|
||
</Tooltip>
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{object.pose.position.map((value, index) => (
|
||
<ScrubbableNumberInput
|
||
key={index}
|
||
label={`${['X', 'Y', 'Z'][index]}(m)`}
|
||
axis={(['x', 'y', 'z'] as const)[index]}
|
||
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
||
value={value}
|
||
step={0.1}
|
||
disabled={loading || poseLocked || index === 2}
|
||
onValueChange={(next) => updatePosition(index, next)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<ScrubbableNumberInput
|
||
containerClassName="mt-2"
|
||
label="绕 Z 旋转(°)"
|
||
axis="z"
|
||
aria-label="对象绕Z旋转"
|
||
value={Number(yawDegrees(object).toFixed(4))}
|
||
step={1}
|
||
disabled={loading || poseLocked}
|
||
onValueChange={(next) => {
|
||
const half = (next * Math.PI) / 360;
|
||
onUpdate({
|
||
pose: {
|
||
...object.pose,
|
||
quaternion: [Math.cos(half), 0, 0, Math.sin(half)],
|
||
},
|
||
});
|
||
}}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="尺寸">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{Object.entries(object.parameters).map(([key, value]) => (
|
||
<ScrubbableNumberInput
|
||
key={key}
|
||
label={PARAMETER_LABELS[key] ?? key}
|
||
labelClassName="whitespace-normal"
|
||
aria-label={`对象参数${key}`}
|
||
value={value}
|
||
min={0.001}
|
||
step={key === 'count' ? 1 : 0.1}
|
||
disabled={loading}
|
||
onValueChange={(next) => updateParameter(key, next)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<details className="domain-details">
|
||
<summary>表面材质</summary>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<label className="text-xs text-text-secondary">
|
||
材质预设
|
||
<Select
|
||
aria-label="对象表面材质"
|
||
className="mt-1 w-full"
|
||
value={selectedSurfacePreset(object)}
|
||
disabled={loading}
|
||
onChange={(event) => {
|
||
if (event.target.value === 'custom') return;
|
||
const preset = SURFACE_PRESETS[event.target.value as SurfacePreset];
|
||
onUpdate({
|
||
rgba: [...preset.rgb, object.rgba[3]],
|
||
friction: [...preset.friction],
|
||
});
|
||
}}
|
||
>
|
||
<option value="custom">自定义</option>
|
||
{(
|
||
Object.entries(SURFACE_PRESETS) as [
|
||
SurfacePreset,
|
||
(typeof SURFACE_PRESETS)[SurfacePreset],
|
||
][]
|
||
).map(([value, preset]) => (
|
||
<option key={value} value={value}>
|
||
{preset.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</label>
|
||
<label className="text-xs text-text-secondary">
|
||
表面颜色
|
||
<input
|
||
aria-label="对象颜色"
|
||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
|
||
type="color"
|
||
value={colorValue(object)}
|
||
disabled={loading}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
if (!/^#[0-9a-f]{6}$/i.test(value)) return;
|
||
onUpdate({
|
||
rgba: [
|
||
Number.parseInt(value.slice(1, 3), 16) / 255,
|
||
Number.parseInt(value.slice(3, 5), 16) / 255,
|
||
Number.parseInt(value.slice(5, 7), 16) / 255,
|
||
object.rgba[3],
|
||
],
|
||
});
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<ScrubbableNumberInput
|
||
containerClassName="mt-2"
|
||
label="不透明度"
|
||
aria-label="对象不透明度"
|
||
value={object.rgba[3]}
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
disabled={loading}
|
||
onValueChange={(alpha) =>
|
||
onUpdate({
|
||
rgba: [object.rgba[0], object.rgba[1], object.rgba[2], alpha],
|
||
})
|
||
}
|
||
/>
|
||
</details>
|
||
|
||
<details className="domain-details">
|
||
<summary>摩擦力</summary>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{object.friction.map((value, index) => (
|
||
<ScrubbableNumberInput
|
||
key={index}
|
||
label={['滑动', '扭转', '滚动'][index]}
|
||
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
|
||
value={value}
|
||
min={0}
|
||
max={5}
|
||
step={index === 0 ? 0.05 : 0.0001}
|
||
disabled={loading}
|
||
onValueChange={(next) => updateFriction(index, next)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</details>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
|
||
复制对象
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
disabled={loading}
|
||
icon={<Trash2 className="h-3 w-3" />}
|
||
onClick={onDelete}
|
||
>
|
||
删除对象
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|