Files
linuxcncWebWASM/docs/user-manual/local-walkthrough.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

313 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 按"使用说明书"的操作顺序,在本地发布站点上完整走一遍标准使用流程,并逐步取证。
//
// 运行:
// 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);