feat(web-platform): release V0.3
添加驱动器与传感器
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user