feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { Ray, Triangle, Vector3 } from 'three';
|
||||
|
||||
export type Surface = { vertices: Vector3[]; faces: number[][] };
|
||||
|
||||
/** Independent, unsigned triangle-surface distance oracle for the small E2E tip hulls.
|
||||
* Not a runtime collision detector. Includes vertex/face, edge/edge and edge/face cases.
|
||||
*/
|
||||
export function surfaceDistance(a: Surface, b: Surface) {
|
||||
let distanceSq = Infinity;
|
||||
let from = new Vector3(),
|
||||
to = new Vector3();
|
||||
const candidate = new Vector3(),
|
||||
rayPoint = new Vector3(),
|
||||
segmentPoint = new Vector3();
|
||||
const consider = (p: Vector3, q: Vector3, reversed = false) => {
|
||||
const distance = p.distanceToSquared(q);
|
||||
if (distance < distanceSq) {
|
||||
distanceSq = distance;
|
||||
from = (reversed ? q : p).clone();
|
||||
to = (reversed ? p : q).clone();
|
||||
}
|
||||
};
|
||||
const triangles = (surface: Surface) =>
|
||||
surface.faces.map(
|
||||
([i, j, k]) => new Triangle(surface.vertices[i], surface.vertices[j], surface.vertices[k]),
|
||||
);
|
||||
const edges = (surface: Surface) => {
|
||||
const pairs = new Map<string, [Vector3, Vector3]>();
|
||||
for (const f of surface.faces)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = f[i],
|
||||
q = f[(i + 1) % 3];
|
||||
pairs.set([p, q].sort((x, y) => x - y).join(','), [
|
||||
surface.vertices[p],
|
||||
surface.vertices[q],
|
||||
]);
|
||||
}
|
||||
return [...pairs.values()];
|
||||
};
|
||||
const ta = triangles(a),
|
||||
tb = triangles(b),
|
||||
ea = edges(a),
|
||||
eb = edges(b);
|
||||
for (const v of a.vertices) for (const t of tb) consider(v, t.closestPointToPoint(v, candidate));
|
||||
for (const v of b.vertices)
|
||||
for (const t of ta) consider(v, t.closestPointToPoint(v, candidate), true);
|
||||
for (const [p, q] of ea) {
|
||||
const length = p.distanceTo(q);
|
||||
if (length === 0) continue;
|
||||
const ray = new Ray(p, q.clone().sub(p).divideScalar(length));
|
||||
for (const [v, w] of eb) {
|
||||
ray.distanceSqToSegment(v, w, rayPoint, segmentPoint);
|
||||
if (rayPoint.distanceTo(p) <= length) consider(rayPoint, segmentPoint);
|
||||
}
|
||||
for (const t of tb) {
|
||||
const point = ray.intersectTriangle(t.a, t.b, t.c, false, candidate);
|
||||
if (point && point.distanceTo(p) <= length) consider(point, point);
|
||||
}
|
||||
}
|
||||
for (const [p, q] of eb) {
|
||||
const length = p.distanceTo(q);
|
||||
if (length === 0) continue;
|
||||
const ray = new Ray(p, q.clone().sub(p).divideScalar(length));
|
||||
for (const t of ta) {
|
||||
const point = ray.intersectTriangle(t.a, t.b, t.c, false, candidate);
|
||||
if (point && point.distanceTo(p) <= length) consider(point, point);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(distanceSq)) throw new Error('表面距离查询缺少有效三角形');
|
||||
return { distance: Math.sqrt(distanceSq), from: from.toArray(), to: to.toArray() };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<base href="/" />
|
||||
<meta charset="utf-8" /><title>LeKiwi WASM physics fixture (development only)</title>
|
||||
<body>
|
||||
<p>真实 WASM 动力学测试,无生产入口。</p>
|
||||
<script type="module" src="/physics/runner.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,822 @@
|
||||
import { MainThreadPhysicsAdapter } from '../src/simulation/PhysicsAdapter';
|
||||
import { SimulationSession } from '../src/simulation/SimulationSession';
|
||||
import { ModelBindings } from '../src/simulation/ModelBindings';
|
||||
import { LEKIWI_PROFILE as p, bodyToWheels } from '../src/robot/profiles/lekiwi';
|
||||
import type { ProjectManifest } from '../src/project/types';
|
||||
import { ExternalControlClient } from '../src/robot/ExternalControlClient';
|
||||
import { validateDescriptor, validateValues } from '../src/robot/validation';
|
||||
import { sha256 } from '../src/robot/registry';
|
||||
import single from '../../contracts/fixtures/single-joint.json';
|
||||
import { Quaternion, Vector3 } from 'three';
|
||||
import { surfaceDistance, type Surface } from './meshDistance';
|
||||
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
let bindings: ModelBindings;
|
||||
let currentManifest: ProjectManifest;
|
||||
let frame: number | undefined;
|
||||
let bridge: ExternalControlClient | undefined;
|
||||
function session() {
|
||||
if (!adapter.session) throw new Error('not initialized');
|
||||
return adapter.session;
|
||||
}
|
||||
function read() {
|
||||
const s = session(),
|
||||
id = bindings.resolve(bindings.bodies, p.baseBody);
|
||||
const quat = Array.from(s.data.xquat.slice(id * 4, id * 4 + 4), Number);
|
||||
const [w, x, y, z] = quat;
|
||||
return {
|
||||
time: Number(s.data.time),
|
||||
position: Array.from(s.data.xpos.slice(id * 3, id * 3 + 3), Number),
|
||||
quaternion: quat,
|
||||
roll: Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
|
||||
pitch: Math.asin(Math.max(-1, Math.min(1, 2 * (w * y - z * x)))),
|
||||
yaw: Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
|
||||
arm: p.arm.map((j) => Number(s.data.qpos[bindings.scalarJoint(j.joint).qposAddress])),
|
||||
wheel: p.wheels.map((j) => Number(s.data.qvel[bindings.scalarJoint(j).qvelAddress])),
|
||||
finite: Array.from(s.data.qpos, Number).every(Number.isFinite),
|
||||
contacts: Number(s.data.ncon),
|
||||
nu: s.model.nu,
|
||||
njnt: s.model.njnt,
|
||||
};
|
||||
}
|
||||
function contactPairs() {
|
||||
const s = session();
|
||||
const result: {
|
||||
geom1: string;
|
||||
geom2: string;
|
||||
body1: number;
|
||||
body2: number;
|
||||
distance: number;
|
||||
position: number[];
|
||||
}[] = [];
|
||||
for (let i = 0; i < s.data.ncon; i++) {
|
||||
const contact = s.data.contact.get(i)!;
|
||||
if (
|
||||
contact.geom1 < 0 ||
|
||||
contact.geom2 < 0 ||
|
||||
contact.geom1 >= s.model.ngeom ||
|
||||
contact.geom2 >= s.model.ngeom
|
||||
) {
|
||||
const message = `非法接触几何 id: ${contact.geom1}/${contact.geom2}; ${i}/${s.data.ncon}`;
|
||||
contact.delete();
|
||||
throw new Error(message);
|
||||
}
|
||||
const a = s.model.geom(contact.geom1),
|
||||
b = s.model.geom(contact.geom2);
|
||||
try {
|
||||
result.push({
|
||||
geom1: a.name,
|
||||
geom2: b.name,
|
||||
body1: Number(s.model.geom_bodyid[contact.geom1]),
|
||||
body2: Number(s.model.geom_bodyid[contact.geom2]),
|
||||
distance: contact.dist,
|
||||
position: Array.from(contact.pos, Number),
|
||||
});
|
||||
} finally {
|
||||
a.delete();
|
||||
b.delete();
|
||||
contact.delete();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function step(seconds: number) {
|
||||
const s = session(),
|
||||
steps = Math.round(seconds / Number(s.model.opt.timestep));
|
||||
let maxContacts = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
s.singleStep();
|
||||
maxContacts = Math.max(maxContacts, Number(s.data.ncon));
|
||||
}
|
||||
return { ...read(), maxContacts };
|
||||
}
|
||||
const harness = {
|
||||
async bootSingle() {
|
||||
const data = new TextEncoder().encode(
|
||||
'<mujoco><option gravity="0 0 0" timestep=".002"/><worldbody><body pos="0 0 1"><joint name="slider" type="slide" axis="1 0 0" range="-1 1"/><geom type="sphere" size=".1" mass="1"/></body></worldbody><actuator><position name="servo" joint="slider" kp="40" kv="15" ctrlrange="-1 1"/></actuator></mujoco>',
|
||||
);
|
||||
const manifest: ProjectManifest = {
|
||||
id: 'single',
|
||||
name: 'single',
|
||||
entries: [{ path: 'single.xml', format: 'mjcf', label: 'single' }],
|
||||
maps: [],
|
||||
files: [{ path: 'single.xml', data, size: data.length, mimeType: '', source: 'file' }],
|
||||
totalBytes: data.length,
|
||||
};
|
||||
await adapter.load(manifest, 'single.xml', {
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'fixed',
|
||||
map: { kind: 'none' },
|
||||
});
|
||||
const s = session(),
|
||||
b = new ModelBindings(s.model),
|
||||
j = b.scalarActuator('servo', 'slider', 'position');
|
||||
const descriptor = validateDescriptor({
|
||||
...single.descriptor,
|
||||
modelFingerprint: await sha256(data),
|
||||
});
|
||||
const values = (v: unknown) => validateValues(v, descriptor.actionChannels, true);
|
||||
s.configureRobotAdapter({
|
||||
describe: () => descriptor,
|
||||
validateAction: values,
|
||||
applyAction(v) {
|
||||
const accepted = values(v);
|
||||
s.data.ctrl[j.controlAddress] = accepted['slider.position'];
|
||||
return accepted;
|
||||
},
|
||||
readObservation: () => ({ 'slider.position': Number(s.data.qpos[j.qposAddress]) }),
|
||||
safeStop() {
|
||||
s.data.ctrl[j.controlAddress] = Math.max(
|
||||
-1,
|
||||
Math.min(1, Number(s.data.qpos[j.qposAddress])),
|
||||
);
|
||||
},
|
||||
reset() {
|
||||
s.data.qpos[j.qposAddress] = 0;
|
||||
s.data.ctrl[j.controlAddress] = 0;
|
||||
},
|
||||
dispose() {},
|
||||
});
|
||||
return descriptor;
|
||||
},
|
||||
async connectBridge(endpoint: string, token: string) {
|
||||
bridge?.disconnect();
|
||||
bridge = new ExternalControlClient(() => adapter);
|
||||
session().setPaused(false);
|
||||
session().setExternalControlEnabled(true);
|
||||
const tick = (now: number) => {
|
||||
adapter.advance(now);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick);
|
||||
await bridge.connect(endpoint, token);
|
||||
return bridge.status();
|
||||
},
|
||||
externalState() {
|
||||
return {
|
||||
control: session().externalControlStatus(),
|
||||
observation: session().robotObservation(),
|
||||
bridge: bridge?.status(),
|
||||
};
|
||||
},
|
||||
authorize() {
|
||||
session().setPaused(false);
|
||||
session().setExternalControlEnabled(true);
|
||||
bridge?.sync();
|
||||
},
|
||||
pause() {
|
||||
session().setPaused(true);
|
||||
bridge?.sync();
|
||||
},
|
||||
async boot(assetBase: string) {
|
||||
const response = await fetch(`${assetBase}/source-manifest.json`);
|
||||
if (!response.ok) throw new Error('Run examples/lekiwi/prepare_assets.py first');
|
||||
const source = (await response.json()) as { files: Record<string, string> };
|
||||
const files = await Promise.all(
|
||||
Object.keys(source.files)
|
||||
.filter((f) => f.startsWith('URDF/'))
|
||||
.map(async (path) => {
|
||||
const response = await fetch(`${assetBase}/${path}`);
|
||||
if (!response.ok) throw new Error(`Missing ${path}`);
|
||||
const data = new Uint8Array(await response.arrayBuffer());
|
||||
return { path, data, size: data.byteLength, source: 'file' as const, mimeType: '' };
|
||||
}),
|
||||
);
|
||||
const manifest: ProjectManifest = {
|
||||
id: 'lekiwi-physics',
|
||||
name: 'LeKiwi physics',
|
||||
files,
|
||||
entries: [{ path: 'URDF/LeKiwi.urdf', format: 'urdf', label: 'LeKiwi' }],
|
||||
maps: [],
|
||||
totalBytes: files.reduce((s, f) => s + f.size, 0),
|
||||
};
|
||||
currentManifest = manifest;
|
||||
await adapter.load(manifest, 'URDF/LeKiwi.urdf', {
|
||||
robotProfileId: p.id,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
enhancements: { addActuators: false, addSensors: false, sensorType: 'camera' },
|
||||
});
|
||||
bindings = new ModelBindings(session().model);
|
||||
return { version: session().module.mj_versionString(), ...read() };
|
||||
},
|
||||
reset() {
|
||||
session().reset();
|
||||
return step(1);
|
||||
},
|
||||
step,
|
||||
read,
|
||||
contactPairs,
|
||||
adjacentArmChecks() {
|
||||
const s = session(),
|
||||
joint = bindings.scalarJoint('arm_elbow_flex');
|
||||
const upper = bindings.resolve(bindings.bodies, 'SO_ARM100_08k_116_Square-v1');
|
||||
const forearm = bindings.resolve(bindings.bodies, 'SO_ARM100_08k_Mirror-v1');
|
||||
const touching = () =>
|
||||
contactPairs().filter(
|
||||
(c) =>
|
||||
(c.body1 === upper && c.body2 === forearm) || (c.body2 === upper && c.body1 === forearm),
|
||||
);
|
||||
s.reset();
|
||||
const before = Array.from(s.data.qpos, Number);
|
||||
const teleportAccepted = s.setJointPosition(joint.id, -1.3);
|
||||
let jointResetError = '';
|
||||
try {
|
||||
s.resetJoints();
|
||||
} catch (error) {
|
||||
jointResetError = String(error);
|
||||
}
|
||||
const after = Array.from(s.data.qpos, Number);
|
||||
const editable = s.snapshot().joints.find((j) => j.id === joint.id)!.editable;
|
||||
// Bearing/structural hull partitioning must leave these pan poses reachable.
|
||||
const samples = [0, 0.8, -0.8].map((pan) => {
|
||||
s.reset();
|
||||
harness.arm([pan, 0, 0, 0, 0, 0], 1.5);
|
||||
const neutral = {
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
panActual: read().arm[0],
|
||||
contacts: touching(),
|
||||
};
|
||||
s.setActuator(bindings.resolve(bindings.actuators, 'arm_elbow_flex_servo'), -1.3);
|
||||
let maxPenetration = 0,
|
||||
maxContacts = 0;
|
||||
for (let i = 0; i < Math.ceil(3 / Number(s.model.opt.timestep)); i++) {
|
||||
s.singleStep();
|
||||
const contacts = touching();
|
||||
maxContacts = Math.max(maxContacts, contacts.length);
|
||||
for (const c of contacts) maxPenetration = Math.max(maxPenetration, -c.distance);
|
||||
}
|
||||
const contacts = touching();
|
||||
const anchor = Array.from(s.data.xanchor.slice(joint.id * 3, joint.id * 3 + 3), Number);
|
||||
const axis = Array.from(s.data.xaxis.slice(joint.id * 3, joint.id * 3 + 3), Number);
|
||||
const radii = contacts.map((c) => {
|
||||
const offset = c.position.map((x, i) => x - anchor[i]);
|
||||
const axial = offset.reduce((sum, x, i) => sum + x * axis[i], 0);
|
||||
return Math.hypot(...offset.map((x, i) => x - axial * axis[i]));
|
||||
});
|
||||
const blocked = {
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
target: -1.3,
|
||||
contacts,
|
||||
maxPenetration,
|
||||
maxContacts,
|
||||
minContactRadius: Math.min(...radii),
|
||||
finite: read().finite,
|
||||
};
|
||||
harness.arm([pan, 0, 0.3, 0, 0, 0], 2);
|
||||
return {
|
||||
pan,
|
||||
neutral,
|
||||
blocked,
|
||||
released: { actual: Number(s.data.qpos[joint.qposAddress]), contacts: touching() },
|
||||
};
|
||||
});
|
||||
s.reset();
|
||||
return { teleportAccepted, jointResetError, editable, before, after, samples };
|
||||
},
|
||||
jointSweepChecks() {
|
||||
const s = session(),
|
||||
dt = Number(s.model.opt.timestep);
|
||||
const results = [];
|
||||
for (const spec of p.arm)
|
||||
for (const target of [spec.min * 0.6, spec.max * 0.6]) {
|
||||
s.reset();
|
||||
step(0.5);
|
||||
const motor = bindings.resolve(bindings.actuators, `${spec.joint}_servo`);
|
||||
const joint = bindings.scalarJoint(spec.joint);
|
||||
const initial = Number(s.data.qpos[joint.qposAddress]);
|
||||
const duration = Math.abs(target - initial) / 0.4; // bounded 0.4 rad/s target ramp
|
||||
let maxPenetration = 0,
|
||||
maxContacts = 0;
|
||||
const start = Number(s.data.time),
|
||||
steps = Math.ceil((duration + 0.75) / dt);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
if (i % 10 === 0)
|
||||
s.setActuator(motor, initial + (target - initial) * Math.min(1, (i * dt) / duration));
|
||||
s.singleStep();
|
||||
const contacts = contactPairs().filter(
|
||||
(c) => c.geom1.startsWith('__lekiwi_cad_') && c.geom2.startsWith('__lekiwi_cad_'),
|
||||
);
|
||||
maxContacts = Math.max(maxContacts, contacts.length);
|
||||
for (const c of contacts) maxPenetration = Math.max(maxPenetration, -c.distance);
|
||||
}
|
||||
results.push({
|
||||
joint: spec.joint,
|
||||
target,
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
maxPenetration,
|
||||
maxContacts,
|
||||
finite: read().finite,
|
||||
elapsed: Number(s.data.time) - start,
|
||||
expectedTime: steps * dt,
|
||||
contacts: contactPairs().filter(
|
||||
(c) => c.geom1.startsWith('__lekiwi_cad_') && c.geom2.startsWith('__lekiwi_cad_'),
|
||||
),
|
||||
});
|
||||
}
|
||||
s.reset();
|
||||
return results;
|
||||
},
|
||||
visualCollisionCoverage() {
|
||||
const xml = new DOMParser().parseFromString(harness.xml(), 'application/xml');
|
||||
const arm = xml.querySelector('body[name="Base_08q-v1"]')!;
|
||||
const original = new DOMParser().parseFromString(
|
||||
new TextDecoder().decode(
|
||||
currentManifest.files.find((f) => f.path === 'URDF/LeKiwi.urdf')!.data,
|
||||
),
|
||||
'application/xml',
|
||||
);
|
||||
const visuals = Array.from(original.querySelectorAll('link > visual')).filter((v) =>
|
||||
arm.querySelector(`geom[name="${v.getAttribute('name')}"]`),
|
||||
);
|
||||
const probeBody = xml.createElement('body');
|
||||
probeBody.setAttribute('name', '__coverage_probe_body');
|
||||
probeBody.setAttribute('mocap', 'true');
|
||||
probeBody.setAttribute('pos', '10 10 10');
|
||||
const geom = xml.createElement('geom');
|
||||
geom.setAttribute('name', '__coverage_probe');
|
||||
geom.setAttribute('type', 'sphere');
|
||||
geom.setAttribute('size', '.0015');
|
||||
probeBody.append(geom);
|
||||
xml.querySelector('worldbody')!.append(probeBody);
|
||||
const path = 'URDF/lekiwi-coverage-test.xml';
|
||||
adapter.workspace!.writeGenerated(
|
||||
path,
|
||||
new TextEncoder().encode(new XMLSerializer().serializeToString(xml)),
|
||||
);
|
||||
const module = session().module;
|
||||
session().dispose();
|
||||
adapter.session = new SimulationSession(module, adapter.workspace!.path(path));
|
||||
const s = session();
|
||||
bindings = new ModelBindings(s.model);
|
||||
const mocap = Number(
|
||||
s.model.body_mocapid[bindings.resolve(bindings.bodies, '__coverage_probe_body')],
|
||||
);
|
||||
const samples = visuals.map((v) => {
|
||||
const name = v.getAttribute('name')!,
|
||||
link = v.parentElement!.getAttribute('name')!;
|
||||
const sourceMesh = v.querySelector('mesh')!;
|
||||
const scale = (sourceMesh.getAttribute('scale') ?? '1 1 1').split(/\s+/).map(Number);
|
||||
const file = currentManifest.files.find(
|
||||
(f) => f.path === `URDF/${sourceMesh.getAttribute('filename')}`,
|
||||
)!;
|
||||
const data = new DataView(file.data.buffer, file.data.byteOffset, file.data.byteLength);
|
||||
const extrema: number[][] = [];
|
||||
for (let triangle = 0; triangle < data.getUint32(80, true); triangle++)
|
||||
for (let vertex = 0; vertex < 3; vertex++) {
|
||||
const point = [0, 1, 2].map(
|
||||
(a) => data.getFloat32(84 + triangle * 50 + 12 + vertex * 12 + a * 4, true) * scale[a],
|
||||
);
|
||||
for (let a = 0; a < 3; a++)
|
||||
for (let sign = 0; sign < 2; sign++) {
|
||||
const i = a * 2 + sign,
|
||||
factor = sign ? 1 : -1;
|
||||
if (!extrema[i] || point[a] * factor > extrema[i][a] * factor) extrema[i] = point;
|
||||
}
|
||||
}
|
||||
const visual = xml.querySelector(`geom[name="${name}"]`)!;
|
||||
const body = bindings.resolve(bindings.bodies, visual.parentElement!.getAttribute('name')!);
|
||||
const q = (visual.getAttribute('quat') ?? '1 0 0 0').split(/\s+/).map(Number);
|
||||
const pos = (visual.getAttribute('pos') ?? '0 0 0').split(/\s+/).map(Number);
|
||||
const probes = extrema.map((point) => {
|
||||
const local = new Vector3(...point)
|
||||
.applyQuaternion(new Quaternion(q[1], q[2], q[3], q[0]))
|
||||
.add(new Vector3(...pos))
|
||||
.toArray();
|
||||
const world = [0, 1, 2].map(
|
||||
(row) =>
|
||||
Number(s.data.xpos[body * 3 + row]) +
|
||||
local.reduce(
|
||||
(sum, value, col) => sum + value * Number(s.data.xmat[body * 9 + row * 3 + col]),
|
||||
0,
|
||||
),
|
||||
);
|
||||
s.data.mocap_pos.set(world, mocap * 3);
|
||||
module.mj_forward(s.model, s.data);
|
||||
const contacts = contactPairs().filter(
|
||||
(c) =>
|
||||
(c.geom1 === '__coverage_probe' && c.geom2.startsWith(`__lekiwi_cad_${link}__`)) ||
|
||||
(c.geom2 === '__coverage_probe' && c.geom1.startsWith(`__lekiwi_cad_${link}__`)),
|
||||
);
|
||||
return { point, contacts };
|
||||
});
|
||||
return { visual: name, probes };
|
||||
});
|
||||
s.data.mocap_pos.set([10, 10, 10], mocap * 3);
|
||||
module.mj_forward(s.model, s.data);
|
||||
return samples;
|
||||
},
|
||||
drive(x: number, y: number, w: number, seconds: number) {
|
||||
const wheels = bodyToWheels(x, y, w);
|
||||
p.wheels.forEach((j, i) =>
|
||||
session().setActuator(bindings.resolve(bindings.actuators, `${j}_servo`), wheels[i]),
|
||||
);
|
||||
return step(seconds);
|
||||
},
|
||||
arm(targets: number[], seconds: number) {
|
||||
p.arm.forEach((j, i) =>
|
||||
session().setActuator(bindings.resolve(bindings.actuators, `${j.joint}_servo`), targets[i]),
|
||||
);
|
||||
return step(seconds);
|
||||
},
|
||||
wall() {
|
||||
const xml = new DOMParser().parseFromString(
|
||||
new TextDecoder().decode(adapter.exportMjcf()),
|
||||
'application/xml',
|
||||
);
|
||||
const wall = xml.createElement('geom');
|
||||
for (const [k, v] of Object.entries({
|
||||
name: 'test_wall',
|
||||
type: 'box',
|
||||
pos: '.32 0 .12',
|
||||
size: '.03 .5 .12',
|
||||
}))
|
||||
wall.setAttribute(k, v);
|
||||
xml.querySelector('worldbody')!.append(wall);
|
||||
adapter.workspace!.writeGenerated(
|
||||
'URDF/lekiwi-test.xml',
|
||||
new TextEncoder().encode(new XMLSerializer().serializeToString(xml)),
|
||||
);
|
||||
const module = session().module;
|
||||
session().dispose();
|
||||
adapter.session = new SimulationSession(
|
||||
module,
|
||||
adapter.workspace!.path('URDF/lekiwi-test.xml'),
|
||||
);
|
||||
bindings = new ModelBindings(session().model);
|
||||
return step(1);
|
||||
},
|
||||
armSelfCollisionChecks() {
|
||||
const s = session();
|
||||
const supportGroups = new Set([
|
||||
bindings.resolve(bindings.bodies, p.baseBody),
|
||||
bindings.resolve(bindings.bodies, 'Rotation_Pitch_08i-v1'),
|
||||
]);
|
||||
const upperArm = bindings.resolve(bindings.bodies, 'SO_ARM100_08k_116_Square-v1');
|
||||
const joint = bindings.scalarJoint('arm_shoulder_lift');
|
||||
const geomNames = Array.from({ length: s.model.ngeom }, (_, i) => {
|
||||
const geom = s.model.geom(i);
|
||||
try {
|
||||
return geom.name;
|
||||
} finally {
|
||||
geom.delete();
|
||||
}
|
||||
});
|
||||
const contacts = () => {
|
||||
const touching: { distance: number; geom1: string; geom2: string }[] = [];
|
||||
for (let i = 0; i < s.data.ncon; i++) {
|
||||
const contact = s.data.contact.get(i)!;
|
||||
try {
|
||||
// The fixed base/mounting plate and rotating shoulder clip can all
|
||||
// stop the upper arm before it reaches the original Base/Square pair.
|
||||
const a = Number(s.model.body_weldid[Number(s.model.geom_bodyid[contact.geom1])]);
|
||||
const b = Number(s.model.body_weldid[Number(s.model.geom_bodyid[contact.geom2])]);
|
||||
if (
|
||||
((supportGroups.has(a) && b === upperArm) ||
|
||||
(a === upperArm && supportGroups.has(b))) &&
|
||||
geomNames[contact.geom1].startsWith('__lekiwi_cad_') &&
|
||||
geomNames[contact.geom2].startsWith('__lekiwi_cad_')
|
||||
)
|
||||
touching.push({
|
||||
distance: contact.dist,
|
||||
geom1: geomNames[contact.geom1],
|
||||
geom2: geomNames[contact.geom2],
|
||||
});
|
||||
} finally {
|
||||
contact.delete();
|
||||
}
|
||||
}
|
||||
return touching;
|
||||
};
|
||||
const samples = [0, 0.8, -0.8].map((pan) => {
|
||||
s.reset();
|
||||
harness.arm([pan, 0, 0, 0, 0, 0], 1.5);
|
||||
const neutral = { actual: Number(s.data.qpos[joint.qposAddress]), contacts: contacts() };
|
||||
const target = 0.6;
|
||||
s.setActuator(bindings.resolve(bindings.actuators, 'arm_shoulder_lift_servo'), target);
|
||||
let maxContacts = 0,
|
||||
maxPenetration = 0;
|
||||
for (let i = 0; i < Math.ceil(3 / Number(s.model.opt.timestep)); i++) {
|
||||
s.singleStep();
|
||||
const touching = contacts();
|
||||
maxContacts = Math.max(maxContacts, touching.length);
|
||||
for (const contact of touching)
|
||||
maxPenetration = Math.max(maxPenetration, -contact.distance);
|
||||
}
|
||||
const blocked = {
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
target,
|
||||
contacts: contacts(),
|
||||
allArmContacts: contactPairs().filter(
|
||||
(c) => c.geom1.startsWith('__lekiwi_cad_') && c.geom2.startsWith('__lekiwi_cad_'),
|
||||
),
|
||||
maxContacts,
|
||||
maxPenetration,
|
||||
finite: read().finite,
|
||||
};
|
||||
harness.arm([pan, -0.3, 0, 0, 0, 0], 2);
|
||||
const released = { actual: Number(s.data.qpos[joint.qposAddress]), contacts: contacts() };
|
||||
return { pan, neutral, blocked, released };
|
||||
});
|
||||
s.reset();
|
||||
return samples;
|
||||
},
|
||||
gripperCollisionChecks() {
|
||||
const xml = new DOMParser().parseFromString(harness.xml(), 'application/xml');
|
||||
const probe = xml.createElement('body');
|
||||
probe.setAttribute('name', 'gripper_probe_body');
|
||||
probe.setAttribute('mocap', 'true');
|
||||
probe.setAttribute('pos', '10 10 10');
|
||||
const sphere = xml.createElement('geom');
|
||||
sphere.setAttribute('name', 'gripper_probe');
|
||||
sphere.setAttribute('type', 'sphere');
|
||||
sphere.setAttribute('size', '.008');
|
||||
probe.append(sphere);
|
||||
xml.querySelector('worldbody')!.append(probe);
|
||||
const path = 'URDF/lekiwi-gripper-test.xml';
|
||||
adapter.workspace!.writeGenerated(
|
||||
path,
|
||||
new TextEncoder().encode(new XMLSerializer().serializeToString(xml)),
|
||||
);
|
||||
const module = session().module;
|
||||
session().dispose();
|
||||
adapter.session = new SimulationSession(module, adapter.workspace!.path(path));
|
||||
const s = session();
|
||||
bindings = new ModelBindings(s.model);
|
||||
const geoms = new Map<string, number>();
|
||||
for (let i = 0; i < s.model.ngeom; i++) {
|
||||
const geom = s.model.geom(i);
|
||||
try {
|
||||
geoms.set(geom.name, i);
|
||||
} finally {
|
||||
geom.delete();
|
||||
}
|
||||
}
|
||||
const probeId = geoms.get('gripper_probe')!;
|
||||
const fixedBody = bindings.resolve(bindings.bodies, 'Wrist_Roll_08c-v1');
|
||||
const movingBody = bindings.resolve(bindings.bodies, 'Moving_Jaw_08d-v1');
|
||||
const motor = bindings.resolve(bindings.actuators, 'arm_gripper_servo');
|
||||
const joint = bindings.scalarJoint('arm_gripper');
|
||||
const spec = p.arm[5];
|
||||
const sourcePoint = (visualName: string, point: number[]) => {
|
||||
// Independent CAD surface samples, NOT points taken from the collision recipe.
|
||||
const visual = xml.querySelector(`geom[name="${visualName}"]`)!;
|
||||
const body = bindings.resolve(bindings.bodies, visual.parentElement!.getAttribute('name')!);
|
||||
const q = (visual.getAttribute('quat') ?? '1 0 0 0').split(/\s+/).map(Number);
|
||||
const pos = (visual.getAttribute('pos') ?? '0 0 0').split(/\s+/).map(Number);
|
||||
const local = new Vector3(...point)
|
||||
.multiplyScalar(0.001)
|
||||
.applyQuaternion(new Quaternion(q[1], q[2], q[3], q[0]))
|
||||
.add(new Vector3(...pos))
|
||||
.toArray();
|
||||
return [0, 1, 2].map(
|
||||
(row) =>
|
||||
Number(s.data.xpos[body * 3 + row]) +
|
||||
local.reduce(
|
||||
(sum, value, col) => sum + value * Number(s.data.xmat[body * 9 + row * 3 + col]),
|
||||
0,
|
||||
),
|
||||
);
|
||||
};
|
||||
const contacts = () => {
|
||||
const result: { other: string; distance: number }[] = [];
|
||||
for (let i = 0; i < s.data.ncon; i++) {
|
||||
const contact = s.data.contact.get(i)!;
|
||||
try {
|
||||
if (contact.geom1 !== probeId && contact.geom2 !== probeId) continue;
|
||||
const other = contact.geom1 === probeId ? contact.geom2 : contact.geom1;
|
||||
result.push({
|
||||
other: [...geoms].find(([, id]) => id === other)![0],
|
||||
distance: contact.dist,
|
||||
});
|
||||
} finally {
|
||||
contact.delete();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const place = (position: number[]) => {
|
||||
s.data.mocap_pos.set(position);
|
||||
module.mj_forward(s.model, s.data);
|
||||
return contacts();
|
||||
};
|
||||
step(0.5);
|
||||
const coverage = [
|
||||
{ name: 'fixed_tip', visual: 'Wrist_Roll_08c-v1_visual', point: [-12, 0, 99] },
|
||||
{ name: 'moving_tip', visual: 'Moving_Jaw_08d-v1_visual', point: [-10, -78, 0] },
|
||||
].map(({ name, visual, point }) => ({ name, contacts: place(sourcePoint(visual, point)) }));
|
||||
place([10, 10, 10]);
|
||||
// Select actual collision parts contacted by independent CAD tip probes.
|
||||
// No hull indices or generator vertices are used as the test oracle.
|
||||
const fingerIds = coverage.map((sample, i) =>
|
||||
sample.contacts
|
||||
.filter((c) =>
|
||||
c.other.startsWith(
|
||||
`__lekiwi_cad_${i === 0 ? 'Wrist_Roll_08c-v1' : 'Moving_Jaw_08d-v1'}__`,
|
||||
),
|
||||
)
|
||||
.map((c) => geoms.get(c.other)!),
|
||||
);
|
||||
const surface = (geom: number): Surface => {
|
||||
const m = s.model,
|
||||
d = s.data,
|
||||
mesh = Number(m.geom_dataid[geom]);
|
||||
const va = Number(m.mesh_vertadr[mesh]),
|
||||
fa = Number(m.mesh_faceadr[mesh]);
|
||||
return {
|
||||
vertices: Array.from({ length: Number(m.mesh_vertnum[mesh]) }, (_, i) => {
|
||||
const local = [0, 1, 2].map((c) => Number(m.mesh_vert[3 * (va + i) + c]));
|
||||
return new Vector3(
|
||||
...[0, 1, 2].map(
|
||||
(r) =>
|
||||
Number(d.geom_xpos[3 * geom + r]) +
|
||||
local.reduce((sum, v, c) => sum + v * Number(d.geom_xmat[9 * geom + 3 * r + c]), 0),
|
||||
),
|
||||
);
|
||||
}),
|
||||
faces: Array.from({ length: Number(m.mesh_facenum[mesh]) }, (_, i) =>
|
||||
[0, 1, 2].map((c) => Number(m.mesh_face[3 * (fa + i) + c])),
|
||||
),
|
||||
};
|
||||
};
|
||||
const closestPair = () => {
|
||||
// Independent triangle geometry: WASM 3.11 mj_geomDistance returned scalar 0
|
||||
// for a separated thin hull pair (its witness points were >50 mm apart).
|
||||
// Do not silently discard that pair, loosen gap assertions or tune the solver.
|
||||
const surfaces = fingerIds.map((ids) => ids.map(surface));
|
||||
let closest: ReturnType<typeof surfaceDistance> | undefined;
|
||||
for (const fixed of surfaces[0])
|
||||
for (const moving of surfaces[1]) {
|
||||
const result = surfaceDistance(fixed, moving);
|
||||
if (!closest || result.distance < closest.distance) closest = result;
|
||||
}
|
||||
if (!closest) throw new Error('CAD 指尖没有对应碰撞体');
|
||||
return closest;
|
||||
};
|
||||
const openings = [0, 0.5, 1].map((opening) => {
|
||||
const target = spec.min + opening * (spec.max - spec.min);
|
||||
s.setActuator(motor, target);
|
||||
step(1);
|
||||
return {
|
||||
opening,
|
||||
target,
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
separation: closestPair().distance,
|
||||
contacts: contactPairs().filter(
|
||||
(c) =>
|
||||
(c.body1 === fixedBody && c.body2 === movingBody) ||
|
||||
(c.body1 === movingBody && c.body2 === fixedBody),
|
||||
),
|
||||
};
|
||||
});
|
||||
// Put a rigid object in the jaw space of the half-open pose; then close on it.
|
||||
s.setActuator(motor, (spec.min + spec.max) / 2);
|
||||
step(1);
|
||||
const closest = closestPair();
|
||||
const midpoint = [0, 1, 2].map((axis) => (closest.from[axis] + closest.to[axis]) / 2);
|
||||
s.setActuator(motor, spec.max);
|
||||
step(1);
|
||||
const gapContacts = place(midpoint);
|
||||
s.setActuator(motor, spec.min);
|
||||
let maxContacts = 0,
|
||||
maxPenetration = 0;
|
||||
for (let i = 0; i < Math.ceil(2 / Number(s.model.opt.timestep)); i++) {
|
||||
s.singleStep();
|
||||
const touching = contacts();
|
||||
maxContacts = Math.max(maxContacts, touching.length);
|
||||
for (const contact of touching) maxPenetration = Math.max(maxPenetration, -contact.distance);
|
||||
}
|
||||
const obstruction = {
|
||||
maxContacts,
|
||||
maxPenetration,
|
||||
actual: Number(s.data.qpos[joint.qposAddress]),
|
||||
target: spec.min,
|
||||
contacts: contacts(),
|
||||
};
|
||||
place([10, 10, 10]);
|
||||
// Deliberately overlap the fingers to check structural pairs bypass the
|
||||
// default parent filter. This is a query, never a normal control command.
|
||||
s.data.qpos[joint.qposAddress] = -0.24;
|
||||
module.mj_forward(s.model, s.data);
|
||||
let fingerSelfContacts = 0;
|
||||
for (let i = 0; i < s.data.ncon; i++) {
|
||||
const contact = s.data.contact.get(i)!;
|
||||
try {
|
||||
if (
|
||||
(s.model.geom_bodyid[contact.geom1] === fixedBody &&
|
||||
s.model.geom_bodyid[contact.geom2] === movingBody) ||
|
||||
(s.model.geom_bodyid[contact.geom2] === fixedBody &&
|
||||
s.model.geom_bodyid[contact.geom1] === movingBody)
|
||||
)
|
||||
fingerSelfContacts++;
|
||||
} finally {
|
||||
contact.delete();
|
||||
}
|
||||
}
|
||||
s.reset();
|
||||
return {
|
||||
coverage,
|
||||
openings,
|
||||
gapContacts,
|
||||
obstruction,
|
||||
fingerSelfContacts,
|
||||
finite: read().finite,
|
||||
};
|
||||
},
|
||||
async runtimeChecks() {
|
||||
const s = session();
|
||||
await s.loadPythonController(
|
||||
`OLD = None\nMOTOR = 0\ndef init(model):\n global MOTOR\n MOTOR=model.actuator('arm_shoulder_pan_servo')\n return MOTOR\ndef step(ctx, state):\n global OLD\n OLD=ctx\n ctx.set_control(state, 0.1)\ndef dispose(state):\n if OLD is not None:\n OLD.set_control(MOTOR, 1.2)\n`,
|
||||
'scoped.py',
|
||||
);
|
||||
s.setControllerEnabled(true);
|
||||
s.singleStep();
|
||||
const motor = bindings.resolve(bindings.actuators, 'arm_shoulder_pan_servo');
|
||||
const pythonTarget = Number(s.data.ctrl[motor]);
|
||||
s.setPaused(false);
|
||||
s.setExternalControlEnabled(true);
|
||||
const identity = s.claimExternalControlLease('browser-lease');
|
||||
const values = Object.fromEntries(s.describeRobot()!.actionChannels.map((c) => [c.id, 0]));
|
||||
values['arm_gripper.pos'] = 0.25;
|
||||
values['arm_shoulder_pan.pos'] = 0.3;
|
||||
values['x.vel'] = 0.1;
|
||||
const pending = s.sendRobotAction({ protocolVersion: 1, ...identity, actionSeq: 1, values });
|
||||
s.singleStep();
|
||||
const accepted = await pending;
|
||||
let manualBlocked = false;
|
||||
try {
|
||||
s.setActuator(motor, 1);
|
||||
} catch {
|
||||
manualBlocked = true;
|
||||
}
|
||||
s.removeController(); // old Python dispose tries to write 1.2 through its saved ctx
|
||||
const afterOldDispose = Number(s.data.ctrl[motor]);
|
||||
const measured = s.robotObservation();
|
||||
s.setPaused(true);
|
||||
const held = Number(s.data.ctrl[motor]);
|
||||
const actual = Number(s.data.qpos[bindings.scalarJoint('arm_shoulder_pan').qposAddress]);
|
||||
const paused = s.snapshot();
|
||||
s.setPaused(false);
|
||||
s.setExternalControlEnabled(true);
|
||||
const nextIdentity = s.claimExternalControlLease('next-lease');
|
||||
const cancel = s
|
||||
.sendRobotAction({ protocolVersion: 1, ...nextIdentity, actionSeq: 1, values })
|
||||
.catch((e) => e.code);
|
||||
s.reset();
|
||||
return {
|
||||
pythonTarget,
|
||||
accepted,
|
||||
manualBlocked,
|
||||
afterOldDispose,
|
||||
measured,
|
||||
held,
|
||||
actual,
|
||||
paused: paused.externalControl,
|
||||
pausedOwner: paused.controlOwner,
|
||||
reset: s.robotObservation(),
|
||||
cancel: await cancel,
|
||||
};
|
||||
},
|
||||
async reimport() {
|
||||
const data = adapter.exportMjcf();
|
||||
const file = {
|
||||
path: 'URDF/export.xml',
|
||||
data,
|
||||
size: data.length,
|
||||
mimeType: '',
|
||||
source: 'file' as const,
|
||||
};
|
||||
const manifest: ProjectManifest = {
|
||||
...currentManifest,
|
||||
files: [...currentManifest.files, file],
|
||||
entries: [...currentManifest.entries, { path: file.path, format: 'mjcf', label: 'export' }],
|
||||
};
|
||||
await adapter.load(manifest, file.path, {
|
||||
robotProfileId: p.id,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
});
|
||||
adapter.releaseRetired();
|
||||
bindings = new ModelBindings(session().model);
|
||||
return { robot: adapter.describeRobot(), ...read() };
|
||||
},
|
||||
xml() {
|
||||
return new TextDecoder().decode(adapter.exportMjcf());
|
||||
},
|
||||
dispose() {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||
frame = undefined;
|
||||
bridge?.disconnect();
|
||||
bridge = undefined;
|
||||
adapter.dispose();
|
||||
},
|
||||
};
|
||||
export type PhysicsHarness = typeof harness;
|
||||
declare global {
|
||||
interface Window {
|
||||
lekiwiPhysics: PhysicsHarness;
|
||||
}
|
||||
}
|
||||
window.lekiwiPhysics = harness;
|
||||
Reference in New Issue
Block a user