Files
Mujoco_WASM/web_platform/src/simulation/PhysicsAdapter.ts
T
chenlin 0d986f60bd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
feat: release v1.0.2 LeKiwi 语言控制与网站嵌入
集成服务器托管模型、自然语言移动与有界抓放、内置 LeKiwi URL 导入和双摄像头;同步部署契约与指定域名 iframe 白名单,保留原有物理安全、会话及调用预算防护。

更新 npm 包及锁文件版本、CHANGELOG 与发布文档。提交前 typecheck、120 项定向前端测试和 44 项后端测试通过(3 项可选跳过);真实 v2 云模型抓放仍待单独验收,不包含运行密钥或构建产物。
2026-09-24 15:29:49 +08:00

816 lines
34 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 type { RobotConfig } from '../mobile/RobotDescriptor';
import { composeMobileScene, prepareBundle } from '../mobile/SceneComposer';
import {
composeTrainingMap,
trainingTerrainFromCompiledScene,
type CompiledMapGeometry,
type TrainingSceneCoordinates,
} from '../map/trainingMap';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
import type { MainModule } from '@mujoco/mujoco';
import type { ProjectFile, ProjectManifest } from '../project/types';
import { prepareProjectForMujoco } from '../project/importer';
import { prepareLargeMeshes } from '../project/meshCompatibility';
import { prepareRobotProject } from '../project/robotProfiles';
import { sha256 } from '../robot/registry';
import type { ExternalControlStatus } from '../robot/RobotRuntime';
import {
RobotError,
type RobotDescriptor,
type RobotObservation,
type RobotIdentity,
type RobotActionResult,
} from '../robot/types';
import { enhanceLeKiwiMjcf, type LeKiwiJointGeometry } from '../project/robotProfiles/lekiwi';
import {
enhanceConvertedMjcf,
groundConvertedMjcf,
type UrdfBaseMode,
type UrdfEnhancementOptions,
} from '../project/urdfToMjcf';
import { MemfsWorkspace } from '../project/workspace';
import {
SimulationSession,
type ActuatorParameters,
type FrameResult,
type SimulationSnapshot,
} from './SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types';
import type {
DataRecorderConfig,
DataRecorderStatus,
TelemetryChannel,
} from '../telemetry/DataRecorder';
import { composePhysicalMap } from '../map/physicalMap';
import { composeProjectMap } from '../map/MapComposer';
import { resolveProjectMap } from '../map/MapLoader';
import { composePlacedMapAssets } from '../map/MapStackComposer';
import { DEFAULT_MAP_SELECTION, type MapSelection, type PlacedMapAsset } from '../map/types';
export type UrdfLoadMode = 'mjcf' | 'native';
export type { UrdfBaseMode, UrdfEnhancementOptions };
export interface PhysicsLoadProgress {
value: number;
label: string;
}
export interface PhysicsLoadOptions {
/** Explicit built-in profile; never inferred from actuator count. */
robotProfileId?: string;
/** false: reuse model recipe without installing the external-control runtime. */
configureRobotRuntime?: boolean;
/** Optional caller-owned free VFS slot; bounds native asset-cache path identities. */
workspaceSlot?: string;
/** Trusted application hook, after URDF/map conversion and include expansion. */
sceneComposer?: (mjcf: Uint8Array) => Uint8Array;
mobileRobot?: RobotConfig;
trainingDeployment?: PolicyDeployment;
/** Candidate policy is initialized/validated before replacing the active session. */
trainingPolicy?: { data: Uint8Array; path: string };
urdfMode?: UrdfLoadMode;
baseMode?: UrdfBaseMode;
enhancements?: UrdfEnhancementOptions;
/** @deprecated 单地图兼容入口;新场景应使用 mapAssets。 */
map?: MapSelection;
mapAssets?: readonly PlacedMapAsset[];
onProgress?: (progress: PhysicsLoadProgress) => void;
}
export interface PhysicsAdapter {
load(
manifest: ProjectManifest,
entryPath: string,
options?: PhysicsLoadOptions,
): Promise<SimulationSnapshot>;
advance(now: number): FrameResult;
snapshot(): SimulationSnapshot | null;
setPaused(paused: boolean): void;
setSpeed(speed: number): void;
reset(): void;
singleStep(): void;
describeRobot(): RobotDescriptor | undefined;
robotObservation(): RobotObservation | undefined;
externalControlStatus(): ExternalControlStatus | undefined;
setExternalControlEnabled(enabled: boolean): void;
claimExternalControlLease(leaseId: string): RobotIdentity;
sendRobotAction(action: unknown): Promise<RobotActionResult>;
stopExternalControl(reason?: string): void;
setActuator(id: number, value: number): void;
setActuatorParameters(id: number, parameters: ActuatorParameters): boolean;
setJointPosition(id: number, value: number): boolean;
resetJoints(): void;
setIgnoreJointLimits(ignore: boolean): void;
setExternalForce(bodyId: number, force: [number, number, number]): void;
clearExternalForce(): void;
loadPythonController(source: string, path: string): Promise<ControllerStatus>;
setControllerEnabled(enabled: boolean): void;
sendControllerCommand(command: ControllerCommand): void;
removeController(): void;
loadRLPolicy(
model: Uint8Array,
path: string,
deployment?: PolicyDeployment,
): Promise<RLPolicyStatus>;
setRLPolicyEnabled(enabled: boolean): void;
setRLCommand(command: RLCommand): void;
setNavigationTarget(target: [number, number]): void;
resetNavigationTarget(): void;
removeRLPolicy(): void;
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus | undefined;
startDataRecording(): DataRecorderStatus | undefined;
stopDataRecording(): DataRecorderStatus | undefined;
clearDataRecording(): DataRecorderStatus | undefined;
registerDataChannel(channel: TelemetryChannel): (() => void) | undefined;
exportDataRecording(format: 'csv' | 'json'): Uint8Array;
cachedSupportFiles(): ProjectFile[];
releaseRetired(): void;
rollbackRetired(): void;
exportMjcf(): Uint8Array;
exportTrainingTerrain(
assets: readonly PlacedMapAsset[],
coordinates?: TrainingSceneCoordinates,
): TrainingTerrain;
dispose(): void;
}
let modulePromise: Promise<MainModule> | undefined;
export function getMujocoModule(): Promise<MainModule> {
if (!modulePromise) {
console.info('[MuJoCo] 开始初始化单线程 WASM');
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;
}
export class MainThreadPhysicsAdapter implements PhysicsAdapter {
session: SimulationSession | null = null;
workspace: MemfsWorkspace | null = null;
private supportFiles: ProjectFile[] = [];
private trainingScenes = new WeakMap<
SimulationSession,
{
assets: string;
geometries: CompiledMapGeometry[];
initialPose: number[];
extent: number;
}
>();
private loadGeneration = 0;
private readonly workspacePrefix = `adapter_${crypto.randomUUID().replaceAll('-', '_')}`;
private readonly candidateRoots = new Set<string>();
private disposed = false;
private retiredSession: SimulationSession | null = null;
private retiredWorkspace: MemfsWorkspace | null = null;
private retiredSupportFiles: ProjectFile[] = [];
async load(
manifest: ProjectManifest,
entryPath: string,
options: PhysicsLoadOptions = {},
): Promise<SimulationSnapshot> {
if (this.disposed) throw new Error('物理适配器已释放');
this.session?.stopAgent?.('模型或地图正在重载');
if (this.session?.describeRobot?.())
this.session.stopExternalControl('模型正在重载,请重新授权');
const generation = ++this.loadGeneration;
const urdfMode = options.urdfMode ?? 'mjcf';
const baseMode = options.baseMode ?? 'floating';
const enhancements = options.enhancements ?? {
addActuators: false,
addSensors: false,
sensorType: 'camera',
};
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
const placedMapAssets = options.mapAssets;
const hasMaps = placedMapAssets ? placedMapAssets.length > 0 : mapSelection.kind !== 'none';
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();
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
report(0.32, '校验并准备模型资源');
if (
options.robotProfileId &&
(urdfMode !== 'mjcf' || baseMode !== 'floating' || options.trainingDeployment)
)
throw new Error('机器人 profile 需要浮动基座 URDF 转换,不能与 Go2 部署混用');
const mobileRobot = options.mobileRobot;
if (
mobileRobot &&
(options.trainingDeployment || urdfMode !== 'mjcf' || baseMode !== 'floating')
)
throw new Error('移动操作需要浮动基座 MJCF,不能与 Go2 部署混用');
const sourceManifest =
mobileRobot?.recipe === 'lekiwi-bundle'
? prepareBundle(manifest, entryPath, mobileRobot)
: manifest;
const profileManifest = options.robotProfileId
? await prepareRobotProject(sourceManifest, entryPath, options.robotProfileId)
: sourceManifest;
const normalized = await prepareProjectForMujoco(profileManifest, entryPath);
const prepared = await prepareLargeMeshes(normalized.manifest, entryPath, {
shouldCancel: () => this.disposed || generation !== this.loadGeneration,
onMeshProgress: (path, done, total) =>
report(
0.34 + (0.1 * done) / total,
`兼容高面数网格 ${path}(${done}/${total} 面,保留全部三角形)`,
),
});
prepared.warnings.unshift(...normalized.warnings);
// Map files may not be in the robot's include graph. Prepare the selected
// physics assets before MapComposer rebases them into the resulting scene.
const mapPaths = new Set<string>();
for (const selection of placedMapAssets?.map((asset) => asset.selection) ?? [mapSelection]) {
if (selection.kind === 'project') {
const map = resolveProjectMap(prepared.manifest, selection.descriptorPath);
if (map.physicsPath) mapPaths.add(map.physicsPath);
}
}
for (const path of mapPaths) {
const next = await prepareLargeMeshes(prepared.manifest, path, {
shouldCancel: () => this.disposed || generation !== this.loadGeneration,
});
prepared.manifest = next.manifest;
prepared.warnings.push(...next.warnings);
}
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
const supportFiles = prepared.manifest.files.filter(
(file) => !manifest.files.some((original) => original.path === file.path),
);
// Bounded path identities stop the native mesh cache accumulating a new
// copy on every reload. Three slots permit active + rollback + candidate;
// callers with explicit two-slot viewer transactions retain their contract.
const freeSlot = [0, 1, 2]
.map((i) => `${this.workspacePrefix}_${i}`)
.find((slot) => {
const root = `/workspace/${slot}`;
return (
root !== this.workspace?.root &&
root !== this.retiredWorkspace?.root &&
!this.candidateRoots.has(root)
);
});
if (!options.workspaceSlot && !freeSlot) throw new Error('候选 VFS 槽已满,请等待当前加载结束');
const workspace = new MemfsWorkspace(module, options.workspaceSlot ?? freeSlot!);
if (
workspace.root === this.workspace?.root ||
workspace.root === this.retiredWorkspace?.root ||
this.candidateRoots.has(workspace.root)
)
throw new Error('候选 VFS 槽仍被活动/待释放模型占用');
this.candidateRoots.add(workspace.root);
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 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 {
const minimumZ = intermediate.minimumGeometryZ();
const slash = entryPath.lastIndexOf('/');
const directory = slash >= 0 ? entryPath.slice(0, slash + 1) : '';
const convertedPath = `${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
if (module.mj_saveLastXML(workspace.path(convertedPath), intermediate.model) === 0)
throw new Error('MuJoCo 无法导出中间 MJCF');
const grounded = groundConvertedMjcf(
new TextEncoder().encode(workspace.readText(convertedPath)),
minimumZ,
baseMode,
);
const enhanced = enhanceConvertedMjcf(grounded, enhancements);
let convertedData = enhanced.data;
if (options.robotProfileId) {
const geometry: LeKiwiJointGeometry[] = [];
for (let id = 0; id < intermediate.model.njnt; id++) {
const joint = intermediate.model.jnt(id);
const bodyId = Number(intermediate.model.jnt_bodyid[id]);
const body = intermediate.model.body(bodyId);
try {
let wheelCenter: number[] | undefined;
if (joint.name.startsWith('base_') && joint.name.endsWith('_wheel')) {
for (let geomId = 0; geomId < intermediate.model.ngeom; geomId++) {
const geom = intermediate.model.geom(geomId);
try {
if (
!geom.name.startsWith('4-Omni-Directional-Wheel_Single_Body') ||
Number(intermediate.model.geom_group[geomId]) !== 1
)
continue;
let ancestor = Number(intermediate.model.geom_bodyid[geomId]);
while (ancestor && ancestor !== bodyId)
ancestor = Number(intermediate.model.body_parentid[ancestor]);
if (ancestor === bodyId) {
wheelCenter = Array.from(
intermediate.data.geom_xpos.slice(geomId * 3, geomId * 3 + 3),
);
break;
}
} finally {
geom.delete();
}
}
}
geometry.push({
name: joint.name,
wheelCenter,
body: body.name,
position: Array.from(
intermediate.data.xpos.slice(bodyId * 3, bodyId * 3 + 3),
Number,
),
rotation: Array.from(
intermediate.data.xmat.slice(bodyId * 9, bodyId * 9 + 9),
Number,
),
});
} finally {
joint.delete();
body.delete();
}
}
const inputHash = await sha256(
manifest.files.find((file) => file.path === entryPath)!.data,
);
convertedData = enhanceLeKiwiMjcf(convertedData, geometry, inputHash);
warnings.unshift(
'LeKiwi v1:完整 CAD 轮视觉;碰撞仍使用被动滚子/整臂凸分解与九路伺服,轮距/动力学为仿真估计,不代表实机标定',
);
}
workspace.writeGenerated(convertedPath, convertedData);
modelRelativePath = convertedPath;
modelPath = workspace.path(modelRelativePath);
warnings.push(
`URDF 已转换为 MJCF(${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`,
);
if (enhanced.actuatorCount)
warnings.push(
`已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`,
);
if (enhanced.unitreeGo2wTuned)
warnings.unshift(
'已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数',
);
if (enhanced.imuAdded)
warnings.unshift(
'已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)',
);
if (enhanced.cameraAdded)
warnings.push(
enhancements.cameras
? `已添加 ${enhanced.cameraCount} 路 640×480 固定摄像头:${enhancements.cameras.map((camera) => `${camera.name} → ${camera.cameraMountBody}`).join(';')}`
: `已将 640×480 摄像头固连到 ${enhancements.cameraMountBody || '自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition ?? [0.1, 0, 0.05]).join(' ')} m,朝向 ${enhancements.cameraQuaternion ? `四元数 wxyz ${enhancements.cameraQuaternion.join(' ')}` : (enhancements.cameraDirection ?? '+X')}`,
);
} finally {
intermediate.dispose();
}
}
if (hasMaps && !options.trainingDeployment?.terrain) {
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`;
const source = new TextEncoder().encode(workspace.readText(modelRelativePath));
if (placedMapAssets) {
const composed = composePlacedMapAssets(
source,
mapPath,
prepared.manifest,
placedMapAssets,
);
workspace.writeGenerated(mapPath, composed.data);
warnings.push(...composed.warnings);
} else if (mapSelection.kind === 'builtin') {
const composed = composePhysicalMap(source, mapSelection.config);
workspace.writeGenerated(mapPath, composed.data);
if (composed.summary) warnings.push(composed.summary);
} else if (mapSelection.kind === 'project') {
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
const composed = composeProjectMap(
source,
mapPath,
prepared.manifest,
resolvedMap,
mapSelection,
);
workspace.writeGenerated(mapPath, composed.data);
warnings.push(...composed.warnings, composed.summary);
}
modelRelativePath = mapPath;
modelPath = workspace.path(modelRelativePath);
}
if (options.trainingDeployment?.terrain) {
if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('训练地图需要URDF转换模式');
const intermediate = new SimulationSession(module, modelPath);
try {
const flatPath = `${modelRelativePath}.training.xml`;
if (!module.mj_saveLastXML(workspace.path(flatPath), intermediate.model))
throw new Error('无法展开训练场景');
workspace.writeGenerated(
flatPath,
composeTrainingMap(
new TextEncoder().encode(workspace.readText(flatPath)),
options.trainingDeployment,
),
);
modelPath = workspace.path(flatPath);
} finally {
intermediate.dispose();
}
warnings.push('已用策略配套训练布局替换场景地形;编辑器地图未更改。Go2-W动力学不等同Go2。');
}
const sceneComposer = mobileRobot
? (xml: Uint8Array) => {
const mobileScene = composeMobileScene(xml, mobileRobot);
return options.sceneComposer ? options.sceneComposer(mobileScene) : mobileScene;
}
: options.sceneComposer;
if (sceneComposer) {
if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('任务场景组合需要 MJCF 转换模式');
const intermediate = new SimulationSession(module, modelPath);
try {
const scenePath = `${modelRelativePath}.task.xml`;
// Register before native save so failed composition is cleaned up too.
workspace.writeGenerated(scenePath, new Uint8Array());
if (!module.mj_saveLastXML(workspace.path(scenePath), intermediate.model))
throw new Error('无法展开任务场景');
workspace.writeGenerated(
scenePath,
sceneComposer(new TextEncoder().encode(workspace.readText(scenePath))),
);
modelRelativePath = scenePath;
modelPath = workspace.path(scenePath);
} finally {
intermediate.dispose();
}
}
report(0.84, '编译模型与物理数据');
console.info('[MuJoCo] 编译模型', modelPath);
nextSession = new SimulationSession(module, modelPath, warnings);
if (options.robotProfileId && options.configureRobotRuntime !== false && !mobileRobot) {
const fingerprint = await sha256(
new TextEncoder().encode(workspace.readText(modelRelativePath)),
);
nextSession.configureRobot(options.robotProfileId, fingerprint);
}
if (options.trainingDeployment) nextSession.configureDeployment(options.trainingDeployment);
if (entry?.format === 'urdf' && urdfMode === 'native') {
const offset = nextSession.alignLowestPointToGround();
warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);
}
if (placedMapAssets?.length && !options.trainingDeployment) {
const sanitize = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, '_');
const prefixes = placedMapAssets.map((asset) =>
asset.selection.kind === 'builtin'
? `__platform_map_${sanitize(asset.id)}__`
: `__platform_map_${sanitize(`${resolveProjectMap(prepared.manifest, asset.selection.descriptorPath).definition.id}_${asset.id}`)}_`,
);
const model = nextSession.model,
data = nextSession.data;
const geometries: CompiledMapGeometry[] = [];
for (let id = 0; id < model.ngeom; id++) {
const geom = model.geom(id);
try {
const name = geom.name;
geometries.push({
name,
type: Number(model.geom_type[id]),
position: Array.from(data.geom_xpos.slice(id * 3, id * 3 + 3)),
rotation: Array.from(data.geom_xmat.slice(id * 9, id * 9 + 9)),
size: Array.from(model.geom_size.slice(id * 3, id * 3 + 3)),
friction: Array.from(model.geom_friction.slice(id * 3, id * 3 + 3)),
collision: Boolean(model.geom_contype[id] || model.geom_conaffinity[id]),
static: Number(model.body_weldid[Number(model.geom_bodyid[id])]) === 0,
map:
prefixes.some((prefix) => name.startsWith(prefix)) ||
name === '__platform_map_ground__',
});
} finally {
geom.delete();
}
}
const roots = Array.from({ length: model.njnt }, (_, id) => id).filter(
(id) => Number(model.jnt_type[id]) === 0,
);
if (roots.length === 1) {
const address = Number(model.jnt_qposadr[roots[0]]);
this.trainingScenes.set(nextSession, {
assets: JSON.stringify(placedMapAssets),
geometries,
initialPose: Array.from(data.qpos.slice(address, address + 7)),
extent: Math.max(
4,
...placedMapAssets.map((asset) =>
asset.selection.kind === 'builtin'
? Math.max(
Math.abs(asset.selection.config.positionX),
Math.abs(asset.selection.config.positionY),
) +
asset.selection.config.size / 2
: 0,
),
),
});
}
}
if (mobileRobot) nextSession.configureMobile(mobileRobot);
if (options.trainingPolicy) {
if (!options.trainingDeployment?.terrain) throw new Error('事务策略加载需要配套训练地图');
report(0.9, '校验候选场景的 ONNX 策略');
await nextSession.loadRLPolicy(
options.trainingPolicy.data,
options.trainingPolicy.path,
options.trainingDeployment,
);
// Configure/reset already supplied the correct spawn. Enable while still paused;
// the candidate cannot advance until the viewer transaction commits.
nextSession.setRLPolicyEnabled(true);
}
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.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)}`,
{ cause: error },
);
} finally {
this.candidateRoots.delete(workspace.root);
this.session?.mobile?.env.refreshViews();
this.retiredSession?.mobile?.env.refreshViews();
}
}
exportTrainingTerrain(
assets: readonly PlacedMapAsset[],
coordinates?: TrainingSceneCoordinates,
): TrainingTerrain {
const scene = this.session && this.trainingScenes.get(this.session);
if (!scene || scene.assets !== JSON.stringify(assets))
throw new Error('没有匹配的已应用碰撞场景/唯一浮动机器人,或场景已过时;请先应用地图');
return trainingTerrainFromCompiledScene(
scene.geometries,
scene.initialPose,
scene.extent,
coordinates,
);
}
advance(now: number): FrameResult {
return this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
}
snapshot(): SimulationSnapshot | null {
return this.session?.snapshot() ?? null;
}
setPaused(value: boolean): void {
this.session?.setPaused(value);
}
setSpeed(value: number): void {
this.session?.setSpeed(value);
}
reset(): void {
this.session?.reset();
}
singleStep(): void {
this.session?.singleStep();
}
describeRobot(): RobotDescriptor | undefined {
return this.session?.describeRobot();
}
externalControlStatus(): ExternalControlStatus | undefined {
return this.session?.externalControlStatus();
}
robotObservation(): RobotObservation | undefined {
return this.session?.robotObservation();
}
setExternalControlEnabled(enabled: boolean): void {
this.session?.setExternalControlEnabled(enabled);
}
claimExternalControlLease(leaseId: string): RobotIdentity {
if (!this.session) throw new RobotError('DISCONNECTED', '没有仿真会话');
return this.session.claimExternalControlLease(leaseId);
}
sendRobotAction(action: unknown): Promise<RobotActionResult> {
if (!this.session) return Promise.reject(new RobotError('DISCONNECTED', '没有仿真会话'));
return this.session.sendRobotAction(action);
}
stopExternalControl(reason?: string): void {
this.session?.stopExternalControl(reason);
}
setActuator(id: number, value: number): void {
this.session?.setActuator(id, value);
}
setActuatorParameters(id: number, parameters: ActuatorParameters): boolean {
return this.session?.setActuatorParameters(id, parameters) ?? false;
}
setJointPosition(id: number, value: number): boolean {
return this.session?.setJointPosition(id, value) ?? false;
}
resetJoints(): void {
this.session?.resetJoints();
}
setIgnoreJointLimits(ignore: boolean): void {
this.session?.setIgnoreJointLimits(ignore);
}
setExternalForce(bodyId: number, force: [number, number, number]): void {
this.session?.setExternalForce(bodyId, force);
}
clearExternalForce(): void {
this.session?.clearExternalForce();
}
async loadPythonController(source: string, path: string): Promise<ControllerStatus> {
if (!this.session) throw new Error('请先加载模型');
return this.session.loadPythonController(source, path);
}
setControllerEnabled(enabled: boolean): void {
this.session?.setControllerEnabled(enabled);
}
sendControllerCommand(command: ControllerCommand): void {
this.session?.sendControllerCommand(command);
}
removeController(): void {
this.session?.removeController();
}
async loadRLPolicy(
model: Uint8Array,
path: string,
deployment?: PolicyDeployment,
): Promise<RLPolicyStatus> {
if (!this.session) throw new Error('请先加载模型');
return this.session.loadRLPolicy(model, path, deployment);
}
setRLPolicyEnabled(enabled: boolean): void {
this.session?.setRLPolicyEnabled(enabled);
}
setNavigationTarget(target: [number, number]): void {
this.session?.setNavigationTarget(target);
}
resetNavigationTarget(): void {
this.session?.resetNavigationTarget();
}
setRLCommand(command: RLCommand): void {
this.session?.setRLCommand(command);
}
removeRLPolicy(): void {
this.session?.removeRLPolicy();
}
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus | undefined {
return this.session?.configureDataRecorder(config);
}
startDataRecording(): DataRecorderStatus | undefined {
return this.session?.startDataRecording();
}
stopDataRecording(): DataRecorderStatus | undefined {
return this.session?.stopDataRecording();
}
clearDataRecording(): DataRecorderStatus | undefined {
return this.session?.clearDataRecording();
}
registerDataChannel(channel: TelemetryChannel): (() => void) | undefined {
return this.session?.registerDataChannel(channel);
}
exportDataRecording(format: 'csv' | 'json'): Uint8Array {
if (!this.session) throw new Error('请先加载模型');
return this.session.exportDataRecording(format);
}
cachedSupportFiles(): ProjectFile[] {
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
}
releaseRetired(): void {
const retiredSession = this.retiredSession;
const retiredWorkspace = this.retiredWorkspace;
this.retiredSession = null;
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
try {
retiredSession?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放旧仿真会话失败', error);
}
try {
retiredWorkspace?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放旧内存工作区失败', error);
}
}
rollbackRetired(): void {
const failedSession = this.session;
const failedWorkspace = this.workspace;
this.session = this.retiredSession;
this.workspace = this.retiredWorkspace;
this.supportFiles = this.retiredSupportFiles;
this.retiredSession = null;
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
try {
failedSession?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放失败的候选仿真会话失败', error);
}
try {
failedWorkspace?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放失败的候选内存工作区失败', error);
}
}
exportMjcf(): Uint8Array {
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
const relative = '.__platform_export__.xml';
if (this.session.module.mj_saveLastXML(this.workspace.path(relative), this.session.model) === 0)
throw new Error('MuJoCo 无法生成 MJCF');
const source = this.workspace.readText(relative),
document = new DOMParser().parseFromString(source, 'application/xml'),
actuatorSection = document.querySelector('mujoco > actuator');
if (document.querySelector('parsererror')) return new TextEncoder().encode(source);
const snapshot = this.session.snapshot();
if (snapshot.robot) {
const custom = document.querySelector('mujoco > custom') ?? document.createElement('custom');
if (!custom.parentNode) document.documentElement.append(custom);
for (const [name, value] of [
['platform_robot_profile', snapshot.robot.profileId],
['platform_robot_profile_version', String(snapshot.robot.profileVersion)],
// Provenance of the loaded source, not a self-referential export hash.
['platform_robot_source_fingerprint', snapshot.robot.modelFingerprint],
]) {
const marker =
custom.querySelector(`text[name="${name}"]`) ?? document.createElement('text');
marker.setAttribute('name', name);
marker.setAttribute('data', value);
custom.append(marker);
}
}
if (actuatorSection) {
for (const info of snapshot.actuators) {
const element = Array.from(actuatorSection.children).find(
(candidate) => candidate.getAttribute('name') === info.name,
);
if (!element) continue;
element.setAttribute('ctrllimited', info.ctrlLimited ? 'true' : 'false');
element.setAttribute('forcelimited', info.forceLimited ? 'true' : 'false');
}
}
for (const info of snapshot.actuators) {
if (info.kind !== 'motor' || !info.jointName) continue;
const joint = Array.from(document.querySelectorAll('worldbody joint[name]')).find(
(candidate) => candidate.getAttribute('name') === info.jointName,
);
if (joint) {
joint.setAttribute('stiffness', String(info.kp));
joint.setAttribute('damping', String(info.kv));
}
}
const output = new TextEncoder().encode(new XMLSerializer().serializeToString(document));
this.workspace.writeGenerated(relative, output);
return output;
}
private releaseCurrent(): void {
this.releaseRetired();
this.session?.dispose();
this.session = null;
this.workspace?.dispose();
this.workspace = null;
this.supportFiles = [];
}
dispose(): void {
this.disposed = true;
this.loadGeneration += 1;
this.releaseCurrent();
}
}