Files
linuxcncWebWASM/scripts/generate_panel_png_guide.mjs

184 lines
23 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.
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const modulePath = process.env.PLAYWRIGHT_MODULE ?? "/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js";
const imported = await import(modulePath);
const { chromium } = imported.default ?? imported;
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
const out = resolve("docs/web-panel-png-guide");
await mkdir(out, { recursive: true });
const entries = [];
const add = (id, name, detail) => entries.push({ id, name, detail, number: entries.length + 1 });
add("menu-file", "File 文件菜单", "展开文件菜单。当前包含工具表编辑和退出入口,二者受浏览器运行边界限制。");
add("menu-help", "Help 帮助菜单", "展开帮助菜单,可打开 About 信息窗口;不向机床发送运动命令。");
add("toolbar-estop", "急停 / 解除急停 · F1", "按当前状态切换急停。触发急停会中止当前执行和运动;解除后仍需单独上电。F1 在文本输入时也有效。");
add("toolbar-power", "机床电源 · F2", "在解除急停后上电;再次点击断电。断电中止运动和任务。上电不会自动回零,也不会自动执行程序。");
add("toolbar-open", "打开 G-code · o", "从本机选择程序文件并通过 Worker/WASM 加载、解析预览。执行活动或加载未结束时不可替换程序;取消选择不提交。");
add("toolbar-load-case", "重新加载 XYZAC 案例", "重新载入选定 INI、HAL 等配置及其 OPEN_FILE 程序。这是 Web 工作区入口;不会自动上电、回零或运行。");
add("toolbar-reload", "重新加载当前程序 · Ctrl+r", "将编辑区当前文本重新提交给解释器并生成静态预览。要求已有文件且允许加载;保留已有 Live Plot。");
add("toolbar-run", "执行程序 · r", "切换 AUTO 并从第 0 行开始执行当前文件。任务层检查上电、回零等条件;完成或 Stop 后可直接再次 Run。");
add("toolbar-step", "单步 · t", "执行下一处程序/运动边界。首次单步启动后暂停;已有运动队列时按执行边界推进,不等于每次恰好执行一行文本。");
add("toolbar-pause-resume", "暂停 / 恢复", "同一按钮按当前状态切换 Pause 与 Resume,保留程序和运动队列。暂停仍属于活动任务,不能据此进行 Jog 或 Home。");
add("toolbar-stop", "停止程序 · Esc", "中止当前任务及运动,保留所选程序。按钮按任务状态启用;Esc 也可停止手动运动。停止后重新 Run 从头开始。");
add("toolbar-block-delete", "跳过 / 程序段 · 未实现", "原生功能用于跳过带斜杠的程序段。当前 WASM 未暴露完整状态,按钮保持禁用,不是本地开关。");
add("toolbar-optional-pause", "可选停止 · 未实现", "原生功能控制是否在 M1 处停止。当前 ABI 未提供完整命令和状态,按钮保持禁用。");
add("toolbar-zoom-in", "放大预览", "增大当前预览的相机缩放;仅改变显示,不改变进给、机床坐标或程序内容。");
add("toolbar-zoom-out", "缩小预览", "减小预览的相机缩放,用于查看更大范围;不改变实际运动。");
for (const [id, name, detail] of [
["z", "Z 顶视图", "正交投影,屏幕右/上对应 X/Y。"], ["z2", "Z2 旋转顶视图", "顶视图在屏幕平面旋转,右/上对应 Y/-X。"],
["x", "X 侧视图", "正交投影,右/上对应 Y/Z。"], ["y", "Y 正视图", "正交投影,右/上对应 X/Z。"],
["y2", "Y2 反向正视图", "原生工具栏附加方向,右/上对应 Z/X。"], ["p", "P 透视图", "三维透视观察刀路;可配合拖动、平移和缩放查看。"],
]) add(`toolbar-view-${id}`, name, `${detail} 点击后显示 Preview 页;不改变机床状态。`);
add("toolbar-rotate", "拖动 / 旋转模式", "切换鼠标左键拖动时的平移/旋转行为。按钮按下不代表自动旋转,也不控制旋转轴运动。");
add("toolbar-clear-live-plot", "清除加工轨迹 · Ctrl+K", "仅清除 Live Plot 历史,保留静态程序预览。运行中清除后继续积累新轨迹;不会停止、重载或重启程序。");
add("workspace-case-select", "仿真案例选择", "当前目录限定为支持的 XYZAC TRT 案例。选择本身不执行加载;重新载入使用工具栏的案例加载按钮。");
add("workspace-load-3d-chips", "加载 3D_Chips.ngc", "打开随项目提供的示例程序,走同一 WASM 加载和预览流程。保留 Live Plot,执行前需另按 Run。");
add("switchkins-0", "Identity · M429", "选择本案例 active_type=0 的恒等运动学。要求上电、已回零且无活动队列;M429 不会把模块 KINEMATICS_BOTH 类型改为 IDENTITY。");
add("switchkins-1", "TCP: XYZAC · M428", "选择 active_type=1 的 XYZAC TRT 刀尖运动学,使用当前几何偏置进行变换。切换需任务空闲且运动队列为空。");
add("switchkins-2", "User K · M430", "选择 active_type=2 的已链接用户运动学;当前为 identity 模板,不表示可在浏览器动态加载任意运动学模块。");
add("tab-manual", "Manual Control · F3", "显示手动控制页,并在源门控允许时请求对应模式。AUTO 运行或暂停时,仅切换可见页签不会强制变为 MANUAL。");
add("tab-mdi", "MDI · F5", "显示单条指令输入页,并在允许时请求 MDI 模式。提交指令仍受任务层门控约束。");
add("tab-diagnostics", "Diagnostics 诊断", "查看工作区资源、禁用原因、操作记录和错误信息。浏览此页本身不执行运动或恢复操作。");
add("manual-home-joint-select", "回零关节选择", "选择数字关节 0–4,仅决定 Home Joint 的目标。与下面 X/Y/Z/A/C 世界轴选择相互独立。");
add("manual-home-axis", "Home Joint 单关节回零", "对选定数字关节运行原生 homemod。已回零时先确认;回零完成以 homing/homed 状态为准。不是 G53 返回机床零点。");
add("manual-home-all", "HOME ALL / RE-HOME ALL", "按 INI HOME_SEQUENCE 发起全部关节回零。当前案例搜索/锁存速度为 0,可能在当前位置建立参考点而不产生电机位移。");
for (const axis of ["x", "y", "z", "a", "c"]) add(`manual-axis-${axis}`, `选择 ${axis.toUpperCase()} 轴`, `选择世界坐标 ${axis.toUpperCase()} 作为 Jog、Touch Off 目标。选择本身不移动机床,也不修改回零关节选择。${["a", "c"].includes(axis) ? "旋转轴以度为单位。" : "线性轴按 mm/inch 显示单位处理。"}`);
add("manual-jog-negative", "Jog − 负向点动", "沿所选轴负方向运动。Continuous 按住运动、释放/失焦停止;增量模式提交一次指定距离,松开不截断该增量。");
add("manual-jog-positive", "Jog + 正向点动", "沿所选轴正方向运动,使用当前步长和 Jog Speed。手动页可见且任务允许时生效;运行、暂停或回零期间不可开始点动。");
add("manual-jog-increment", "点动步长", "Continuous 为持续点动;0.1000/0.0100/0.0010/0.0001 为单次增量。线性值遵循 mm/inch,A/C 不做英寸换算。");
add("manual-touch-off", "Touch Off 坐标系对刀", "输入当前点应显示的坐标,再选择 P0 当前或 P1–P9 坐标系。通过 G10 L20 改偏置,不移动机床;取消对话框不执行。");
add("manual-tool-touch-off", "Tool Touch Off · 未实现", "原生为刀具偏置测定,涉及 G10 L10/L11 和 G43。当前 ABI 不完整,禁用;不能用普通 Touch Off 冒充。");
add("manual-override-limits", "Override Limits · 未实现", "原生用于限位触发后的脱离操作。当前面板没有对应完整状态/命令边界,禁用。");
add("manual-spindle-ccw", "主轴逆时针", "发送主轴反转方向命令,选中状态来自 WASM 主轴方向。需上电、任务空闲;不同于 A/C 旋转轴点动。");
add("manual-spindle-stop", "Stop Spindle", "停止主轴旋转。仅控制主轴,不等于停止正在执行的程序;程序停止使用工具栏 Stop。");
add("manual-spindle-cw", "主轴顺时针", "发送主轴正转方向命令,运行方向和速度由规范快照回显;按钮不会设置本地伪运行状态。");
add("manual-spindle-decrease", "降低主轴速度", "主轴方向非零时启用。按下发送减速边沿,释放发送 SPINDLE_CONSTANT;不会减速穿过零后自动变成反转。");
add("manual-spindle-increase", "提高主轴速度", "主轴方向非零时启用。按下发送增速边沿,释放结束调速。主轴倍率与该指令速度分开处理。");
add("manual-spindle-brake", "Brake 主轴制动", "请求接合或释放主轴制动,复选框由 WASM 制动状态回显。受手动操作门控约束。");
add("manual-coolant-mist", "Mist 雾化冷却", "独立开关雾化冷却。手动页需上电且解释器空闲;与 Flood 相互独立,可同时开启。");
add("manual-coolant-flood", "Flood 浇注冷却", "独立开关浇注冷却;状态来自 IO 快照,不因界面勾选而提前认定成功。");
add("tab-preview", "Preview 刀路页", "查看静态程序刀路、加工轨迹和刀具位置。切换显示页不会加载文件、改变模式或发起运动。");
add("tab-dro", "DRO 数显页", "切换至坐标数显与坐标/单位选择;其值来自 Worker 快照。不是另一个轴位置输入入口。");
for (const [id, name, text] of [["panLeft", "L 向左平移", "向左平移相机观察中心"], ["panRight", "R 向右平移", "向右平移相机观察中心"], ["panUp", "U 向上平移", "向上平移相机观察中心"], ["panDown", "D 向下平移", "向下平移相机观察中心"], ["reset", "0 重置视图", "按当前预置视角和缩放重新取景"]]) add(`preview-${id}`, name, `${text}。仅操作画布;“0”不会回零机床。`);
add("override-jog-speed", "Jog Speed 点动速度", "调整之后点动使用的速度比例,当前范围 6%–100%。与程序进给倍率不同;滑块本身不发起运动。");
add("override-feed", "Feed Override 进给倍率", "按当前 INI 调整 0%–200% 程序进给倍率,可在自动运行时修改。不是修改 G-code 中的 F 值;快速倍率独立。");
add("override-spindle", "Spindle Override 主轴倍率", "调整主轴输出速度比例。设为 0% 不会丢失方向和原指令速度,恢复倍率后按原方向输出。");
add("override-max-velocity", "Max Velocity 最大速度", "调整轨迹规划器最大速度比例,当前为 6%–100% 适配。它是速度上限,不能代替 Rapid Override。");
add("override-rapid", "Rapid Override · 未实现", "原生为快速运动倍率。当前选定 WASM TP 没有完整 rapidrate 控制,滑块禁用,不借用 Max Velocity 模拟。");
for (const [id, name, detail] of [["path", "静态刀路", "显示/隐藏当前程序解析得到的静态路径"], ["live", "加工轨迹", "显示/隐藏已采样 Live Plot,隐藏不会删除轨迹"], ["limits", "机床限位", "显示/隐藏 WASM 提供的 XYZ 软限位框"], ["velocity", "运动速度", "显示/隐藏预览中的当前规范速度文字"], ["offsets", "坐标偏置", "显示/隐藏当前已支持的 G5x 平移偏置指示"], ["extents", "程序范围", "显示/隐藏规范刀路样本的包围范围"]]) add(`preview-layer-${id}`, `${id} · ${name}`, `${detail}。只改变显示,不改变程序、偏置或运动状态。`);
add("program-editor", "G-code 编辑区", "修改待提交程序文本。修改后必须通过 Reload 重新提交才影响解释器;编辑文字不等于已经加载或执行。");
add("mdi-command", "MDI 指令输入", "输入单条 G-code/M-code;Enter 与 Go 使用同一提交路径。提交会去除首尾空白,空输入不发送;执行受 manual_ok 与任务门控限制。");
add("mdi-submit", "Go 执行 MDI", "将输入指令提交给 WASM 解释器。可能产生真实仿真运动或 IO 动作;收到 ACK 不代表排队运动已经完成。");
add("mdi-history-select", "MDI 历史条目", "单击将该条指令填回输入框,双击重新提交执行。上下方向键浏览历史,相邻重复记录去重;历史并非执行成功证明。");
add("dro-coordinate", "Machine / Relative", "切换机床坐标与相对工件坐标显示。当前相对投影支持已实现的 G5x 平移偏置,不用显示切换改写运动目标。");
add("dro-display", "Actual / Commanded", "切换实际与指令位置的显示语义。当前理想仿真两者可相同,不能据标签推断已模拟真实跟随误差。");
add("dro-units", "mm / inch", "切换线性坐标显示单位,A/C 仍为角度。该设置也用于界面点动和对刀输入的线性单位换算,不改写原程序。");
add("menu-file-edit-tool-data", "Edit tool data… · 未实现", "桌面原生会启动工具表编辑器,浏览器没有对应宿主编辑流程,因此该菜单项禁用。");
add("menu-file-quit", "Quit · 未实现", "桌面原生用于结束 AXIS 进程。Web 不模拟结束机床进程,此项禁用;可由浏览器关闭页面。");
add("menu-help-about", "About AXIS", "显示本地 About 窗口,查看应用名称与案例信息;不会改变 Worker、任务或 IO 状态。");
add("about-close", "About 窗口 OK", "关闭 About 窗口并返回原界面。属于界面操作,不等于命令确认、复位或启动。");
add("program-file-input", "文件选择对话框入口", "隐藏的浏览器文件输入,由编号 05“打开 G-code”触发。选择 .ngc/.nc/.gcode 等文件后走同一加载事务;没有第二个可见按钮。");
assert.equal(entries.length, 83);
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const covered = new Set();
const escape = (s) => String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
const browser = await chromium.launch({ headless: false, executablePath: "/usr/bin/google-chrome", slowMo: 90 });
const page = await browser.newPage({ viewport: { width: 1600, height: 1100 }, deviceScaleFactor: 2 });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("dialog", (dialog) => dialog.dismiss());
async function capture(selector, title, ids) {
const locator = page.locator(selector);
await locator.scrollIntoViewIfNeeded();
await page.waitForTimeout(180);
const bounds = await locator.boundingBox();
const marks = [];
for (const id of ids) {
assert.ok(byId.has(id), `unknown id ${id}`);
const control = locator.locator(`[data-axis-control="${id}"]`);
if (!await control.count()) continue;
if (!await control.first().isVisible()) continue;
const box = await control.first().boundingBox();
marks.push({ ...byId.get(id), x: box.x - bounds.x, y: box.y - bounds.y, w: box.width, h: box.height });
covered.add(id);
}
const image = await locator.screenshot();
return { title, image: image.toString("base64"), width: bounds.width, height: bounds.height, marks };
}
const ids = (prefix) => entries.filter((e) => e.id.startsWith(prefix)).map((e) => e.id);
const docs = [];
try {
await page.goto(url);
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
await page.locator('[data-axis-control="toolbar-power"]').click();
await page.locator('[data-axis-control="manual-home-all"]').click();
await page.waitForFunction(() => document.querySelector(".command-status")?.textContent === "HOME complete");
await page.evaluate(() => scrollTo(0, 0));
await page.screenshot({ path: `${out}/00-panel-overview-hd.png`, fullPage: true });
docs.push({ file: "01-toolbar-and-workspace", title: "工具栏与文件操作", note: "截图状态:XYZAC TRT 已上电、已回零、程序空闲。灰色按钮须结合禁用原因判断,不代表点击后可以执行。", captures: [
await capture(".axis-menubar", "文件 / 帮助菜单入口", ["menu-file", "menu-help"]),
await capture(".axis-toolbar", "机器与程序工具栏 · 图标从左至右", ids("toolbar-")),
await capture(".axis-case-strip", "案例与示例程序", ids("workspace-")),
], extra: ["program-file-input"] });
docs.push({ file: "02-manual-and-homing", title: "手动操作与原生回零", note: "Home Joint 选择数字关节;Jog/Touch Off 选择世界轴。当前案例 Home 可能只重新建立坐标参考点,电机位置不变,这是原生 INI 回零语义。", captures: [
await capture(".left-notebook", "Manual Control 手动页", [...ids("tab-").filter((id) => ["tab-manual", "tab-mdi", "tab-diagnostics"].includes(id)), ...ids("manual-")]),
] });
docs.push({ file: "03-views-and-overrides", title: "运动学、视图与倍率", note: "Switchkins 改变运动学解释;相机与图层仅改变显示。Feed、Spindle、Jog 和 Max Velocity 各有独立含义,不能互相代替。", captures: [
await capture(".xyzac-kinematics", "Switchkins 运动学模式", ids("switchkins-")),
await capture(".right-notebook", "Preview / DRO 与画布平移", ["tab-preview", "tab-dro", ...ids("preview-pan"), "preview-reset"]),
await capture(".axis-metrics", "倍率滑块与预览图层", [...ids("override-"), ...ids("preview-layer-")]),
await capture(".axis-program-pane", "程序编辑与执行记录", ["program-editor"]),
] });
await page.locator('[data-axis-control="tab-mdi"]').click();
await page.locator('[data-axis-control="mdi-command"]').fill("G21");
await page.locator('[data-axis-control="mdi-submit"]').click();
await page.waitForFunction(() => document.querySelector(".command-status")?.textContent === "MDI_EXECUTE acknowledged");
const mdi = await capture(".left-notebook", "MDI 单条指令与历史", ids("mdi-"));
await page.locator('[data-axis-control="tab-dro"]').click();
const dro = await capture(".right-notebook", "DRO 数显设置", ids("dro-"));
await page.locator('[data-axis-control="menu-file"]').click();
const fileMenu = await capture('.axis-menu-item:has([data-axis-control="menu-file"]) .axis-menu-popup', "File 菜单展开", ["menu-file-edit-tool-data", "menu-file-quit"]);
await page.locator('[data-axis-control="menu-file"]').click();
await page.locator('[data-axis-control="menu-help"]').click();
const helpMenu = await capture('.axis-menu-item:has([data-axis-control="menu-help"]) .axis-menu-popup', "Help 菜单展开", ["menu-help-about"]);
await page.locator('[data-axis-control="menu-help-about"]').click();
const about = await capture('.axis-about-dialog', "About 弹窗", ["about-close"]);
docs.push({ file: "04-mdi-dro-and-menus", title: "MDI、数显与菜单窗口", note: "MDI 示例仅提交 G21(毫米单位),未执行加工运动。错误及禁用原因以当前状态栏与 Diagnostics 为准。", captures: [mdi, dro, fileMenu, helpMenu, about] });
covered.add("program-file-input");
assert.deepEqual(entries.filter((e) => !covered.has(e.id)).map((e) => e.id), [], "unannotated controls");
assert.deepEqual(errors, []);
const reportPage = await browser.newPage({ viewport: { width: 1800, height: 1200 }, deviceScaleFactor: 2 });
const paths = [];
for (const doc of docs) {
const blocks = doc.captures.map((cap) => {
const scale = Math.min(1.2, 1672 / cap.width);
const width = cap.width * scale;
const height = cap.height * scale;
// Put badges in lanes outside small controls, with a leader to each target.
const used = [];
const badges = cap.marks.map((mark) => {
let x = Math.max(14, Math.min(width - 14, (mark.x + mark.w / 2) * scale));
let y = mark.y * scale + 60 - 16;
while (used.some((p) => Math.abs(p.x - x) < 34 && Math.abs(p.y - y) < 28)) y -= 30;
used.push({ x, y });
const tx = (mark.x + mark.w / 2) * scale, ty = (mark.y + mark.h / 2) * scale + 60;
return `<line x1="${x}" y1="${y}" x2="${tx}" y2="${ty}" stroke="#b32732" stroke-width="1.5"/><circle cx="${x}" cy="${y}" r="13" fill="#b32732" stroke="white" stroke-width="2"/><text x="${x}" y="${y+4.5}" text-anchor="middle" font-size="12" font-weight="bold" fill="white">${mark.number}</text>`;
}).join("");
return `<section><h2>${cap.title}</h2><div class="shot" style="width:${width}px;height:${height+76}px"><img style="top:60px;width:${width}px;height:${height}px" src="data:image/png;base64,${cap.image}"/><svg width="${width}" height="${height+76}">${badges}</svg></div><div class="legend">${cap.marks.map((e) => `<article><b class="number">${e.number}</b><div><h3>${escape(e.name)}</h3><p>${escape(e.detail)}</p><code>${e.id}</code></div></article>`).join("")}</div></section>`;
}).join("");
const extra = (doc.extra ?? []).map((id) => { const e = byId.get(id); return `<article><b class="number">${e.number}</b><div><h3>${escape(e.name)}</h3><p>${escape(e.detail)}</p></div></article>`; }).join("");
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>${doc.title}</title><style>
*{box-sizing:border-box}body{margin:0;width:1800px;padding:44px 64px;background:#fff;color:#182226;font-family:"Noto Sans CJK SC",sans-serif;letter-spacing:0}header{border-bottom:4px solid #126b67;padding-bottom:22px}h1{font-size:38px;margin:8px 0}header small{color:#126b67;font-size:18px}header p{font-size:20px;line-height:1.65;margin:10px 0 0}h2{font-size:25px;margin:28px 0 6px;border-left:5px solid #126b67;padding-left:14px}.shot{position:relative;margin:0 auto 14px;max-width:100%}.shot img,.shot svg{position:absolute;left:0}.shot svg{top:0;overflow:visible}.legend{display:grid;grid-template-columns:1fr 1fr;gap:0 36px}article{display:flex;gap:13px;padding:12px 0;border-bottom:1px solid #d8e0e2;break-inside:avoid}.number{background:#b32732;color:white;width:32px;height:32px;border-radius:50%;text-align:center;line-height:32px;flex-shrink:0;font-size:17px}h3{font-size:21px;margin:0 0 4px}p{font-size:18px;line-height:1.65;margin:0}code{font-size:12px;color:#687579}footer{margin-top:28px;border-top:2px solid #126b67;padding-top:12px;font-size:15px;color:#58656a}article div{min-width:0}</style><header><small>LinuxCNC AXIS Web/WASM · XYZAC TRT · 控件功能图解 ${doc.file.slice(0,2)}/04</small><h1>${doc.title}</h1><p>${doc.note}</p></header>${blocks}${extra}<footer>红色编号指向实际控件;说明依据当前 Web 实现与源流程契约。灰色“未实现”项不代表已支持。截图来源:${url} · 2026-09-09 · PNG 2× 清晰度</footer></html>`;
await writeFile(`${out}/${doc.file}.html`, html);
await reportPage.setContent(html);
await reportPage.evaluate(() => document.fonts.ready);
await reportPage.waitForTimeout(250);
assert.equal(await reportPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await reportPage.screenshot({ path: `${out}/${doc.file}.png`, fullPage: true });
paths.push(`${doc.file}.png`);
}
await writeFile(`${out}/controls.json`, JSON.stringify({ url, controls: entries, images: paths }, null, 2));
console.log(JSON.stringify({ status: "verified", controls: entries.length, annotated: covered.size, output: out, images: paths }));
} finally { await browser.close(); }