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

122 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// 最小演示:打开本地发布站点 → 上电 → HOME ALL → 载入并执行程序。
// 默认在桌面开可见窗口执行,逐步打印中文说明,方便人工观察。
//
// 运行:
// DISPLAY=:1 PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
// node docs/user-manual/local-run-demo.mjs
//
// 环境变量:DEMO_URL(默认 http://127.0.0.1:5174/)、DEMO_PACE_MS(每步停留,默认 2500)、
// DEMO_RUN_HOLD_MS(程序运行观察时长,默认 45000)、CHROME_PATH。
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const baseUrl = process.env.DEMO_URL ?? "http://127.0.0.1:5174/";
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const pace = Number(process.env.DEMO_PACE_MS ?? 2500);
const runHold = Number(process.env.DEMO_RUN_HOLD_MS ?? 45000);
const shotDir = resolve(here, "local-walkthrough");
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
const browser = await chromium.launch({
executablePath: chromePath,
headless: false,
args: ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage({ viewport: null });
await page.bringToFront().catch(() => {});
const wait = (ms) => page.waitForTimeout(ms);
const status = async () => (await page.locator(".machine-status").innerText()).replace(/\s+/g, " ").trim();
const command = async () => (await page.locator(".command-status").innerText()).trim();
const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
const shot = (name) => page.screenshot({ path: resolve(shotDir, name) });
const say = (step, message) => console.log(`\n[${step}] ${message}`);
const detail = async () => {
const text = await status();
return `${text.match(/Task state (\S+)/)?.[1]} / ${text.match(/Interpreter (\S+)/)?.[1]} / ${text.match(/Machine sequence (NOT HOMED|HOMED)/)?.[1]}`;
};
const waitFor = async (check, timeout, label) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await check();
if (last) return last;
await wait(400);
}
throw new Error(`${label} 超时(最后:${last || "无"})`);
};
const waitStatus = (pattern, timeout = 60000) => waitFor(async () => {
const text = await status();
return pattern.test(text) ? text : null;
}, timeout, `等待 ${pattern}`);
const waitCase = () => waitFor(async () => ((await page.locator(".case-status").innerText()).trim().startsWith("Program:") ? "ok" : null), 120000, "程序装载");
say(1, `打开网站 ${baseUrl}`);
await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 });
await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await waitCase();
await waitStatus(/STATE_/, 60000);
await wait(2000);
console.log(` 连接:${(await page.locator(".connection-state").innerText()).trim()}|当前程序:${(await page.locator(".case-status").innerText()).trim()}|状态:${await detail()}`);
await shot("demo-1-opened.png");
say(2, "机床尚未上电,确认运动操作被门控挡住");
const jogButton = page.locator('[data-axis-control="manual-jog-positive"]');
console.log(` Jog 按钮:${(await jogButton.isDisabled()) ? `禁用(${await jogButton.getAttribute("title")})` : "可用"}|任务模式:${(await status()).match(/Task mode (\S+)/)?.[1]}`);
await wait(pace);
say(3, "急停复位,然后上电");
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await wait(1500); }
await page.locator('[data-axis-control="toolbar-power"]').click();
const powered = await waitStatus(/STATE_ON|STATE_IDLE/, 40000);
console.log(` 上电完成:${powered.match(/Task state (\S+)/)?.[1]}|反馈:${await command()}`);
await shot("demo-2-powered.png");
await wait(pace);
say(4, "HOME ALL 全部关节回零");
await page.locator('[data-axis-control="manual-home-all"]').click();
await waitStatus(/Machine sequence HOMED/, 60000);
await wait(1500);
console.log(` 回零完成:${await detail()}|轴位:${(await axes()).join(" / ")}`);
await shot("demo-3-homed.png");
await wait(pace);
say(5, "载入 XYZAC 船体程序");
await page.locator('[data-axis-control="workspace-load-boat"]').click();
await waitCase();
await wait(3000);
console.log(` 程序:${(await page.locator(".case-status").innerText()).trim()}|反馈:${await command()}`);
await shot("demo-4-program-loaded.png");
await wait(pace);
say(6, "执行程序,观察解释器与轴运动");
await page.locator('[data-axis-control="toolbar-run"]').click();
await waitStatus(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
console.log(` 运行中:${await detail()}`);
const samples = [];
const holdUntil = Date.now() + runHold;
let index = 0;
while (Date.now() < holdUntil) {
await wait(3000);
const current = await axes();
samples.push(current.slice(0, 5).join(" / "));
index += 1;
console.log(` +${String(index * 3).padStart(2)}s ${current.slice(0, 5).join(" ")}`);
if (index % 3 === 0) await shot(`demo-5-running-${index / 3}.png`);
}
console.log(` 轴运动采样(X / Y / Z / A / C):\n ${samples.join("\n ")}`);
await shot("demo-6-running-final.png");
say(7, "停止程序");
if (await page.locator('[data-axis-control="toolbar-stop"]').isEnabled()) await page.locator('[data-axis-control="toolbar-stop"]').click();
await wait(3000);
console.log(` 已停止:${await detail()}|反馈:${await command()}`);
await shot("demo-7-stopped.png");
console.log("\n演示结束,窗口保持 20 秒后关闭。");
await wait(20000);
await browser.close();