Files
Mujoco_WASM/web_platform/src/map/MapObjectInspector.tsx
T
chenlin 13e35be98b
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.9.3 全模块界面重构
2026-09-08 17:57:57 +08:00

331 lines
11 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 { 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: '尺寸 Xm',
sizeY: '尺寸 Ym',
sizeZ: '尺寸 Zm',
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>
);
}