按关节槽位驱动 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,121 @@
// 最小演示:打开本地发布站点 → 上电 → 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();