Files
Mujoco_WASM/web_platform/src/mobile/SceneComposer.ts
T
chenlin f3a8a38acd
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
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
2026-09-24 09:57:41 +08:00

290 lines
10 KiB
TypeScript

import type { ProjectManifest } from '../project/types';
import { MOBILE_TASK as T, type RobotConfig } from './RobotDescriptor';
const encoder = new TextEncoder();
function parse(data: Uint8Array): Document {
const doc = new DOMParser().parseFromString(new TextDecoder().decode(data), 'application/xml');
if (doc.querySelector('parsererror') || doc.doctype) throw new Error('模型 XML 无效');
return doc;
}
function node(
doc: Document,
tag: string,
attributes: Record<string, string | number> = {},
): Element {
const n = doc.createElement(tag);
for (const [key, value] of Object.entries(attributes)) n.setAttribute(key, String(value));
return n;
}
function named(doc: Document, tag: string, name: string): Element {
const result = Array.from(doc.getElementsByTagName(tag)).filter(
(e) => e.getAttribute('name') === name,
);
if (result.length !== 1) throw new Error(`模型缺少/重复 ${tag}: ${name}`);
return result[0];
}
/** The new bundle has a different arm. Never route it through the old source/FK gate. */
export function prepareBundle(
manifest: ProjectManifest,
entryPath: string,
c: RobotConfig,
): ProjectManifest {
const source = manifest.files.find((f) => f.path === entryPath);
if (!source) throw new Error('缺少 bundle URDF');
const doc = parse(source.data);
for (const name of [...c.baseJoints, ...c.armJoints.map((j) => j.name), c.gripperJoint])
named(doc, 'joint', name);
for (const mesh of doc.querySelectorAll('mesh[filename]')) {
if (mesh.getAttribute('filename')!.includes('4-Omni-Directional-Wheel_Single_Body'))
mesh.replaceWith(node(doc, 'sphere', { radius: 0.05 }));
}
// Exporter duplicated collisions. Keep one representation per visual, not overlapping copies.
for (const link of doc.querySelectorAll('link')) {
const seen = new Set<string>();
for (const collision of link.querySelectorAll(':scope > collision')) {
const mesh = collision.querySelector('mesh');
if (!mesh) continue;
const origin = collision.querySelector('origin');
const transform = [
mesh.getAttribute('scale') ?? '1 1 1',
origin?.getAttribute('xyz') ?? '0 0 0',
origin?.getAttribute('rpy') ?? '0 0 0',
]
.map((text) =>
text
.trim()
.split(/\s+/)
.map((v) => Math.round(Number(v) * 1e9) / 1e9)
.join(' '),
)
.join('|');
const key = `${mesh.getAttribute('filename')}|${transform}`;
if (seen.has(key)) collision.remove();
else seen.add(key);
}
}
const data = encoder.encode(new XMLSerializer().serializeToString(doc));
return {
...manifest,
files: manifest.files.map((f) => (f === source ? { ...f, data, size: data.length } : f)),
};
}
/** Simulation-only ideal omni base; preserves Link1..4 arm frames/inertias/limits.
* Native arm mesh contacts are convex approximations, not the old CAD hull recipe.
*/
function adaptBundle(doc: Document, c: RobotConfig): void {
const root = doc.documentElement,
cad = named(doc, 'body', c.baseBodyName);
for (const name of c.baseJoints) named(doc, 'joint', name).parentElement!.remove();
for (const j of cad.querySelectorAll(':scope > joint, :scope > freejoint')) j.remove();
cad.setAttribute('name', '__mm_bundle_cad');
cad.setAttribute('pos', '0 0 -0.01786');
cad.setAttribute('quat', '0.7071067811865476 0 0 -0.7071067811865476');
const base = node(doc, 'body', { name: c.baseBodyName, pos: '0 0 0.0515' });
base.append(node(doc, 'freejoint', { name: c.baseJointName }));
base.append(node(doc, 'inertial', { pos: '0 0 .04', mass: 2.2, diaginertia: '.014 .014 .022' }));
cad.replaceWith(base);
base.append(cad);
// Keep collision on the arm, simplify chassis to avoid convex CAD covering wheel wells.
const arm = named(doc, 'body', 'base_link');
for (const geom of cad.querySelectorAll('geom')) {
if (!arm.contains(geom)) {
geom.setAttribute('contype', '0');
geom.setAttribute('conaffinity', '0');
} else {
geom.setAttribute('friction', '.8 .005 .0001');
}
}
base.append(
node(doc, 'geom', { type: 'cylinder', size: '.112 .025', pos: '0 0 .04', group: 3, mass: 0 }),
);
root.querySelector('actuator')?.remove();
const actuators = node(doc, 'actuator');
root.append(actuators);
const specs = [
...c.armJoints,
{ name: c.gripperJoint, min: c.gripperClosed, max: c.gripperOpen, mode: 'position' },
];
for (const spec of specs) {
const joint = named(doc, 'joint', spec.name);
joint.setAttribute('limited', 'true');
joint.setAttribute('range', `${spec.min} ${spec.max}`);
joint.setAttribute('damping', '.02');
joint.setAttribute('armature', '.002');
// URDF conversion leaves a joint-level ±1 N·m clamp. It otherwise overrides
// the simulation servos below and lets the loaded arm collapse under gravity.
joint.setAttribute('actuatorfrclimited', 'true');
joint.setAttribute('actuatorfrcrange', spec.name === c.gripperJoint ? '-2 2' : '-8 8');
if (spec.name === c.gripperJoint)
joint.setAttribute(
'axis',
joint
.getAttribute('axis')!
.split(/\s+/)
.map((v) => -Number(v))
.join(' '),
);
actuators.append(
node(doc, 'position', {
name: `${spec.name}_servo`,
joint: spec.name,
kp: spec.name === c.gripperJoint ? 15 : 40,
kv: 2,
ctrllimited: 'true',
ctrlrange: `${spec.min} ${spec.max}`,
forcelimited: 'true',
forcerange: spec.name === c.gripperJoint ? '-2 2' : '-8 8',
}),
);
}
for (let i = 0; i < c.baseJoints.length; i++) {
const name = c.baseJoints[i],
tx = c.baseMix[i][0] * 0.05,
ty = c.baseMix[i][1] * 0.05;
const axle = `${-ty} ${tx} 0`;
const wheel = node(doc, 'body', {
name: `__mm_${name}`,
pos: `${0.125 * ty} ${-0.125 * tx} 0`,
});
wheel.append(
node(doc, 'joint', {
name,
axis: axle,
limited: 'false',
damping: '.0002',
armature: '.00005',
}),
);
wheel.append(
node(doc, 'inertial', { pos: '0 0 0', mass: '.06', diaginertia: '.00004 .00004 .00004' }),
);
wheel.append(
node(doc, 'geom', {
type: 'cylinder',
size: '.033 .012',
zaxis: axle,
contype: 0,
conaffinity: 0,
mass: 0,
group: 1,
rgba: '.15 .18 .2 1',
}),
);
for (let j = 0; j < 12; j++) {
const angle = (2 * Math.PI * j) / 12,
s = Math.sin(angle),
k = Math.cos(angle);
const axis = `${k * tx} ${k * ty} ${-s}`;
const roller = node(doc, 'body', { pos: `${0.041 * s * tx} ${0.041 * s * ty} ${0.041 * k}` });
roller.append(
node(doc, 'joint', { axis, limited: 'false', damping: '.000005', armature: '.0000001' }),
);
roller.append(
node(doc, 'geom', {
type: 'capsule',
size: '.009 .006',
zaxis: axis,
mass: '.003',
contype: 4,
conaffinity: 1,
group: 1,
friction: '1 .001 .0001',
condim: 3,
solref: '.008 1',
rgba: '.3 .3 .3 1',
}),
);
wheel.append(roller);
}
base.append(wheel);
actuators.append(
node(doc, 'velocity', {
name: c.baseActuators[i],
joint: name,
kv: '.5',
ctrllimited: 'true',
ctrlrange: `${-c.wheelLimit} ${c.wheelLimit}`,
forcelimited: 'true',
forcerange: '-2 2',
}),
);
}
}
export function composeMobileScene(data: Uint8Array, config: RobotConfig): Uint8Array {
const doc = parse(data),
root = doc.documentElement;
if (root.tagName !== 'mujoco') throw new Error('场景组合需要已转换的 MJCF');
if (
Array.from(doc.querySelectorAll('[name]')).some((e) =>
e.getAttribute('name')!.startsWith('__mm_'),
)
)
throw new Error('任务命名空间 __mm_ 冲突(请勿重复组合)');
if (config.recipe === 'lekiwi-bundle') adaptBundle(doc, config);
const world = root.querySelector('worldbody');
if (!world) throw new Error('缺少 worldbody');
// A single task floor, no inherited robot-scene floors.
for (const geom of world.querySelectorAll(':scope > geom[type="plane"]')) geom.remove();
world.append(
node(doc, 'geom', {
name: '__mm_floor',
type: 'plane',
group: 2,
size: '3 3 .1',
friction: '.8 .005 .0001',
contype: 1,
conaffinity: 7,
rgba: '.25 .3 .35 1',
}),
);
const object = node(doc, 'body', { name: '__mm_object', pos: T.objectStart.join(' ') });
object.append(node(doc, 'freejoint', { name: '__mm_object_joint' }));
object.append(
node(doc, 'geom', {
name: '__mm_object_geom',
type: 'box',
group: 1,
size: `${T.objectHalfSize} ${T.objectHalfSize} ${T.objectHalfSize}`,
mass: '.04',
friction: '1 .005 .0001',
condim: 4,
contype: 1,
conaffinity: 7,
rgba: '1 .35 .08 1',
}),
);
world.append(object);
const goal = node(doc, 'body', { name: '__mm_goal', mocap: 'true', pos: T.goalStart.join(' ') });
goal.append(
node(doc, 'geom', {
name: '__mm_goal_geom',
type: 'cylinder',
group: 1,
size: '.05 .003',
contype: 0,
conaffinity: 0,
rgba: '.1 1 .35 .45',
}),
);
world.append(goal);
if (config.eefSiteName)
named(doc, 'body', config.eefBodyName).append(
node(doc, 'site', {
name: config.eefSiteName,
pos: config.eefOffset.join(' '),
size: '.007',
rgba: '.2 .8 1 1',
}),
);
const option = root.querySelector('option') ?? node(doc, 'option');
if (!option.parentElement) root.prepend(option);
// Preserve reviewed source physics (the full LeKiwi collision recipe uses 1 ms).
if (!option.hasAttribute('timestep')) option.setAttribute('timestep', '.002');
if (!option.hasAttribute('integrator')) option.setAttribute('integrator', 'implicitfast');
root.querySelector('keyframe')?.remove(); // nq grew: old keys are no longer valid.
return encoder.encode(new XMLSerializer().serializeToString(doc));
}