feat(web-platform): release V0.3

添加驱动器与传感器
This commit is contained in:
2026-08-24 11:47:34 +08:00
parent ba7ee9697c
commit dc494f7a2d
12 changed files with 316 additions and 39 deletions
@@ -1,4 +1,4 @@
import {groundConvertedMjcf} from './urdfToMjcf';
import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf';
const encode=(value:string)=>new TextEncoder().encode(value);
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
@@ -20,3 +20,41 @@ describe('groundConvertedMjcf',()=>{
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
});
});
describe('enhanceConvertedMjcf',()=>{
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint',()=>{
const source='<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
const result=enhanceConvertedMjcf(encode(source),{addActuators:true,addSensors:false,sensorType:'camera'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
expect(result.actuatorCount).toBe(1);
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe('false');
expect(document.querySelector('motor[joint="slider"]')?.hasAttribute('ctrlrange')).toBe(false);
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe('slider_motor');
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe('-100 100');
expect(document.querySelector('joint[name="slider"]')?.getAttribute('stiffness')).toBe('0');
expect(document.querySelector('joint[name="slider"]')?.getAttribute('damping')).toBe('0');
expect(document.querySelectorAll('[joint="shoulder"]')).toHaveLength(1);
expect(document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]')).toHaveLength(1);
expect(document.querySelector('[joint="ball"]')).toBeNull();
});
it('将可调摄像头固连到指定机器人 body',()=>{
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:true,sensorType:'camera',cameraMountBody:'head',cameraPosition:[.2,0,.1],cameraDirection:'+X'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
const camera=document.querySelector('body[name="head"] > camera');
expect(result.cameraAdded).toBe(true);
expect(camera?.getAttribute('mode')).toBe('fixed');
expect(camera?.getAttribute('pos')).toBe('0.2 0 0.1');
expect(camera?.getAttribute('xyaxes')).toBe('0 -1 0 0 0 1');
expect(camera?.getAttribute('resolution')).toBe('640 480');
const repeated=enhanceConvertedMjcf(result.data,{addActuators:false,addSensors:true,sensorType:'camera'});
expect(new DOMParser().parseFromString(decode(repeated.data),'application/xml').querySelectorAll('camera')).toHaveLength(1);
});
it('关闭选项时不修改 actuator 和 camera',()=>{
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:false,sensorType:'camera'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
expect(document.querySelector('actuator')).toBeNull();
expect(document.querySelector('camera')).toBeNull();
});
});
@@ -7,6 +7,53 @@ function numbers(value:string|undefined,count:number):number[]{
}
export type UrdfBaseMode='floating'|'fixed';
export type CameraDirection='+X'|'-X'|'+Y'|'-Y'|'+Z'|'-Z';
export interface UrdfEnhancementOptions {addActuators:boolean;addSensors:boolean;sensorType:'camera';cameraMountBody?:string;cameraPosition?:[number,number,number];cameraDirection?:CameraDirection;}
export interface UrdfEnhancementResult {data:Uint8Array;actuatorCount:number;cameraAdded:boolean;}
function uniqueName(document:Document,selector:string,base:string):string {
const occupied=new Set(Array.from(document.querySelectorAll(selector)).map(element=>element.getAttribute('name')).filter(Boolean));
if(!occupied.has(base))return base;
for(let index=2;;index+=1)if(!occupied.has(`${base}_${index}`))return `${base}_${index}`;
}
/** 为转换后的 MJCF 按需补充可直接控制的关节电机和一台自动取景相机。 */
export function enhanceConvertedMjcf(data:Uint8Array,options:UrdfEnhancementOptions):UrdfEnhancementResult {
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
const root=document.querySelector('mujoco'),worldbody=document.querySelector('mujoco > worldbody');
if(!root||!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
let actuatorCount=0;
if(options.addActuators){
let actuator=Array.from(root.children).find(element=>element.tagName==='actuator');
if(!actuator){actuator=document.createElement('actuator');root.append(actuator);}
const driven=new Set(Array.from(actuator.children).flatMap(element=>[element.getAttribute('joint'),element.getAttribute('jointinparent')]).filter(Boolean));
for(const joint of Array.from(worldbody.querySelectorAll('joint'))){
const type=joint.getAttribute('type')??'hinge';
if(type!=='hinge'&&type!=='slide')continue;
let jointName=joint.getAttribute('name');
if(!jointName){jointName=uniqueName(document,'joint[name]','__platform_joint__');joint.setAttribute('name',jointName);}
if(driven.has(jointName))continue;
const motor=document.createElement('motor');
motor.setAttribute('name',uniqueName(document,'actuator > *[name]',`${jointName}_motor`));
motor.setAttribute('joint',jointName);motor.setAttribute('ctrllimited','false');motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange','-100 100');
if(!joint.hasAttribute('stiffness'))joint.setAttribute('stiffness','0');if(!joint.hasAttribute('damping'))joint.setAttribute('damping','0');
actuator.append(motor);driven.add(jointName);actuatorCount+=1;
}
if(!actuator.children.length)actuator.remove();
}
let cameraAdded=false;
if(options.addSensors&&options.sensorType==='camera'&&!document.querySelector('camera[name^="__platform_camera__"]')){
const bodies=Array.from(worldbody.querySelectorAll('body')),preferred=options.cameraMountBody?bodies.find(body=>body.getAttribute('name')===options.cameraMountBody):undefined;
const mount=preferred??bodies.find(body=>/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name')??''))??bodies.at(-1);
if(mount){
const directions:Record<CameraDirection,string>={'+X':'0 -1 0 0 0 1','-X':'0 1 0 0 0 1','+Y':'1 0 0 0 0 1','-Y':'-1 0 0 0 0 1','+Z':'0 1 0 1 0 0','-Z':'0 -1 0 1 0 0'},position=options.cameraPosition??[.1,0,.05],direction=options.cameraDirection??'+X';
const camera=document.createElement('camera');camera.setAttribute('name',uniqueName(document,'camera[name]','__platform_camera__'));camera.setAttribute('mode','fixed');
camera.setAttribute('pos',position.map(value=>Number.isFinite(value)?value:0).join(' '));camera.setAttribute('xyaxes',directions[direction]);camera.setAttribute('fovy','45');camera.setAttribute('resolution','640 480');mount.append(camera);cameraAdded=true;
}
}
return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded};
}
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {