feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
|
||||
import { Button, ScrubbableNumberInput, Select } 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="engineering-card rounded-lg border p-2.5">
|
||||
<h3 className="mb-2 text-[10px] 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-[11px] 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-[11px] 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-[10px] 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" />}
|
||||
在视口工具条切换
|
||||
</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)`}
|
||||
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 旋转(°)"
|
||||
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}
|
||||
aria-label={`对象参数${key}`}
|
||||
value={value}
|
||||
min={0.001}
|
||||
step={key === 'count' ? 1 : 0.1}
|
||||
disabled={loading}
|
||||
onValueChange={(next) => updateParameter(key, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="表面材质">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="text-[10px] 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-[10px] 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],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="摩擦力">
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user