${escape(e.name)}
${escape(e.detail)}
${e.id}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("&", "&").replaceAll("<", "<").replaceAll('"', """);
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 ` ${escape(e.detail)} ${escape(e.detail)}${cap.title}
${escape(e.name)}
${e.id}${escape(e.name)}
${doc.note}