feat(web-platform): release V0.7 地图模块
This commit is contained in:
@@ -22,6 +22,46 @@ const SIMPLE_MODEL = `
|
||||
|
||||
const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
|
||||
|
||||
function minimalGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
buffers: [{ byteLength: 36 }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
componentType: 5126,
|
||||
count: 3,
|
||||
type: 'VEC3',
|
||||
min: [0, 0, 0],
|
||||
max: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const jsonPadding = (4 - (json.length % 4)) % 4;
|
||||
const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]);
|
||||
const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer);
|
||||
const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length;
|
||||
const glb = Buffer.alloc(totalLength);
|
||||
glb.writeUInt32LE(0x46546c67, 0);
|
||||
glb.writeUInt32LE(2, 4);
|
||||
glb.writeUInt32LE(totalLength, 8);
|
||||
glb.writeUInt32LE(jsonChunk.length, 12);
|
||||
glb.writeUInt32LE(0x4e4f534a, 16);
|
||||
jsonChunk.copy(glb, 20);
|
||||
const binOffset = 20 + jsonChunk.length;
|
||||
glb.writeUInt32LE(positions.length, binOffset);
|
||||
glb.writeUInt32LE(0x004e4942, binOffset + 4);
|
||||
positions.copy(glb, binOffset + 8);
|
||||
return glb;
|
||||
}
|
||||
|
||||
const LARGE_MODEL = `
|
||||
<mujoco model="soak">
|
||||
<option timestep=".005"/>
|
||||
@@ -420,6 +460,284 @@ test('中等规模模型持续步进并可重复加载', async ({ page }) => {
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
|
||||
const library = page.getByLabel('认证资产');
|
||||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
|
||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
|
||||
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
|
||||
await expect(page.getByLabel('地图对象列表').getByText('标准坡道 · 坡道')).toBeVisible();
|
||||
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
|
||||
|
||||
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
|
||||
await page.getByLabel('对象放置方式').selectOption('locked');
|
||||
await expect(page.getByLabel('对象位置X')).toBeDisabled();
|
||||
await page.getByLabel('对象放置方式').selectOption('auto_ground');
|
||||
await expect(page.getByLabel('对象位置X')).toBeEnabled();
|
||||
|
||||
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
|
||||
const beforeRotation = await gizmoLine.getAttribute('x2');
|
||||
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24);
|
||||
await page.mouse.down({ button: 'left' });
|
||||
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
||||
await page.mouse.up({ button: 'left' });
|
||||
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
||||
});
|
||||
|
||||
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'map-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
await page.getByLabel('物理地图预设').selectOption('stairs');
|
||||
await page.getByLabel('台阶数量').fill('6');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('依次应用全部系统参数化地形', async ({ page }) => {
|
||||
const terrains = [
|
||||
['discrete_obstacles', '离散障碍地形'],
|
||||
['gap', '沟壑地形'],
|
||||
['inverted_pyramid_stairs', '倒金字塔阶梯'],
|
||||
['pit', '深坑地形'],
|
||||
['pyramid_stairs', '金字塔阶梯'],
|
||||
['rails', '轨道地形'],
|
||||
['rough', '随机粗糙地形'],
|
||||
['stepping_stones', '踏石地形'],
|
||||
['wave', '波浪地形'],
|
||||
] as const;
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'terrain-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
for (const [preset, label] of terrains) {
|
||||
await page.getByLabel('物理地图预设').selectOption(preset);
|
||||
await page.getByLabel('地形边长(m)').fill('6');
|
||||
if (preset === 'rough' || preset === 'wave')
|
||||
await page.getByLabel('水平采样间距(m)').fill('0.25');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('导入并应用分层工程地图包', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'test-room',
|
||||
name: '测试场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
visual: { source: 'visuals/scene.glb' },
|
||||
spawnPoints: [{ id: 'start', name: '起点', position: [2, 0, 0.5], yawDeg: 0 }],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/test/map.json': Buffer.from(mapJson),
|
||||
'maps/test/physics/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/><geom name="wall" type="box" pos="3 0 .5" size=".1 2 .5"/></worldbody></mujoco>',
|
||||
),
|
||||
'maps/test/visuals/scene.glb': minimalGlb(),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'map-project.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
||||
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('将受支持的只读物理地图转换为可编辑副本', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'legacy-room',
|
||||
name: '旧版基础场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
spawnPoints: [{ id: 'start', name: '入口', position: [0, 0, 0], yawDeg: 0 }],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/legacy/map.json': Buffer.from(mapJson),
|
||||
'maps/legacy/physics/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/><body name="wall" pos="2 0 .5"><geom type="box" size=".1 1 .5"/></body></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'legacy-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
|
||||
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
|
||||
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
|
||||
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
|
||||
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
|
||||
const authoring = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
mapId: 'editable-room',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
});
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: 'editable-room',
|
||||
name: '可编辑场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
authoring: { source: 'authoring/map.scene.json' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/edit/map.json': Buffer.from(mapJson),
|
||||
'maps/edit/physics/world.xml': Buffer.from('<mujoco><worldbody/></mujoco>'),
|
||||
'maps/edit/authoring/map.scene.json': Buffer.from(authoring),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'editable-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('e');
|
||||
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('s');
|
||||
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await editor.getByRole('button', { name: '新增', exact: true }).click();
|
||||
await editor.getByLabel('对象位置X').fill('2');
|
||||
await editor.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('工程地图编译失败时保留上一仿真会话', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'invalid-map',
|
||||
name: '动态错误地图',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'world.xml' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/invalid/map.json': Buffer.from(mapJson),
|
||||
'maps/invalid/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'invalid-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||
await page.getByRole('button', { name: '关闭错误' }).click();
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||
});
|
||||
|
||||
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
|
||||
|
||||
Reference in New Issue
Block a user