Clear live trace when replacing G-code program

This commit is contained in:
2026-09-03 16:47:15 +08:00
parent 6a43f6d1e8
commit 35adac6471
5 changed files with 73 additions and 0 deletions

View File

@@ -41,6 +41,19 @@
"browser_screenshot_sha256": "c3e0a5b992381e7b6cb26f98a30338935df5239c875755727fc678911452ac24",
"status": "verified"
},
"live_trace_program_replacement": {
"command": "node scripts/test_xyzac_live_trace_replacement_browser.mjs",
"release": "20260903-linuxcnc-wasm-live-replace-r32",
"status": "verified",
"first_program_preview_segments": 4,
"first_program_live_trace_points": 5,
"second_program_preview_segments": 1,
"second_program_live_trace_points": 0,
"old_live_trace_retained": false,
"browser_errors": 0,
"screenshot": "/tmp/xyzac-live-trace-replacement.png",
"screenshot_sha256": "4bdf642d3e51cffae8afadb881422d58408d3f095aa795566d045a35d2c05482"
},
"switchkins_direct_matrix": {
"command": "node --experimental-strip-types web/worker/test_xyzac_trt_case.mjs",
"sequence": [0, 1, 2, 0, 1, 2, 0],

View File

@@ -365,3 +365,7 @@ UI cache does not consume WASM sequence: verified
详细字段、文件布局、原子写入和恢复边界见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](../docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。
发布版本 `20260903-linuxcnc-wasm-opfs-r30` 的真实 Chromium OPFS 往返:改变 Preview 视角为 `z`、切换 DRO、选择 A 轴,等待 1.8 秒防抖保存后刷新页面;刷新后 `view=z`、DRO 仍激活、A 轴仍选中,OPFS 中实际存在 `state-b.json` 和 `manifest.json`,两次 `localStorage` key 数均为 `0`,`browser_errors=0`。截图 `/tmp/xyzac-opfs-browser.png`。
## 实时轨迹程序替换验证
发布版本 `20260903-linuxcnc-wasm-live-replace-r32` 执行了真实浏览器流程:第一程序运行完成并产生 5 个 live trace 点,随后加载第二个程序。第二程序加载完成后 WASM preview 为 1 段、live trace 为 0,旧程序实时轨迹不再保留,`browser_errors=0`。截图 `/tmp/xyzac-live-trace-replacement.png`,SHA-256 `4bdf642d3e51cffae8afadb881422d58408d3f095aa795566d045a35d2c05482`。

View File

@@ -0,0 +1,33 @@
import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE;
const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
if (!modulePath) { console.log(JSON.stringify({ schema: "xyzac-live-trace-replacement-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, machine: element.querySelector(".machine-state")?.textContent?.trim(), program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, path: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacToolpathCount, live: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacLiveTraceCount, loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", command: element.querySelector(".command-status")?.textContent, error: element.querySelector(".diagnostic-error")?.textContent ?? null }));
const wait = async (predicate, label, timeout = 45000) => { const started = Date.now(); let last; while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(100); } throw new Error(`${label}:${JSON.stringify(last)}`); };
const click = async (locator, label) => { for (let index = 0; index < 300; index += 1) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); } throw new Error(`${label}:disabled_or_missing`); };
const loadFile = async (name, source) => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) }); return wait((value) => value.program === "loaded" && !value.loading && value.error === null, `${name}_loaded`); };
const runUntilTrace = async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await click(main.getByRole("button", { name: /^Identity/ }), "identity"); await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_first"); return wait((value) => Number(value.live) > 2, "first_live_trace"); };
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
await wait((value) => value.source === "linuxcnc-wasm", "wasm_startup");
const first = await loadFile("live-first.ngc", "G21\nG90\nG1 X20 F120\nG1 Y20\nG1 X0\nG1 Y0\nM2\n");
const running = await runUntilTrace();
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_first");
await wait((value) => value.program === "aborted" || value.program === "loaded", "first_stop");
const second = await loadFile("live-second.ngc", "G21\nG90\nG1 X3 Y4 F120\nM2\n");
const after = await wait((value) => value.program === "loaded" && value.path === "1" && value.live === "0" && value.error === null, "second_replacement");
await page.screenshot({ path: "/tmp/xyzac-live-trace-replacement.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-live-trace-replacement-browser.v1", status: errors.length === 0 && Number(running.live) > 2 && Number(after.path) === 1 && after.live === "0" ? "verified" : "failed", url, first_loaded: first, first_running: running, second_loaded: second, after_replacement: after, browser_errors: errors, screenshot: "/tmp/xyzac-live-trace-replacement.png" }));
} catch (error) {
console.log(JSON.stringify({ schema: "xyzac-live-trace-replacement-browser.v1", status: "failed", url, reason: error?.message ?? String(error), browser_errors: errors, screenshot: "/tmp/xyzac-live-trace-replacement.png" }));
}
await browser.close();

View File

@@ -218,6 +218,27 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
}
clearLines(pathGroup);
clearLines(liveGroup);
// A new PROGRAM_LOAD, including the transient empty snapshot while
// loading, starts a new live-trace epoch. Clear internal playback and
// history buffers as well as rendered geometry so the previous
// program's yellow/orange path cannot be painted again.
liveTrace = [];
playbackQueue = [];
playbackStart = 0;
queuedLastTick = -1;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
// Seed the new epoch without adding a visible history point. The
// first post-load motion delta will create the first live segment.
liveTick = renderedTick;
bounds = fit(segments);
// Long programs can contain thousands of linear blocks. Batch their
// vertices so preview loading does not block the UI on one WebGL

View File

@@ -16,6 +16,8 @@ assert.deepEqual(visual.pose, { x: 11, y: -4, z: 19, a: 31, b: 0, c: 44, u: 0, v
assert.deepEqual(visual.livePose, visual.pose);
assert.equal(visual.activeProgress, 0.375);
assert.equal(visual.activeSegmentId, 3);
for (const token of ["liveTrace = []", "playbackQueue = []", "queuedLastTick = -1", "liveTick = -1"]) assert.ok(source.includes(token), token);
for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "A new PROGRAM_LOAD"]) assert.ok(source.includes(token), token);
for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "activeSegment.samples.map(sourcePoint)", "segmentCandidatesBySourceLine", "candidates.length === 1", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token);
for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden));
for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden));