diff --git a/web_platform/src/map/MapComposer.test.ts b/web_platform/src/map/MapComposer.test.ts
index 0306ce27..1d832c18 100644
--- a/web_platform/src/map/MapComposer.test.ts
+++ b/web_platform/src/map/MapComposer.test.ts
@@ -87,6 +87,39 @@ describe('composeProjectMap', () => {
expect(result.warnings.join(' ')).toContain('入口');
});
+ it('对工程地图物理层和出生点应用同一个实例变换', () => {
+ const result = composeProjectMap(robot, 'robot/scene.xml', manifest(), definition, {
+ kind: 'project',
+ descriptorPath: definition.descriptorPath,
+ positionX: 2,
+ positionY: -3,
+ yawDeg: 90,
+ spawnPointId: 'door',
+ });
+ const document = new DOMParser().parseFromString(
+ decoder.decode(result.data),
+ 'application/xml',
+ );
+ const instanceRoot = document.querySelector(
+ 'body[name="__platform_map_warehouse_instance_root"]',
+ );
+ expect(instanceRoot?.getAttribute('pos')).toBe('2 -3 0');
+ expect(instanceRoot?.getAttribute('quat')?.split(/\s+/).map(Number)).toEqual([
+ expect.closeTo(Math.SQRT1_2),
+ 0,
+ 0,
+ expect.closeTo(Math.SQRT1_2),
+ ]);
+ const position = document
+ .querySelector('body[name="robot"]')!
+ .getAttribute('pos')!
+ .split(/\s+/)
+ .map(Number);
+ expect(position[0]).toBeCloseTo(-19);
+ expect(position[1]).toBeCloseTo(7);
+ expect(position[2]).toBeCloseTo(0.5);
+ });
+
it('地图明确提供地面时替换平台基础地面', () => {
const floorMap = manifest(
'
',
diff --git a/web_platform/src/map/MapComposer.ts b/web_platform/src/map/MapComposer.ts
index ef867d6a..9ffeb757 100644
--- a/web_platform/src/map/MapComposer.ts
+++ b/web_platform/src/map/MapComposer.ts
@@ -1,6 +1,12 @@
import type { ProjectManifest } from '../project/types';
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
-import type { MapSelection, ResolvedProjectMap, SpawnPoint } from './types';
+import {
+ mapLocalPointToWorld,
+ mapSelectionTransform,
+ type MapSelection,
+ type ResolvedProjectMap,
+ type SpawnPoint,
+} from './types';
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
@@ -23,6 +29,13 @@ export interface ProjectMapComposition {
summary: string;
}
+export interface ProjectMapCompositionOptions {
+ /** 场景实例命名空间;允许同一 map.json 被重复放置。 */
+ instanceId?: string;
+ /** 多地图场景中只允许一个实例移动机器人到出生点。 */
+ applySpawnPoint?: boolean;
+}
+
function xml(data: Uint8Array, label: string): Document {
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`);
@@ -46,6 +59,19 @@ function multiplyQuaternion(a: number[], b: number[]): [number, number, number,
];
}
+function transformSpawnPoint(
+ spawn: SpawnPoint | undefined,
+ selection: Extract
,
+): SpawnPoint | undefined {
+ if (!spawn) return undefined;
+ const transform = mapSelectionTransform(selection);
+ return {
+ ...spawn,
+ position: mapLocalPointToWorld(spawn.position, selection),
+ yawDeg: spawn.yawDeg + transform.yawDeg,
+ };
+}
+
function applySpawnPoint(
document: Document,
spawn: SpawnPoint | undefined,
@@ -145,6 +171,22 @@ function mapProvidesGround(document: Document): boolean {
return false;
}
+function applyMapInstanceTransform(
+ document: Document,
+ selection: Extract,
+): void {
+ const worldbody = document.querySelector('mujoco > worldbody');
+ if (!worldbody) throw new Error('物理地图缺少 worldbody');
+ const transform = mapSelectionTransform(selection);
+ const root = document.createElement('body');
+ const halfYaw = (transform.yawDeg * Math.PI) / 360;
+ root.setAttribute('name', 'instance_root');
+ root.setAttribute('pos', `${transform.positionX} ${transform.positionY} 0`);
+ root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
+ for (const child of Array.from(worldbody.children)) root.append(child);
+ worldbody.append(root);
+}
+
function namespaceMap(document: Document, mapId: string): void {
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
const names = new Map();
@@ -203,19 +245,22 @@ export function composeProjectMap(
manifest: ProjectManifest,
resolvedMap: ResolvedProjectMap,
selection: Extract,
+ options: ProjectMapCompositionOptions = {},
): ProjectMapComposition {
const robot = xml(robotSource, '机器人 MJCF');
const robotWorldbody = robot.querySelector('mujoco > worldbody');
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
const warnings: string[] = [];
- const spawn = selection.spawnPointId
- ? resolvedMap.definition.spawnPoints.find(
- (candidate) => candidate.id === selection.spawnPointId,
- )
- : undefined;
- if (selection.spawnPointId && !spawn)
+ const shouldApplySpawn = options.applySpawnPoint ?? true;
+ const spawn =
+ shouldApplySpawn && selection.spawnPointId
+ ? resolvedMap.definition.spawnPoints.find(
+ (candidate) => candidate.id === selection.spawnPointId,
+ )
+ : undefined;
+ if (shouldApplySpawn && selection.spawnPointId && !spawn)
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
- applySpawnPoint(robot, spawn, selection.robotRootBody, warnings);
+ applySpawnPoint(robot, transformSpawnPoint(spawn, selection), selection.robotRootBody, warnings);
let geomCount = 0;
if (resolvedMap.physicsPath) {
@@ -225,7 +270,13 @@ export function composeProjectMap(
validateMapStructure(map);
const replacesGround = mapProvidesGround(map);
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
- namespaceMap(map, resolvedMap.definition.id);
+ applyMapInstanceTransform(map, selection);
+ namespaceMap(
+ map,
+ options.instanceId
+ ? `${resolvedMap.definition.id}_${options.instanceId}`
+ : resolvedMap.definition.id,
+ );
if (selection.frictionOverride !== undefined) {
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
diff --git a/web_platform/src/map/MapEditorPanel.test.tsx b/web_platform/src/map/MapEditorPanel.test.tsx
index 00b8db82..f4fad62b 100644
--- a/web_platform/src/map/MapEditorPanel.test.tsx
+++ b/web_platform/src/map/MapEditorPanel.test.tsx
@@ -1,8 +1,10 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import type { EditableMapDocument } from './editor/types';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { EditableMapDocument, MapEditorInteractionCallbacks } from './editor/types';
import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = {
+ descriptorPath: 'maps/map/map.json',
+ onDraftChange: () => {},
onConvert: async () => true,
canConvert: true,
onBindInteraction: () => {},
@@ -34,18 +36,84 @@ describe('MapEditorPanel', () => {
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
+ fireEvent.change(screen.getByLabelText('对象滑动摩擦'), { target: { value: '0.8' } });
+ fireEvent.change(screen.getByLabelText('对象颜色'), { target: { value: '#ff0000' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
+ expect(preview).not.toHaveBeenLastCalledWith(null);
expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({
revision: 1,
objects: [
- expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
+ expect.objectContaining({
+ pose: expect.objectContaining({ position: [2, 0, 0.5] }),
+ friction: [0.8, 0.005, 0.0001],
+ rgba: [1, 0, 0, 1],
+ }),
],
}),
);
});
+ it('把认证资产编辑实时上报为场景级草稿', () => {
+ const onDraftChange = vi.fn();
+ render(
+ {}}
+ onDraftChange={onDraftChange}
+ onApply={async () => true}
+ onExport={() => {}}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '新增' }));
+ expect(onDraftChange).toHaveBeenLastCalledWith(
+ 'maps/map/map.json',
+ expect.objectContaining({ objects: [expect.objectContaining({ type: 'box' })] }),
+ true,
+ );
+ });
+
+ it('把外部拾取同步到属性表单和视口操纵器', async () => {
+ const onSelectPreview = vi.fn();
+ let interaction: MapEditorInteractionCallbacks | null = null;
+ render(
+ {}}
+ onSelectPreview={onSelectPreview}
+ onBindInteraction={(callbacks) => {
+ interaction = callbacks;
+ }}
+ onApply={async () => true}
+ onExport={() => {}}
+ />,
+ );
+
+ act(() => interaction?.onSelect('box_1'));
+ await waitFor(() => expect(onSelectPreview).toHaveBeenLastCalledWith('box_1'));
+ expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
+ });
+
it('锁定放置方式后禁用位姿编辑', () => {
render(
void;
+ onDraftChange: (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => void;
onApply: (document: EditableMapDocument) => Promise;
onExport: () => void;
onConvert: () => Promise;
@@ -77,8 +85,15 @@ export function MapEditorPanel({
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
+ onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
}) {
- const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
+ // draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
+ const session = useMemo(
+ () =>
+ document ? new MapEditSession(document, draftDocument ?? document, onSurfaceHeight) : null,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [document, descriptorPath, onSurfaceHeight],
+ );
const [, render] = useState(0),
[selected, setSelected] = useState(),
[selectedType, setSelectedType] = useState('box'),
@@ -86,13 +101,17 @@ export function MapEditorPanel({
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const refresh = () => {
+ const next = session?.document ?? null;
render((value) => value + 1);
- onPreview(session?.document ?? null);
+ onPreview(next);
+ if (session && next) onDraftChange(descriptorPath, next, session.dirty);
};
useEffect(() => {
- onPreview(session?.document ?? null);
+ const next = session?.document ?? null;
+ onPreview(next);
+ if (session && next) onDraftChange(descriptorPath, next, session.dirty);
return () => onPreview(null);
- }, [session, onPreview]);
+ }, [session, descriptorPath, onPreview, onDraftChange]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
@@ -101,6 +120,7 @@ export function MapEditorPanel({
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onTransform: ({ id, position, quaternion, scale }) => {
+ if (loading) return;
const object = session.document.objects.find((item) => item.id === id);
if (!object) return;
session.update(id, {
@@ -110,18 +130,32 @@ export function MapEditorPanel({
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
+ onDraftChange(descriptorPath, session.document, session.dirty);
},
- onAddAsset: (type, position, placementMode) => {
- const object = session.addAsset(type, position, placementMode);
+ onAddAsset: (type, position, placementMode, externalSupportTop) => {
+ if (loading) return;
+ const object = session.addAsset(type, position, placementMode, externalSupportTop);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
+ onDraftChange(descriptorPath, session.document, session.dirty);
onSelectPreview(object.id);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
- }, [session, onBindInteraction, onPreview, onSelectPreview]);
+ }, [
+ session,
+ descriptorPath,
+ loading,
+ onBindInteraction,
+ onPreview,
+ onDraftChange,
+ onSelectPreview,
+ ]);
+ useEffect(() => {
+ if (session && selected) onSelectPreview(selected);
+ }, [session, selected, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
@@ -129,6 +163,7 @@ export function MapEditorPanel({
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
+ if (loading) return;
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
@@ -143,11 +178,13 @@ export function MapEditorPanel({
}
render((value) => value + 1);
onPreview(session.document);
+ onDraftChange(descriptorPath, session.document, session.dirty);
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
+ onDraftChange(descriptorPath, session.document, session.dirty);
} 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');
@@ -160,12 +197,13 @@ export function MapEditorPanel({
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
+ onDraftChange(descriptorPath, session.document, session.dirty);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
- }, [session, selected, onPreview, onSelectPreview]);
+ }, [session, descriptorPath, selected, loading, onPreview, onDraftChange, onSelectPreview]);
if (!session)
return (
@@ -196,26 +234,45 @@ export function MapEditorPanel({
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;
+ if (loading || !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;
+ if (loading || !active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
+ const updateFriction = (index: number, value: number) => {
+ if (loading || !active || !Number.isFinite(value) || value < 0) return;
+ const friction = [...active.friction] as [number, number, number];
+ friction[index] = value;
+ session.update(active.id, { friction });
+ refresh();
+ };
+ const updateColor = (value: string) => {
+ if (loading || !active || !/^#[0-9a-f]{6}$/i.test(value)) return;
+ session.update(active.id, {
+ 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,
+ active.rgba[3],
+ ],
+ });
+ refresh();
+ };
const updateSpawnPosition = (
id: string,
position: [number, number, number],
index: number,
value: number,
) => {
- if (!Number.isFinite(value)) return;
+ if (loading || !Number.isFinite(value)) return;
const next = [...position] as [number, number, number];
next[index] = value;
session.updateSpawn(id, { position: next });
@@ -224,45 +281,14 @@ export function MapEditorPanel({
return (
-
-
-
-
-
-
- 在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
-
-
+