feat(web-platform): release V0.7 地图模块
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-01 14:23:49 +08:00
parent 60d3a6d68c
commit 3b4ced6437
58 changed files with 7287 additions and 269 deletions
+127 -24
View File
@@ -1,4 +1,4 @@
import loadMujoco, { type MainModule } from '@mujoco/mujoco';
import type { MainModule } from '@mujoco/mujoco';
import type { ProjectFile, ProjectManifest } from '../project/types';
import { prepareProjectForMujoco } from '../project/importer';
import {
@@ -16,17 +16,32 @@ import {
} from './SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types';
import { composePhysicalMap } from '../map/physicalMap';
import { composeProjectMap } from '../map/MapComposer';
import { resolveProjectMap } from '../map/MapLoader';
import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types';
export type UrdfLoadMode = 'mjcf' | 'native';
export type { UrdfBaseMode, UrdfEnhancementOptions };
export interface PhysicsLoadProgress {
value: number;
label: string;
}
export interface PhysicsLoadOptions {
urdfMode?: UrdfLoadMode;
baseMode?: UrdfBaseMode;
enhancements?: UrdfEnhancementOptions;
map?: MapSelection;
onProgress?: (progress: PhysicsLoadProgress) => void;
}
export interface PhysicsAdapter {
load(
manifest: ProjectManifest,
entryPath: string,
urdfMode?: UrdfLoadMode,
baseMode?: UrdfBaseMode,
enhancements?: UrdfEnhancementOptions,
options?: PhysicsLoadOptions,
): Promise<SimulationSnapshot>;
advance(now: number): FrameResult;
snapshot(): SimulationSnapshot | null;
@@ -50,6 +65,8 @@ export interface PhysicsAdapter {
setRLCommand(command: RLCommand): void;
removeRLPolicy(): void;
cachedSupportFiles(): ProjectFile[];
releaseRetired(): void;
rollbackRetired(): void;
exportMjcf(): Uint8Array;
dispose(): void;
}
@@ -58,10 +75,17 @@ let modulePromise: Promise<MainModule> | undefined;
export function getMujocoModule(): Promise<MainModule> {
if (!modulePromise) {
console.info('[MuJoCo] 开始初始化单线程 WASM');
modulePromise = loadMujoco().then((module) => {
console.info('[MuJoCo] WASM 初始化完成');
return module;
});
modulePromise = import('@mujoco/mujoco')
.then(({ default: loadMujoco }) => loadMujoco())
.then((module) => {
console.info('[MuJoCo] WASM 初始化完成');
return module;
})
.catch((error) => {
// 初始化失败后允许用户重试,而不是永久复用已 rejected 的 Promise。
modulePromise = undefined;
throw error;
});
}
return modulePromise;
}
@@ -70,32 +94,51 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
session: SimulationSession | null = null;
workspace: MemfsWorkspace | null = null;
private supportFiles: ProjectFile[] = [];
private loadGeneration = 0;
private disposed = false;
private retiredSession: SimulationSession | null = null;
private retiredWorkspace: MemfsWorkspace | null = null;
private retiredSupportFiles: ProjectFile[] = [];
async load(
manifest: ProjectManifest,
entryPath: string,
urdfMode: UrdfLoadMode = 'mjcf',
baseMode: UrdfBaseMode = 'floating',
enhancements: UrdfEnhancementOptions = {
options: PhysicsLoadOptions = {},
): Promise<SimulationSnapshot> {
if (this.disposed) throw new Error('物理适配器已释放');
const generation = ++this.loadGeneration;
const urdfMode = options.urdfMode ?? 'mjcf';
const baseMode = options.baseMode ?? 'floating';
const enhancements = options.enhancements ?? {
addActuators: false,
addSensors: false,
sensorType: 'camera',
},
): Promise<SimulationSnapshot> {
this.releaseCurrent();
};
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
const report = (value: number, label: string) => {
if (!this.disposed && generation === this.loadGeneration)
options.onProgress?.({ value, label });
};
report(0.08, '初始化 MuJoCo WebAssembly');
const module = await getMujocoModule();
const workspace = new MemfsWorkspace(module, manifest.id);
const prepared = prepareProjectForMujoco(manifest, entryPath);
this.supportFiles = prepared.manifest.files.filter(
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
report(0.32, '校验并准备模型资源');
const workspace = new MemfsWorkspace(module, `${manifest.id}_stage_${generation}`);
const prepared = await prepareProjectForMujoco(manifest, entryPath);
const supportFiles = prepared.manifest.files.filter(
(file) => !manifest.files.some((original) => original.path === file.path),
);
let nextSession: SimulationSession | null = null;
try {
report(0.46, '写入浏览器内存文件系统');
console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length);
workspace.mount(prepared.manifest);
const entry = prepared.manifest.entries.find((candidate) => candidate.path === entryPath);
let modelPath = workspace.path(entryPath);
let modelRelativePath = entryPath,
modelPath = workspace.path(modelRelativePath);
const warnings = [...prepared.warnings];
if (entry?.format === 'urdf' && urdfMode === 'mjcf') {
report(0.58, '转换并增强 URDF 模型');
console.info('[MuJoCo] 编译 URDF 中间模型', entryPath);
const intermediate = new SimulationSession(module, modelPath);
try {
@@ -112,7 +155,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
);
const enhanced = enhanceConvertedMjcf(grounded, enhancements);
workspace.writeGenerated(convertedPath, enhanced.data);
modelPath = workspace.path(convertedPath);
modelRelativePath = convertedPath;
modelPath = workspace.path(modelRelativePath);
warnings.push(
`URDF 已转换为 MJCF(${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`,
);
@@ -136,20 +180,59 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
intermediate.dispose();
}
}
if (mapSelection.kind !== 'none') {
report(0.72, '组合机器人与物理地图');
if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
const slash = modelRelativePath.lastIndexOf('/');
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
if (mapSelection.kind === 'builtin') {
const composed = composePhysicalMap(
new TextEncoder().encode(workspace.readText(modelRelativePath)),
mapSelection.config,
);
workspace.writeGenerated(mapPath, composed.data);
if (composed.summary) warnings.push(composed.summary);
} else {
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
const composed = composeProjectMap(
new TextEncoder().encode(workspace.readText(modelRelativePath)),
mapPath,
prepared.manifest,
resolvedMap,
mapSelection,
);
workspace.writeGenerated(mapPath, composed.data);
warnings.push(...composed.warnings, composed.summary);
}
modelRelativePath = mapPath;
modelPath = workspace.path(modelRelativePath);
}
report(0.84, '编译模型与物理数据');
console.info('[MuJoCo] 编译模型', modelPath);
const session = new SimulationSession(module, modelPath, warnings);
nextSession = new SimulationSession(module, modelPath, warnings);
if (entry?.format === 'urdf' && urdfMode === 'native') {
const offset = session.alignLowestPointToGround();
const offset = nextSession.alignLowestPointToGround();
warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);
}
if (warnings.length) console.info('[MuJoCo] URDF 兼容处理', warnings);
if (warnings.length) console.info('[MuJoCo] 兼容与地图处理', warnings);
console.info('[MuJoCo] 模型编译完成');
report(0.94, '生成初始仿真状态');
const snapshot = nextSession.snapshot();
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
this.releaseRetired();
this.retiredSession = this.session;
this.retiredWorkspace = this.workspace;
this.retiredSupportFiles = this.supportFiles;
this.session = nextSession;
this.workspace = workspace;
this.session = session;
const snapshot = session.snapshot();
this.supportFiles = supportFiles;
nextSession = null;
console.info('[MuJoCo] 状态快照完成');
return snapshot;
} catch (error) {
nextSession?.dispose();
workspace.dispose();
throw new Error(
`模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`,
@@ -225,6 +308,23 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
cachedSupportFiles(): ProjectFile[] {
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
}
releaseRetired(): void {
this.retiredSession?.dispose();
this.retiredSession = null;
this.retiredWorkspace?.dispose();
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
}
rollbackRetired(): void {
this.session?.dispose();
this.workspace?.dispose();
this.session = this.retiredSession;
this.workspace = this.retiredWorkspace;
this.supportFiles = this.retiredSupportFiles;
this.retiredSession = null;
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
}
exportMjcf(): Uint8Array {
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
const relative = '.__platform_export__.xml';
@@ -260,6 +360,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
return output;
}
private releaseCurrent(): void {
this.releaseRetired();
this.session?.dispose();
this.session = null;
this.workspace?.dispose();
@@ -267,6 +368,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
this.supportFiles = [];
}
dispose(): void {
this.disposed = true;
this.loadGeneration += 1;
this.releaseCurrent();
}
}