按关节槽位驱动 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>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -0,0 +1,312 @@
// 按"使用说明书"的操作顺序,在本地发布站点上完整走一遍标准使用流程,并逐步取证。
//
// 运行:
// PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
// node docs/user-manual/local-walkthrough.mjs
//
// 环境变量:WALKTHROUGH_URL(默认 http://127.0.0.1:5174/)、CHROME_PATH。
// WALKTHROUGH_HEADED=1 在桌面打开可见窗口执行(便于人工观察)
// WALKTHROUGH_PACE_MS 每一步之间的停留时间,默认可见模式 1500ms、无头模式 0
// WALKTHROUGH_HOLD_MS 结束前保持窗口的时间,默认可见模式 20000ms、无头模式 0
// 输出:docs/user-manual/local-walkthrough/(截图 + 分步报告 JSON)
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const outDir = resolve(here, "local-walkthrough");
const baseUrl = process.env.WALKTHROUGH_URL ?? "http://127.0.0.1:5174/";
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const headed = process.env.WALKTHROUGH_HEADED === "1";
const pace = Number(process.env.WALKTHROUGH_PACE_MS ?? (headed ? 1500 : 0));
const hold = Number(process.env.WALKTHROUGH_HOLD_MS ?? (headed ? 20000 : 0));
await mkdir(outDir, { recursive: true });
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
const browser = await chromium.launch({
executablePath: chromePath,
headless: !headed,
args: headed
? ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"]
: ["--no-sandbox", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage({ viewport: headed ? null : { width: 1440, height: 900 } });
await page.bringToFront().catch(() => {});
const consoleErrors = [];
page.on("pageerror", (error) => consoleErrors.push(String(error)));
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
const steps = [];
const findings = [];
const settle = (ms = 500) => page.waitForTimeout(ms);
const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
const status = () => text(".machine-status");
const command = () => text(".command-status");
const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
const caseStatus = async () => (await page.locator(".case-status").innerText()).trim();
const loadGateState = async () => {
const button = page.locator('[data-axis-control="workspace-load-boat"]');
return (await button.isDisabled()) ? `blocked:${await button.getAttribute("title")}` : "enabled";
};
const machineSequence = async () => (await status()).match(/Machine sequence (NOT HOMED|HOMED)/)?.[1] ?? "-";
const waitFor = async (predicate, timeout, label) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await predicate();
if (last) return last;
await page.waitForTimeout(400);
}
throw new Error(`${label} 超时,最后观察:${String(last).slice(0, 160)}`);
};
const waitStatus = async (pattern, timeout = 60000) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await status();
if (pattern.test(last)) return last;
await page.waitForTimeout(400);
}
throw new Error(`等待 ${pattern} 超时,最后状态:${last.slice(0, 160)}`);
};
const waitCase = (timeout = 120000) => waitFor(async () => {
const value = await caseStatus();
return value.startsWith("Program:") ? value : null;
}, timeout, "案例程序就绪");
const record = async (index, title, body) => {
const started = Date.now();
console.log(`\n▶ 第 ${index} 步 ${title}`);
try {
const detail = await body();
const shot = `step-${String(index).padStart(2, "0")}.png`;
await page.screenshot({ path: resolve(outDir, shot) });
steps.push({ index, title, status: "ok", ms: Date.now() - started, detail, screenshot: shot });
console.log(` ✔ 完成${detail ? ` — ${JSON.stringify(detail)}` : ""}`);
} catch (error) {
const shot = `step-${String(index).padStart(2, "0")}-failed.png`;
await page.screenshot({ path: resolve(outDir, shot) }).catch(() => {});
steps.push({ index, title, status: "failed", ms: Date.now() - started, detail: String(error?.message ?? error), screenshot: shot });
console.log(` ✘ 失败 — ${error?.message ?? error}`);
}
if (pace) await page.waitForTimeout(pace);
};
console.log(`本地发布站点使用流程演练:${baseUrl}\n`);
console.log(headed ? "模式:桌面可见窗口(可以看着它操作)" : "模式:无头浏览器");
// 1. 打开页面,等待 WASM 会话就绪
await record(1, "打开页面并等待仿真会话就绪", async () => {
await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 });
await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await waitCase(90000);
await waitStatus(/STATE_/, 60000);
await settle(2000);
return {
标题: await page.title(),
连接: (await text(".connection-state")),
默认案例: await page.locator('[data-axis-control="workspace-case-select"]').inputValue().then((v) => v.split("/").pop()),
当前程序: await caseStatus(),
可渲染画布: await page.locator("canvas").count(),
};
});
// 2. 上电前:确认门控把运动操作锁住
await record(2, "上电前确认门控:手动与运动操作被锁", async () => {
const jog = page.locator('[data-axis-control="manual-jog-positive"]');
const mdi = page.locator('[data-axis-control="mdi-command"]');
return {
任务状态: (await status()).match(/Task state (\S+)/)?.[1],
回零状态: await machineSequence(),
装载程序按钮: await loadGateState(),
Jog按钮: await jog.isDisabled() ? `禁用(${await jog.getAttribute("title")})` : "可用",
MDI输入: await mdi.isDisabled() ? "禁用" : "可用",
};
});
// 3. 急停复位 + 上电
await record(3, "急停复位并上电", async () => {
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
const reset = await status();
await page.locator('[data-axis-control="toolbar-power"]').click();
const on = await waitStatus(/STATE_ON|STATE_IDLE/, 40000);
return { 复位后: reset.match(/Task state (\S+)/)?.[1], 上电后: on.match(/Task state (\S+)/)?.[1] };
});
// 4. 回零
await record(4, "HOME ALL 全部关节回零", async () => {
await page.locator('[data-axis-control="manual-home-all"]').click();
await waitStatus(/Machine sequence HOMED/, 60000);
await settle(1500);
return { 回零状态: await machineSequence(), 轴位: await axes() };
});
// 5. 连续 Jog
await record(5, "手动连续 Jog:选中 X 轴,按住 + 移动,松手停止", async () => {
await page.locator('[data-axis-control="manual-axis-x"]').check();
const before = (await axes())[0];
await page.locator('[data-axis-control="manual-jog-positive"]').hover();
await page.mouse.down();
await settle(1800);
await page.mouse.up();
await settle(1500);
const moved = (await axes())[0];
await settle(1500);
const stopped = (await axes())[0];
return { 移动前: before, 松开瞬间: moved, "1.5s后": stopped, 结论: moved !== before && stopped === moved ? "移动后停止" : "需复核" };
});
// 6. 增量 Jog
await record(6, "增量 Jog:0.1000 步长点动一次", async () => {
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000");
const before = Number((await axes())[0]);
await page.locator('[data-axis-control="manual-jog-negative"]').hover();
await page.mouse.down();
await settle(1200);
await page.mouse.up();
await settle(2000);
const after = Number((await axes())[0]);
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous");
return { 步长: "0.1000 mm", 前: before, 后: after, 实际位移: Number((before - after).toFixed(4)) };
});
// 7. MDI
await record(7, "MDI 执行 G0 X12.5 Y-4 Z0", async () => {
await page.locator('[data-axis-control="tab-mdi"]').click();
await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0");
await page.locator('[data-axis-control="mdi-submit"]').click();
await settle(3500);
return { 命令: "G0 X12.5 Y-4 Z0", 轴位: await axes(), 反馈: await command(), 历史: await page.locator('[data-axis-control="mdi-history-select"]').first().innerText() };
});
// 8. 载入案例自带的五轴程序
await record(8, "MDI 之后切回手动模式(否则装载门控保持失效)", async () => {
const afterMdi = await loadGateState();
const modeBefore = (await status()).match(/Task mode (\S+)/)?.[1];
await page.locator('[data-axis-control="tab-manual"]').click();
await settle(3000);
const afterManual = await loadGateState();
if (afterMdi.startsWith("blocked:xyzac_motion_active") && afterManual === "enabled") {
findings.push({
id: "FINDING-01",
severity: "medium",
control: "MDI 之后的装载门控",
observed: `MDI 执行完成后(Planner queued=0、Task mode=${modeBefore}),"Load XYZAC boat program" 仍被拒绝,提示 ${afterMdi};此时必须切回手动模式或刷新页面才能载入程序`,
expectation: "MDI 运动结束、队列排空后装载程序应当自动恢复可用",
workaround: "点一下左侧 Manual Control [F3] 页签(发出一条模式命令刷新门控),或刷新页面",
});
}
return { MDI后: afterMdi, 切回手动后: afterManual, 任务模式: (await status()).match(/Task mode (\S+)/)?.[1] };
});
// 9. 载入案例自带的五轴程序
await record(9, "载入 XYZAC 船体程序并生成刀路", async () => {
await page.locator('[data-axis-control="tab-preview"]').click();
await page.locator('[data-axis-control="workspace-load-boat"]').click();
await waitCase();
await settle(3000);
return { 程序: await caseStatus(), 反馈: await command() };
});
// 10. 运行程序并采样轴运动
await record(10, "运行程序,观察解释器与轴运动", async () => {
await page.locator('[data-axis-control="toolbar-run"]').click();
await waitStatus(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
const samples = [];
for (let i = 0; i < 4; i += 1) {
await settle(1200);
samples.push((await axes()).slice(0, 4).map(Number));
}
return { 任务模式: (await status()).match(/Task mode (\S+)/)?.[1], 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 轴位采样: samples };
});
// 11. 暂停并确认位置冻结
await record(11, "暂停程序,确认位置冻结", async () => {
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
await waitStatus(/PAUSED|INTERP_PAUSED/, 30000);
const first = await axes();
await settle(2000);
const second = await axes();
return { 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 首次读数: first, "2s后读数": second, 位置冻结: JSON.stringify(first) === JSON.stringify(second) };
});
// 12. 继续并停止
await record(12, "继续运行后停止程序", async () => {
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
await settle(2500);
const resumed = (await status()).match(/Interpreter (\S+)/)?.[1];
await page.locator('[data-axis-control="toolbar-stop"]').click();
await settle(3000);
return { 继续后解释器: resumed, 停止后解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 反馈: await command() };
});
// 13. DRO 读数
await record(13, "查看 DRO 位置显示并切换单位", async () => {
await page.locator('[data-axis-control="tab-dro"]').click();
await page.locator('[data-axis-control="dro-units"]').selectOption("inch");
await settle(1200);
const inch = await page.locator(".dro-row").allInnerTexts();
await page.locator('[data-axis-control="dro-units"]').selectOption("mm");
await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative");
await settle(1200);
return { 行数: inch.length, 英寸读数示例: inch[0]?.replace(/\s+/g, " "), 页脚: await text(".dro-footer") };
});
// 14. 切换机型
await record(14, "切换到 XYZBC TRT 机型,轴组随之改变", async () => {
await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini");
await settle(2500);
await waitCase();
await settle(2500);
const bc = await page.locator(".axis-radio span").allInnerTexts();
await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini");
await settle(2500);
return { XYZBC轴组: bc, 当前程序: await caseStatus() };
});
// 15. 打开使用说明书
await record(15, "从标题栏按钮打开使用说明书", async () => {
const [manual] = await Promise.all([page.waitForEvent("popup"), page.locator('[data-axis-control="user-manual"]').click()]);
await manual.waitForLoadState("load", { timeout: 60000 });
await manual.waitForTimeout(1500);
const info = { 标题: await manual.title(), 地址: manual.url(), 章节: await manual.locator("section").count(), 插图: await manual.locator("figure img").count() };
await manual.screenshot({ path: resolve(outDir, "step-15-manual.png") });
await manual.close();
return info;
});
// 16. 诊断页
await record(16, "查看诊断页的启动资源与门控", async () => {
await page.locator('[data-axis-control="tab-diagnostics"]').click();
await settle(1200);
const body = await text("#axis-panel-diagnostics");
return { 摘要: body.slice(0, 220) };
});
const report = {
report_id: "LINUXCNC-WEB-LOCAL-WALKTHROUGH",
url: baseUrl,
ran_at: new Date().toISOString(),
status: steps.every((step) => step.status === "ok") ? "verified" : "failed",
steps,
findings,
console_errors: consoleErrors,
};
await writeFile(resolve(outDir, "walkthrough.report.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`\n${report.status.toUpperCase()} ${steps.filter((s) => s.status === "ok").length}/${steps.length} 步完成 → ${resolve(outDir, "walkthrough.report.json")}`);
if (consoleErrors.length) console.log("页面错误:", consoleErrors.slice(0, 3));
if (findings.length) console.log(`\n发现问题 ${findings.length} 项:`);
for (const item of findings) console.log(` ${item.id}|${item.control}|${item.observed}`);
if (hold) {
console.log(`演练结束,窗口保持 ${Math.round(hold / 1000)} 秒供查看…`);
await page.waitForTimeout(hold);
}
await browser.close();
process.exit(report.status === "verified" ? 0 : 1);