关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性, 重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位 (max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存 GLB 与既有测试不受影响。 13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建, 可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个 关节,槽位数与运行目录的 joint_count 逐条一致。 新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键: 坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换 案例时自动装载对应模型,失败时保留内置模型并显式提示。 消除前端五关节截断:visual 适配器的 joints 改为数组并新增 jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的 machine id 查找机型。 管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部 可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径 不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的 限位保持 null 而不编造。 修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录( DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下 第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。 验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0; typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、 双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。 证据在 coverage/glb-machine-manager/physical-drive/。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
79 lines
5.6 KiB
JavaScript
79 lines
5.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
import createModule from '../../build-wasm/linuxcnc-sim-core.js';
|
|
import { XyzacSession, CONTEXT_ID } from './xyzac-session.mjs';
|
|
import { loadSimulationCase, toWorkerConfiguration } from '../workspace/sim-case-loader.mjs';
|
|
import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs';
|
|
|
|
const root = new URL('../../', import.meta.url);
|
|
const output = new URL('coverage/glb-machine/jog-limits/', root);
|
|
await mkdir(output, { recursive: true });
|
|
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
|
|
const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] };
|
|
try {
|
|
for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
|
|
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
|
|
const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0;
|
|
const send = (kind, payload = {}, expected = null) => {
|
|
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `limit-${seq}`, payload });
|
|
if (expected) assert.equal(result.kind, expected, JSON.stringify(result.payload));
|
|
else assert.notEqual(result.kind, 'ERROR', `${kind}: ${result.payload.message_key ?? result.payload.gate_reason}`);
|
|
assert.equal(session.timeline.events.cursor, seq, `${kind}: event cursor`);
|
|
assert.equal(core._sim_event_cursor(), seq, `${kind}: native event cursor`);
|
|
return result;
|
|
};
|
|
send('HELLO'); send('SESSION_ATTACH');
|
|
const bundle = await loadSimulationCase({ path: definition.entrypoint, fetchImpl });
|
|
send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) });
|
|
send('POWER', { active: true }); send('HOME', { all: true });
|
|
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send('SIM_STEP', { cycles: 1 });
|
|
const tilt = definition.axes[3];
|
|
const drain = () => { for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 5, response: 'motion_delta' }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); };
|
|
send('MDI_EXECUTE', { block: `G21 G90 G0 X0 Y0 Z30 ${tilt.toUpperCase()}49 C20` }); drain();
|
|
send('JOG_START', { axis: tilt, direction: 1, continuous: true, increment: null });
|
|
drain();
|
|
const limit = session.wasmMotion.read().state;
|
|
assert.ok(limit.joint_commanded[3] <= 50 + 1e-8);
|
|
send('JOG_STOP');
|
|
send('JOG_START', { axis: tilt, direction: -1, increment: 1 }); drain(); send('JOG_STOP');
|
|
assert.ok(Math.abs(session.wasmMotion.read().state.joint_commanded[3] - 49) < 1e-7);
|
|
send('JOG_START', { axis: tilt, direction: 1, increment: 5 }, 'ERROR');
|
|
send('JOG_STOP');
|
|
send('SNAPSHOT_REQUEST');
|
|
const recovered = session.wasmMotion.read().state.joint_commanded;
|
|
send('MDI_EXECUTE', { block: `G90 G0 X190 Y0 Z30 ${tilt.toUpperCase()}0 C0` }); drain();
|
|
send('KINEMATICS_SELECT', { type: 1 });
|
|
send('JOG_START', { axis: 'c', direction: 1, continuous: true, increment: null });
|
|
let fault;
|
|
for (let i = 0; i < 2000; i++) {
|
|
const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind: 'SIM_STEP', seq: ++seq, operation_id: `limit-${seq}`, payload: { cycles: 5, response: 'motion_delta' } });
|
|
if (r.kind === 'ERROR') { fault = r; break; }
|
|
}
|
|
assert.equal(fault?.payload.code, 'motion_limit', JSON.stringify(fault?.payload));
|
|
assert.equal(session.timeline.events.cursor, seq);
|
|
assert.equal(core._sim_event_cursor(), seq);
|
|
assert.equal(session.timeline.events.tick, session.tick);
|
|
assert.equal(session.scheduler.tick, session.tick);
|
|
const stopped = session.wasmMotion.read().state;
|
|
assert.equal(stopped.queue_depth, 0);
|
|
assert.equal(stopped.velocity, 0, 'limit stop must publish zero velocity');
|
|
assert.ok(stopped.joint_commanded.every((value, i) => { const range = session.xyzacKinematics.options.limits[definition.axes[i]]; return value >= range[0] - 1e-8 && value <= range[1] + 1e-8; }));
|
|
send('JOG_STOP'); send('SNAPSHOT_REQUEST');
|
|
const checkpoint = session.createCheckpoint();
|
|
const restored = session.restoreCheckpoint(checkpoint);
|
|
assert.equal(restored.status, 'recovered');
|
|
send('JOG_START', { axis: 'c', direction: -1, increment: 1 }); drain(); send('JOG_STOP');
|
|
assert.ok(session.wasmMotion.read().state.position.c < stopped.position.c - .99);
|
|
// Invalid sampling requests must also consume their event at the actual
|
|
// clock, rather than reserving ticks that will never run.
|
|
send('SIM_STEP', { cycles: 1000001 }, 'ERROR');
|
|
send('SIM_STEP', { cycles: -1 }, 'ERROR');
|
|
send('SNAPSHOT_REQUEST');
|
|
send('SIM_STEP', { cycles: 1, response: 'motion_delta' });
|
|
report.machines.push({ coordinates: definition.coordinates, limit: limit.joint_commanded, recovered, rtcp_limit: stopped.joint_commanded, stopped_tick: fault.simulation_tick, completed_cycles: fault.payload.completed_cycles, seq });
|
|
}
|
|
report.status = 'verified';
|
|
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
|
|
finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); }
|
|
console.log(JSON.stringify(report));
|