feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
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
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
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>LeKiwi 真实接触验收夹具</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./agent.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,356 @@
|
||||
import { MainThreadPhysicsAdapter } from '../src/simulation/PhysicsAdapter';
|
||||
import { importBrowserFiles } from '../src/project/importer';
|
||||
import type { ProjectManifest } from '../src/project/types';
|
||||
import { composePickPlaceScene, PICK_PLACE_ROBOT } from '../src/mobile/agent/PickPlaceScene';
|
||||
import { DeterministicBaseline } from '../src/mobile/agent/DeterministicBaseline';
|
||||
import { AgentTaskController } from '../src/mobile/agent/AgentTaskController';
|
||||
import { MockDecisionProvider } from '../src/mobile/agent/MockDecisionProvider';
|
||||
import { DecisionClient } from '../src/mobile/agent/DecisionClient';
|
||||
import { ModelBindings } from '../src/simulation/ModelBindings';
|
||||
import { PICK_PLACE as P } from '../src/mobile/agent/PickPlaceScene';
|
||||
type Fault =
|
||||
| 'empty-grasp'
|
||||
| 'empty-once'
|
||||
| 'alignment'
|
||||
| 'slip'
|
||||
| 'jam'
|
||||
| 'navigation'
|
||||
| 'unreachable'
|
||||
| 'overspeed';
|
||||
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
let source: ProjectManifest | undefined;
|
||||
let active: DeterministicBaseline | undefined;
|
||||
let task: AgentTaskController | undefined;
|
||||
let generation = 0;
|
||||
const api = {
|
||||
async boot(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`A 资产 HTTP ${response.status}`);
|
||||
source = await importBrowserFiles([new File([await response.blob()], 'lekiwi-v1.zip')]);
|
||||
},
|
||||
async baseline(seed: number, governed = false) {
|
||||
const epoch = ++generation;
|
||||
task?.dispose();
|
||||
task = undefined;
|
||||
active?.dispose();
|
||||
active = undefined;
|
||||
if (!source) throw new Error('尚未导入 A 资产');
|
||||
await adapter.load(source, 'URDF/LeKiwi.urdf', {
|
||||
robotProfileId: 'lekiwi-v1',
|
||||
configureRobotRuntime: false,
|
||||
mobileRobot: governed ? PICK_PLACE_ROBOT : undefined,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
sceneComposer: (xml) => composePickPlaceScene(xml, seed, governed),
|
||||
});
|
||||
adapter.releaseRetired();
|
||||
const session = adapter.session!;
|
||||
let baseline!: DeterministicBaseline;
|
||||
let ownerAtStart = 'manual';
|
||||
if (governed) {
|
||||
session.startAgent((write) => {
|
||||
baseline = new DeterministicBaseline(session, seed, write);
|
||||
let cancelled = false;
|
||||
return {
|
||||
beforeStep: () => {
|
||||
if (cancelled) return false;
|
||||
baseline.beforeStep();
|
||||
return baseline.status === 'running';
|
||||
},
|
||||
afterStep: (dt) => {
|
||||
baseline.afterStep(dt);
|
||||
if (baseline.status !== 'running') session.stopAgent(baseline.status);
|
||||
},
|
||||
hold: () => baseline.hold(),
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
dispose: () => baseline.dispose(),
|
||||
};
|
||||
});
|
||||
session.setPaused(false);
|
||||
ownerAtStart = session.snapshot().controlOwner!;
|
||||
} else
|
||||
baseline = new DeterministicBaseline(session, seed, (id, value) =>
|
||||
session.setActuator(id, value),
|
||||
);
|
||||
active = baseline;
|
||||
const opt = session.model.opt,
|
||||
dt = Number(opt.timestep);
|
||||
opt.delete();
|
||||
let steps = 0;
|
||||
const began = performance.now();
|
||||
while (baseline.status === 'running') {
|
||||
if (epoch !== generation) throw new Error('基线已取消');
|
||||
if (!governed) baseline.beforeStep();
|
||||
if (baseline.status !== 'running') break;
|
||||
session.singleStep();
|
||||
if (++steps % 20 === 0) session.module.mj_forward(session.model, session.data);
|
||||
if (!governed) baseline.afterStep(dt);
|
||||
if (steps % 200 === 0) await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
const result = {
|
||||
seed,
|
||||
ownerAtStart,
|
||||
status: baseline.status,
|
||||
error: baseline.error,
|
||||
steps,
|
||||
simTime: Number(session.data.time),
|
||||
wallMs: performance.now() - began,
|
||||
transported: baseline.evaluator.transported,
|
||||
succeeded: baseline.evaluator.succeeded,
|
||||
maxJointSpeed: baseline.maxJointSpeed,
|
||||
final: baseline.sample,
|
||||
trace: baseline.trace,
|
||||
};
|
||||
baseline.dispose();
|
||||
active = undefined;
|
||||
return result;
|
||||
},
|
||||
async task(
|
||||
seed: number,
|
||||
connection?: { address: string; token: string },
|
||||
options: { fault?: Fault; goal?: number[] } = {},
|
||||
) {
|
||||
if (connection && options.fault) throw new Error('故障夹具禁止真实 API');
|
||||
const epoch = ++generation;
|
||||
task?.dispose();
|
||||
active?.dispose();
|
||||
if (!source) throw new Error('尚未导入 A 资产');
|
||||
await adapter.load(source, 'URDF/LeKiwi.urdf', {
|
||||
robotProfileId: 'lekiwi-v1',
|
||||
configureRobotRuntime: false,
|
||||
mobileRobot: PICK_PLACE_ROBOT,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
sceneComposer: (xml) => composePickPlaceScene(xml, seed, true, options.goal),
|
||||
});
|
||||
adapter.releaseRetired();
|
||||
const session = adapter.session!;
|
||||
const provider = connection
|
||||
? new DecisionClient(connection.address, connection.token)
|
||||
: new MockDecisionProvider();
|
||||
let controller!: AgentTaskController;
|
||||
let skills!: DeterministicBaseline;
|
||||
session.startAgent((write) => {
|
||||
const b = new ModelBindings(session.model),
|
||||
r = PICK_PLACE_ROBOT;
|
||||
const grip = b.scalarActuator(
|
||||
r.gripperActuators[0].name,
|
||||
r.gripperJoint,
|
||||
'position',
|
||||
).actuatorId;
|
||||
const jam = b.scalarActuator(r.armActuators[1], r.armJoints[1].name, 'position').actuatorId;
|
||||
const wheels = new Set(
|
||||
r.baseActuators.map(
|
||||
(name, i) => b.scalarActuator(name, r.baseJoints[i], 'velocity').actuatorId,
|
||||
),
|
||||
);
|
||||
let jamTarget = 0,
|
||||
slipAt: number | undefined,
|
||||
slipOpening = 0;
|
||||
// Test-only actuator faults: no live qpos writes, attachments or object teleportation.
|
||||
const disturbedWrite = (id: number, value: number) => {
|
||||
if (options.fault === 'jam' && id === jam) value = jamTarget;
|
||||
if (options.fault === 'overspeed' && id === jam) value = r.armJoints[1].max;
|
||||
if (options.fault === 'navigation' && wheels.has(id)) value = 0;
|
||||
if (
|
||||
id === grip &&
|
||||
(options.fault === 'empty-grasp' ||
|
||||
(options.fault === 'empty-once' && controller.failures.length === 0))
|
||||
)
|
||||
value = r.gripperOpen;
|
||||
if (id === grip && options.fault === 'slip') {
|
||||
if (
|
||||
slipAt === undefined &&
|
||||
skills.phase === 'carry' &&
|
||||
skills.evaluator.transported > 0.12
|
||||
) {
|
||||
slipAt = skills.sample.time;
|
||||
slipOpening = skills.sample.opening;
|
||||
}
|
||||
if (slipAt !== undefined) {
|
||||
const opening = Math.min(
|
||||
1,
|
||||
slipOpening + P.gripperOpeningRate * (skills.sample.time - slipAt),
|
||||
);
|
||||
value = r.gripperClosed + opening * (r.gripperOpen - r.gripperClosed);
|
||||
}
|
||||
}
|
||||
write(id, value);
|
||||
};
|
||||
skills = new DeterministicBaseline(session, seed, disturbedWrite, true, options.goal);
|
||||
jamTarget = skills.sample.joints[1];
|
||||
if (options.fault === 'unreachable' || options.fault === 'alignment') {
|
||||
const solve = skills.ik.solve.bind(skills.ik);
|
||||
// Actual scratch-data IK, not fake success/failure results or live qpos writes.
|
||||
skills.ik.solve = (target) =>
|
||||
solve(
|
||||
options.fault === 'unreachable' ? [2, 2, 2] : [target[0] + 0.02, target[1], target[2]],
|
||||
);
|
||||
}
|
||||
controller = new AgentTaskController(
|
||||
skills,
|
||||
provider,
|
||||
'将方块抓起,使用底盘搬运到指定支撑台并释放、撤离。',
|
||||
);
|
||||
return controller;
|
||||
});
|
||||
task = controller;
|
||||
active = skills;
|
||||
session.setPaused(false);
|
||||
const ownerAtStart = session.snapshot().controlOwner;
|
||||
const began = performance.now();
|
||||
let frames = 0;
|
||||
while (!controller.terminal) {
|
||||
if (epoch !== generation) throw new Error('任务已取消');
|
||||
session.singleStep();
|
||||
if (++frames % 20 === 0 && controller.state === 'executing')
|
||||
session.module.mj_forward(session.model, session.data);
|
||||
if (frames % 200 === 0) await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
const result = {
|
||||
...controller.snapshot(),
|
||||
fault: options.fault ?? null,
|
||||
ownerAtStart,
|
||||
seed,
|
||||
simTime: Number(session.data.time),
|
||||
wallMs: performance.now() - began,
|
||||
maxJointSpeed: skills.maxJointSpeed,
|
||||
results: controller.results,
|
||||
failures: controller.failures,
|
||||
trace: skills.trace,
|
||||
receipts: provider instanceof DecisionClient ? provider.receipts : [],
|
||||
};
|
||||
session.stopAgent('任务夹具已结束');
|
||||
task = undefined;
|
||||
active = undefined;
|
||||
return result;
|
||||
},
|
||||
async lifecycle(action: 'pause-task' | 'pause-global' | 'stop' | 'force' | 'reload') {
|
||||
task?.dispose();
|
||||
active?.dispose();
|
||||
if (!source) throw new Error('尚未导入 A 资产');
|
||||
const load = () =>
|
||||
adapter.load(source!, 'URDF/LeKiwi.urdf', {
|
||||
robotProfileId: 'lekiwi-v1',
|
||||
configureRobotRuntime: false,
|
||||
mobileRobot: PICK_PLACE_ROBOT,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
sceneComposer: (xml) => composePickPlaceScene(xml, 0, true),
|
||||
});
|
||||
await load();
|
||||
adapter.releaseRetired();
|
||||
const session = adapter.session!,
|
||||
provider = new MockDecisionProvider();
|
||||
let release!: () => void;
|
||||
const delayed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const plan = provider.plan.bind(provider);
|
||||
// Deliberately ignore abort AFTER creating the reply: prove the local epoch gate,
|
||||
// not just HTTP cancellation, prevents a broken provider from moving real WASM.
|
||||
provider.plan = async (...args) => {
|
||||
const reply = await plan(...args);
|
||||
await delayed;
|
||||
return reply;
|
||||
};
|
||||
let controller!: AgentTaskController, skills!: DeterministicBaseline;
|
||||
session.startAgent((write) => {
|
||||
skills = new DeterministicBaseline(session, 0, write, true);
|
||||
controller = new AgentTaskController(skills, provider, '抓取并搬运方块');
|
||||
return controller;
|
||||
});
|
||||
task = controller;
|
||||
active = skills;
|
||||
session.setPaused(false);
|
||||
session.singleStep();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
if (controller.state !== 'planning') throw new Error('生命周期夹具未进入等待规划');
|
||||
if (action === 'pause-task') controller.pause();
|
||||
if (action === 'pause-global') session.setPaused(true);
|
||||
if (action === 'stop') session.stopAgent('夹具停止');
|
||||
if (action === 'force') session.setExternalForce(1, [1, 0, 0]);
|
||||
if (action === 'reload') {
|
||||
await load();
|
||||
adapter.releaseRetired();
|
||||
}
|
||||
const current = adapter.session!;
|
||||
const before = {
|
||||
time: Number(current.data.time),
|
||||
ctrl: Array.from(current.data.ctrl),
|
||||
qpos: Array.from(current.data.qpos),
|
||||
};
|
||||
release();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
for (let i = 0; i < 10; i++) current.advance(performance.now() + i * 20);
|
||||
const after = {
|
||||
time: Number(current.data.time),
|
||||
ctrl: Array.from(current.data.ctrl),
|
||||
qpos: Array.from(current.data.qpos),
|
||||
};
|
||||
const result = {
|
||||
action,
|
||||
source: 'delayed-mock-plus-real-WASM',
|
||||
state: controller.state,
|
||||
owner: current.controlOwner,
|
||||
jevCalls: controller.jevCalls,
|
||||
before,
|
||||
after,
|
||||
};
|
||||
controller.cancel('生命周期验收结束');
|
||||
return result;
|
||||
},
|
||||
async resources() {
|
||||
const rows: { heapBytes: number; resources: { isDeleted(): boolean }[] }[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await api.lifecycle('stop');
|
||||
const current = adapter.session!;
|
||||
// Test-only reflection retains handles, not their typed-array views, to verify
|
||||
// native deletions rather than inferring a leak from linear-memory capacity.
|
||||
const ik = active!.ik as unknown as {
|
||||
scratch: { isDeleted(): boolean };
|
||||
positionJacobian: { isDeleted(): boolean };
|
||||
rotationJacobian: { isDeleted(): boolean };
|
||||
};
|
||||
const sampler = active! as unknown as { sampler: { force: { isDeleted(): boolean } } };
|
||||
rows.push({
|
||||
heapBytes: (current.data.qpos as Float64Array).buffer.byteLength,
|
||||
resources: [
|
||||
current.model,
|
||||
current.data,
|
||||
ik.scratch,
|
||||
ik.positionJacobian,
|
||||
ik.rotationJacobian,
|
||||
sampler.sampler.force,
|
||||
],
|
||||
});
|
||||
task?.dispose();
|
||||
active?.dispose();
|
||||
}
|
||||
api.dispose();
|
||||
return rows.map((row) => ({
|
||||
heapBytes: row.heapBytes,
|
||||
deleted: row.resources.map((resource) => resource.isDeleted()),
|
||||
}));
|
||||
},
|
||||
dispose() {
|
||||
generation++;
|
||||
task?.dispose();
|
||||
task = undefined;
|
||||
active?.dispose();
|
||||
active = undefined;
|
||||
adapter.dispose();
|
||||
},
|
||||
};
|
||||
declare global {
|
||||
interface Window {
|
||||
lekiwiAgent: typeof api;
|
||||
}
|
||||
}
|
||||
window.lekiwiAgent = api;
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Mobile WASM contract runner</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./mobile.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { RobotManager } from '../src/mobile/RobotManager';
|
||||
import { MOBILE_ROBOTS } from '../src/mobile/RobotDescriptor';
|
||||
import type { MobilePolicyMetadata } from '../src/mobile/ONNXPolicyRunner';
|
||||
import { MuJoCoViewer } from '../src/viewer/MuJoCoViewer';
|
||||
import { TaskDragController } from '../src/mobile/TaskDragController';
|
||||
import { Vector3 } from 'three';
|
||||
|
||||
let viewer: MuJoCoViewer | undefined;
|
||||
let drag: TaskDragController | undefined;
|
||||
const manager = new RobotManager({
|
||||
attach(session) {
|
||||
viewer?.attach(session);
|
||||
},
|
||||
});
|
||||
function snapshot() {
|
||||
const env = manager.env!;
|
||||
const observation = env.observe();
|
||||
return {
|
||||
robotId: env.config.id,
|
||||
state: Array.from(env.state),
|
||||
observation: Array.from(observation),
|
||||
ctrl: Array.from(env.session.data.ctrl, Number),
|
||||
qpos: Array.from(env.session.data.qpos, Number),
|
||||
time: Number(env.session.data.time),
|
||||
reward: env.kernel.result.reward,
|
||||
stage: env.kernel.result.info.stage,
|
||||
revision: env.revision,
|
||||
heap: env.wasmHeapBytes,
|
||||
version: env.session.module.mj_versionString(),
|
||||
workspace: manager.adapter.workspace!.root,
|
||||
};
|
||||
}
|
||||
const api = {
|
||||
async boot(url: string, id: string) {
|
||||
const config = MOBILE_ROBOTS.find((c) => c.id === id)!;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`asset HTTP ${response.status}`);
|
||||
const previous = manager.adapter.session;
|
||||
await manager.loadZip(new File([await response.blob()], 'robot.zip'), config);
|
||||
return {
|
||||
...snapshot(),
|
||||
retiredModelDeleted: previous?.model.isDeleted() ?? true,
|
||||
retiredDataDeleted: previous?.data.isDeleted() ?? true,
|
||||
};
|
||||
},
|
||||
async invalidCandidate(url: string) {
|
||||
const config = structuredClone(MOBILE_ROBOTS[1]);
|
||||
const wrong = {
|
||||
...config,
|
||||
armActuators: ['missing_actuator', ...config.armActuators.slice(1)],
|
||||
};
|
||||
await manager.loadZip(new File([await (await fetch(url)).blob()], 'robot.zip'), wrong);
|
||||
},
|
||||
step(n: number, action?: number[], manual = false) {
|
||||
const env = manager.env!;
|
||||
if (action) env.action.set(action);
|
||||
const started = performance.now();
|
||||
for (let i = 0; i < n; i++) env.step(env.action, manual);
|
||||
return { ...snapshot(), wallMs: performance.now() - started };
|
||||
},
|
||||
reset() {
|
||||
manager.reset();
|
||||
return snapshot();
|
||||
},
|
||||
move(entity: 'object' | 'goal', position: number[]) {
|
||||
manager.move(entity, position);
|
||||
return snapshot();
|
||||
},
|
||||
state: snapshot,
|
||||
workspaces() {
|
||||
const module = manager.adapter.session!.module as unknown as {
|
||||
FS: { readdir(p: string): string[] };
|
||||
};
|
||||
return module.FS.readdir('/workspace').filter((s) => s !== '.' && s !== '..');
|
||||
},
|
||||
async download() {
|
||||
const bytes = await manager.exportTrainingBundle();
|
||||
const href = URL.createObjectURL(new Blob([bytes.slice().buffer]));
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = 'training.zip';
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 1000);
|
||||
},
|
||||
async policy(modelUrl: string, metadata: MobilePolicyMetadata) {
|
||||
await manager.loadPolicy(new Uint8Array(await (await fetch(modelUrl)).arrayBuffer()), metadata);
|
||||
manager.setMode('policy');
|
||||
manager.setRunning(true);
|
||||
},
|
||||
tick(now: number) {
|
||||
return {
|
||||
...manager.advance(now),
|
||||
metrics: manager.policy?.metrics,
|
||||
time: Number(manager.env!.session.data.time),
|
||||
};
|
||||
},
|
||||
show() {
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:1000px;height:650px;position:relative';
|
||||
document.body.append(host);
|
||||
const noop = () => {};
|
||||
viewer = new MuJoCoViewer(host, {
|
||||
advance: (now) => manager.advance(now),
|
||||
onSelection: noop,
|
||||
onFrame: noop,
|
||||
onError: (e) => {
|
||||
throw e;
|
||||
},
|
||||
onMapEditorSelect: noop,
|
||||
onMapEditorPreviewSelect: noop,
|
||||
onParametricMapSelect: noop,
|
||||
onParametricMapTransform: noop,
|
||||
onMapEditorTransform: noop,
|
||||
});
|
||||
viewer.attach(manager.adapter.session);
|
||||
drag = new TaskDragController(viewer, manager);
|
||||
},
|
||||
screen(entity: 'object' | 'goal') {
|
||||
const s = manager.env!.state,
|
||||
offset = entity === 'object' ? 37 : 47;
|
||||
const p = new Vector3(s[offset], s[offset + 1], s[offset + 2]).project(viewer!.camera);
|
||||
const r = viewer!.renderer.domElement.getBoundingClientRect();
|
||||
return { x: r.left + (p.x + 1) * 0.5 * r.width, y: r.top + (1 - p.y) * 0.5 * r.height };
|
||||
},
|
||||
async dispose() {
|
||||
drag?.dispose();
|
||||
await manager.dispose();
|
||||
viewer?.dispose();
|
||||
},
|
||||
};
|
||||
declare global {
|
||||
interface Window {
|
||||
mobilePhysics: typeof api;
|
||||
}
|
||||
}
|
||||
window.mobilePhysics = api;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { MainThreadPhysicsAdapter } from '../src/simulation/PhysicsAdapter';
|
||||
import { SimulationSession } from '../src/simulation/SimulationSession';
|
||||
import { ModelBindings } from '../src/simulation/ModelBindings';
|
||||
import { mergeCachedFiles } from '../src/project/cachedFiles';
|
||||
import { compiledProjectPackage } from '../src/project/exportPackage';
|
||||
import { importBrowserFiles } from '../src/project/importer';
|
||||
import { LEKIWI_PROFILE as p, bodyToWheels } from '../src/robot/profiles/lekiwi';
|
||||
import type { ProjectManifest } from '../src/project/types';
|
||||
import { ExternalControlClient } from '../src/robot/ExternalControlClient';
|
||||
@@ -13,6 +16,7 @@ import { surfaceDistance, type Surface } from './meshDistance';
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
let bindings: ModelBindings;
|
||||
let currentManifest: ProjectManifest;
|
||||
let roundtripPackage: ReturnType<typeof compiledProjectPackage> | undefined;
|
||||
let frame: number | undefined;
|
||||
let bridge: ExternalControlClient | undefined;
|
||||
function session() {
|
||||
@@ -169,6 +173,7 @@ const harness = {
|
||||
bridge?.sync();
|
||||
},
|
||||
async boot(assetBase: string) {
|
||||
roundtripPackage = undefined;
|
||||
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> };
|
||||
@@ -198,6 +203,8 @@ const harness = {
|
||||
map: { kind: 'none' },
|
||||
enhancements: { addActuators: false, addSensors: false, sensorType: 'camera' },
|
||||
});
|
||||
currentManifest = mergeCachedFiles(currentManifest, adapter.cachedSupportFiles());
|
||||
adapter.releaseRetired();
|
||||
bindings = new ModelBindings(session().model);
|
||||
return { version: session().module.mj_versionString(), ...read() };
|
||||
},
|
||||
@@ -778,6 +785,86 @@ const harness = {
|
||||
cancel: await cancel,
|
||||
};
|
||||
},
|
||||
visualStats() {
|
||||
const s = session(),
|
||||
m = s.model,
|
||||
d = s.data;
|
||||
s.module.mj_forward(m, d);
|
||||
const wheels = p.wheels.map((name) => {
|
||||
const wheelBody = bindings.resolve(bindings.bodies, `__lekiwi_${name}`);
|
||||
for (let id = 0; id < m.ngeom; id++) {
|
||||
const geom = m.geom(id);
|
||||
try {
|
||||
if (
|
||||
!geom.name.startsWith('4-Omni-Directional-Wheel_Single_Body') ||
|
||||
Number(m.geom_group[id]) !== 1
|
||||
)
|
||||
continue;
|
||||
let body = Number(m.geom_bodyid[id]);
|
||||
while (body && body !== wheelBody) body = Number(m.body_parentid[body]);
|
||||
if (body !== wheelBody) continue;
|
||||
const meshId = Number(m.geom_dataid[id]);
|
||||
const centerError = Math.hypot(
|
||||
...[0, 1, 2].map((i) => d.geom_xpos[id * 3 + i] - d.xpos[wheelBody * 3 + i]),
|
||||
);
|
||||
return {
|
||||
name,
|
||||
faces: Number(m.mesh_facenum[meshId]),
|
||||
vertices: Number(m.mesh_vertnum[meshId]),
|
||||
scale: Array.from(m.mesh_scale.slice(meshId * 3, meshId * 3 + 3)),
|
||||
centerError,
|
||||
rotation: Array.from(d.geom_xmat.slice(id * 9, id * 9 + 9), Number),
|
||||
collision: Boolean(m.geom_contype[id] || m.geom_conaffinity[id]),
|
||||
mass: Number(m.body_mass[Number(m.geom_bodyid[id])]),
|
||||
};
|
||||
} finally {
|
||||
geom.delete();
|
||||
}
|
||||
}
|
||||
throw new Error(`missing CAD wheel ${name}`);
|
||||
});
|
||||
const fs = s.module as unknown as { FS: { readdir(path: string): string[] } };
|
||||
return {
|
||||
wheels,
|
||||
heap: (d.qpos as Float64Array).buffer.byteLength,
|
||||
projectBytes: currentManifest.files.reduce((sum, file) => sum + file.data.byteLength, 0),
|
||||
xmlFiles: currentManifest.files
|
||||
.filter((f) => f.path.endsWith('.xml'))
|
||||
.map((f) => ({ path: f.path, bytes: f.size })),
|
||||
workspaces: fs.FS.readdir('/workspace').filter((name) => name !== '.' && name !== '..'),
|
||||
generatedMeshes: currentManifest.files
|
||||
.filter((f) => /stl-obj-v1-.*\.obj$/.test(f.path))
|
||||
.map((f) => ({ path: f.path, bytes: f.size })),
|
||||
};
|
||||
},
|
||||
async packageRoundtrip() {
|
||||
// Reload the SAME exported package. Re-exporting into the imported sources
|
||||
// would intentionally append a new XML snapshot each round, not test leaks.
|
||||
const packaged = (roundtripPackage ??= compiledProjectPackage(
|
||||
currentManifest,
|
||||
'URDF/LeKiwi.urdf',
|
||||
adapter.exportMjcf(),
|
||||
adapter.cachedSupportFiles(),
|
||||
));
|
||||
const imported = await importBrowserFiles([
|
||||
new File([packaged.data.slice().buffer], 'complete.zip'),
|
||||
]);
|
||||
const old = session();
|
||||
await adapter.load(imported, packaged.entryPath, {
|
||||
robotProfileId: p.id,
|
||||
urdfMode: 'mjcf',
|
||||
baseMode: 'floating',
|
||||
map: { kind: 'none' },
|
||||
});
|
||||
adapter.releaseRetired();
|
||||
currentManifest = imported;
|
||||
bindings = new ModelBindings(session().model);
|
||||
return {
|
||||
...harness.visualStats(),
|
||||
oldModelDeleted: old.model.isDeleted(),
|
||||
oldDataDeleted: old.data.isDeleted(),
|
||||
};
|
||||
},
|
||||
async reimport() {
|
||||
const data = adapter.exportMjcf();
|
||||
const file = {
|
||||
|
||||
Reference in New Issue
Block a user