Files
linuxcncWebWASM/web/worker/test_trt_configuration.mjs
wangdequan db29ae625f 按关节槽位驱动 13 个五轴案例的三维模型
关节契约扩为槽位制:以 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>
2026-10-30 06:09:12 +08:00

99 lines
7.2 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 { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { readTrtConfiguration } from "../workspace/trt-configuration.mjs";
import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const root = new URL("../../", import.meta.url);
const fetchImpl = async (url) => {
try { const source = await readFile(new URL(String(url).replace(/^\//, ""), root), "utf8"); return { ok: true, text: async () => source }; }
catch { return { ok: false }; }
};
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
function edit(bundle, values) {
let section = "";
const content = bundle.files[bundle.entrypoint].split("\n").map((line) => {
const heading = /^\s*\[([^\]]+)\]/.exec(line); if (heading) section = heading[1];
const assignment = /^\s*([A-Z_]+)\s*=/.exec(line);
const value = values[`${section}.${assignment?.[1]}`];
return value === undefined ? line : `${assignment[1]} = ${value}`;
}).join("\n");
return { ...bundle, files: { ...bundle.files, [bundle.entrypoint]: content } };
}
const rows = [];
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const original = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const modified = edit(original, { "AXIS_X.MAX_VELOCITY": 4, "AXIS_X.MAX_ACCELERATION": 8, "AXIS_X.MAX_LIMIT": 15, "JOINT_0.MAX_LIMIT": 17, "TRAJ.MAX_LINEAR_VELOCITY": 13, "EMCMOT.SERVO_PERIOD": 2000000, "TASK.CYCLE_TIME": .02, "DISPLAY.MAX_FEED_OVERRIDE": 1.3 });
const config = toWorkerConfiguration(modified);
const zeroTolerance = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[WEB_RUNTIME]\nSPINDLE_TOLERANCE = 0\n` } };
assert.equal(readTrtConfiguration(zeroTolerance).kinematics.io.spindle_tolerance, 0);
const blendingCase = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[TRAJ]\nARC_BLEND_ENABLE = 1\nARC_BLEND_OPTIMIZATION_DEPTH = 25\nARC_BLEND_GAP_CYCLES = 3\nARC_BLEND_RAMP_FREQ = 50\nARC_BLEND_TANGENT_KINK_RATIO = 0.2\n` } };
const configuredBlend = readTrtConfiguration(blendingCase).kinematics.blend;
assert.equal(configuredBlend.optimization_depth, 25);
assert.equal(readTrtConfiguration(edit(original, { "TRAJ.MAX_LINEAR_ACCELERATION": 8 })).kinematics.max_acceleration, 8);
for (const declaration of ["SPINDLE_COUNT = 2.5", "TOOL_CAPACITY = 1002", "ENCODER_COUNT = 9"]) {
const invalid = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[WEB_RUNTIME]\n${declaration}\n` } };
assert.throws(() => readTrtConfiguration(invalid), /trt_configuration_io_capacity/);
}
assert.deepEqual(config.home_limits.x, [-200, 17]);
assert.deepEqual(config.kinematics.axis_limits.x, [-200, 15]);
assert.equal(config.kinematics.axis_velocity_limits.x, 4);
for (const values of [{ "AXIS_X.MAX_VELOCITY": "NaN" }, { "AXIS_X.MAX_ACCELERATION": 0 }, { "JOINT_0.MAX_LIMIT": -300 }, { "KINS.JOINTS": 4 }, { "TASK.CYCLE_TIME": .0015 }, { "DISPLAY.MAX_FEED_OVERRIDE": .5 }]) assert.throws(() => readTrtConfiguration(edit(original, values)), /trt_configuration/);
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: `configuration-${machine.id}` }); let seq = 0;
const send = (kind, payload = {}, expected = "ok") => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `cfg-${seq}`, payload });
if (expected === "error") assert.equal(result.kind, "ERROR");
else assert.notEqual(result.kind, "ERROR", `${kind}: ${JSON.stringify(result.payload)}`);
return result;
};
send("HELLO"); send("SESSION_ATTACH");
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(original) });
// Existing adapter values are untrusted duplicates: the files must win.
config.home_limits.x = [-1, 1]; config.kinematics.max_linear_velocity = 99;
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: config });
assert.equal(session.wasmMotion.velocityLimit, 13);
assert.equal(session.wasmMotion.cycleTime, .002);
assert.equal(session.wasmHomeLimit.period, .002);
assert.equal(session.xyzacTaskCycleTicks, 10);
assert.equal(readTrtConfiguration(modified).kinematics.io.cycle_time, .02);
assert.equal(session.wasmMotion.jogVelocity("x"), 4);
assert.deepEqual(session.xyzacKinematics.snapshot().limits.x, [-200, 17]);
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 });
assert.ok(session.wasmHomeLimit.read().state.homed);
send("SET_OVERRIDE", { feed: 1.31 }, "error"); send("SET_OVERRIDE", { feed: 1.3 }); send("SET_OVERRIDE", { feed: 1 });
send("JOG_START", { axis: "x", direction: 1, increment: 16 }, "error");
send("MDI_EXECUTE", { block: "G21 G90 G1 X10 F1200" });
let maxVelocity = 0, maxAcceleration = 0, previousVelocity = 0;
const trace = [];
for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth > 0; i++) {
send("SIM_STEP", { cycles: 1 });
const state = session.wasmMotion.read().state;
maxVelocity = Math.max(maxVelocity, state.velocity);
maxAcceleration = Math.max(maxAcceleration, Math.abs(state.velocity - previousVelocity) / .02);
previousVelocity = state.velocity;
trace.push({ tick: session.tick, x: state.position.x, velocity: state.velocity });
}
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
assert.ok(maxVelocity > 3.9 && maxVelocity <= 4.000001, `${machine.id} velocity ${maxVelocity}`);
assert.ok(maxAcceleration <= 8.000001, `${machine.id} acceleration ${maxAcceleration}`);
assert.ok(Math.abs(session.wasmMotion.read().state.position.x - 10) < 1e-8);
rows.push({ machine: machine.id, max_velocity: maxVelocity, max_acceleration: maxAcceleration, period: session.wasmMotion.cycleTime, joint_limit: session.xyzacKinematics.snapshot().limits.x, trace });
const checkpoint = session.createCheckpoint();
send("POWER", { active: false });
const newSpeed = toWorkerConfiguration(edit(modified, { "AXIS_X.MAX_VELOCITY": 5 }));
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: newSpeed });
const restored = session.restoreCheckpoint(checkpoint);
assert.equal(restored.status, "blocked");
assert.equal(restored.blocked[0].reason, "trt_recovery_configuration_mismatch");
assert.equal(session.wasmMotion.jogVelocity("x"), 5);
}
const report = { report_id: "trt-configuration-v1", status: "verified", rows };
await mkdir(new URL("coverage/configuration/", root), { recursive: true });
await writeFile(new URL("coverage/configuration/trt-runtime.report.json", root), JSON.stringify({ ...report, state_hash: fnv1a64(report) }, null, 2) + "\n");
console.log(JSON.stringify({ status: report.status, report_id: report.report_id, rows: rows.map(({ trace, ...row }) => row) }));