关节契约扩为槽位制:以 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>
122 lines
5.7 KiB
JavaScript
122 lines
5.7 KiB
JavaScript
// 最小演示:打开本地发布站点 → 上电 → 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();
|