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; 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; 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; setControllerEnabled(enabled: boolean): void; sendControllerCommand(command: ControllerCommand): void; removeController(): void; loadRLPolicy( model: Uint8Array, path: string, deployment?: PolicyDeployment, ): Promise; setRLPolicyEnabled(enabled: boolean): void; setRLCommand(command: RLCommand): void; setNavigationTarget(target: [number, number]): void; resetNavigationTarget(): void; removeRLPolicy(): void; configureDataRecorder(config: Partial): 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 | undefined; export function getMujocoModule(): Promise { 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(); 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 { 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(); 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 { 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 { 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 { 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): 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(); } }