按关节槽位驱动 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:
312
docs/user-manual/local-walkthrough.mjs
Normal file
312
docs/user-manual/local-walkthrough.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user