Files
cnc_wams/web-rtcp-5axis-xyzbc-trt-sim-plan/tools/capture-full-gcode-process-frames.mjs
2026-07-03 08:49:13 -04:00

284 lines
12 KiB
JavaScript

import { mkdir, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { createReadStream, statSync } from "node:fs";
import { chromium } from "../app/node_modules/playwright/index.mjs";
const repoRoot = resolve(import.meta.dirname, "../..");
const projectRoot = resolve(repoRoot, "web-rtcp-5axis-xyzbc-trt-sim-plan");
const appUrlPath = "/web-rtcp-5axis-xyzbc-trt-sim-plan/app/index.html";
const sourceRel = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc";
const samplePeriodMs = 50;
const chromiumExecutable = process.env.CHROMIUM || findSystemChromium();
const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
const outputDir = resolve(
projectRoot,
"working/screenshots",
`web-simulation-gcode-full-50ms-${timestamp}`,
);
const maxFramesArg = Number(process.env.MAX_FRAMES || process.argv.find((arg) => arg.startsWith("--max-frames="))?.split("=")[1] || 0);
const maxFrames = Number.isFinite(maxFramesArg) && maxFramesArg > 0 ? Math.floor(maxFramesArg) : null;
await mkdir(outputDir, { recursive: true });
const server = await startStaticServer(repoRoot);
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: chromiumExecutable || undefined,
args: ["--disable-gpu", "--no-sandbox"],
});
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
deviceScaleFactor: 1,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.goto(`http://127.0.0.1:${server.port}${appUrlPath}`, { waitUntil: "networkidle" });
await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState));
await Promise.all([
page.evaluate(() => window.webRtcp5AxisSimulation.machineFileSeedReady),
page.evaluate(() => window.webRtcp5AxisSimulation.interpreterRuntimeReady),
]);
await page.evaluate((selectedSourceRel) => {
const api = window.webRtcp5AxisSimulation;
api.dispatch({
type: "TASK_HAL_RUNTIME_FAILED",
error: "disabled for deterministic 50ms screenshot playback; task/HAL path is covered by browser smoke",
});
api.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: selectedSourceRel });
}, sourceRel);
await waitForState(page, (state) => (
state.machineFileStaging?.selectedGcodeSourceRel === sourceRel &&
Number(state.programAxisPreviewPath?.sampleCount || 0) > 0 &&
state.programAxisPreviewPath?.samplePeriodMs === samplePeriodMs
), "real G-code sample stream loaded");
await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "RUN_READY" }));
await waitForState(page, (state) => (
state.machine?.powerOn === true &&
state.machine?.allHomed === true &&
state.machine?.mode === "auto" &&
state.rtcpState === "on"
), "RUN_READY state");
const initial = await page.evaluate(() => window.webRtcp5AxisSimulation.getState());
const sampleCount = Number(initial.programAxisPreviewPath?.sampleCount || 0);
const frameCount = maxFrames ? Math.min(maxFrames, sampleCount) : sampleCount;
const frames = [];
await page.waitForSelector("[data-five-axis-canvas]");
await page.waitForTimeout(100);
for (let sampleIndex = 0; sampleIndex < frameCount; sampleIndex += 1) {
await page.evaluate((targetSampleIndex) => {
const api = window.webRtcp5AxisSimulation;
const state = api.getState();
state.programExecutionSampleIndex = targetSampleIndex - 1;
state.machine.interpState = "idle";
state.machine.interpResumeState = "idle";
state.runState = "idle";
api.dispatch({ type: "STEP" });
}, sampleIndex);
await waitForState(page, (state, sourceRel, samplePeriodMs, context) => (
Number(state.programExecutionSampleIndex || 0) === context.sampleIndex &&
state.programUiExecution?.source === "programAxisPreviewPath.samples"
), `sample ${sampleIndex}`, 30000, { sampleIndex });
await page.waitForTimeout(16);
const live = await page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
const programPane = document.querySelector('[data-region="program"]');
const monitor = document.querySelector("[data-linuxcnc-process-monitor]");
const canvas = document.querySelector("[data-five-axis-canvas]");
return {
runState: state.runState,
activeLine: state.activeLine,
programExecutionSampleIndex: state.programExecutionSampleIndex,
programUiExecution: state.programUiExecution,
programPaneDataset: { ...(programPane?.dataset || {}) },
monitorDataset: { ...(monitor?.dataset || {}) },
canvasDataset: {
threeToolAxis: canvas?.dataset?.threeToolAxis || null,
threeToolGlyphAxis: canvas?.dataset?.threeToolGlyphAxis || null,
},
axisPose: state.axisPose,
toolAxisVector: state.toolAxisVector,
};
});
const frameName = `frame-${String(sampleIndex).padStart(4, "0")}-t${String(sampleIndex * samplePeriodMs).padStart(6, "0")}ms.png`;
await page.screenshot({ path: resolve(outputDir, frameName), fullPage: true });
frames.push({
index: sampleIndex,
timeMs: sampleIndex * samplePeriodMs,
file: frameName,
sourceFile: live.programUiExecution?.sourceFile || null,
line: live.programUiExecution?.line || null,
statement: live.programUiExecution?.statement || "",
operation: live.programUiExecution?.operation || null,
gcodeStepIndex: live.programUiExecution?.gcodeStepIndex ?? null,
segmentIndex: live.programUiExecution?.segmentIndex ?? null,
motionType: live.programUiExecution?.motionType || null,
activeKinematics: live.programUiExecution?.activeKinematics || null,
joint: live.programUiExecution?.joint || null,
tcp: live.programUiExecution?.tcp || null,
toolAxis: live.programUiExecution?.toolAxis || null,
axisPose: live.axisPose,
toolAxisVector: live.toolAxisVector,
canvasDataset: live.canvasDataset,
});
}
const finalState = await page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
return {
activeProgram: state.activeProgram,
selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null,
source: state.programAxisPreviewPath?.source || null,
semanticBoundary: state.programAxisPreviewPath?.semanticBoundary || null,
samplePeriodMs: state.programAxisPreviewPath?.samplePeriodMs || null,
sampleCount: state.programAxisPreviewPath?.sampleCount || state.programAxisPreviewPath?.samples?.length || 0,
capturedSampleIndex: state.programExecutionSampleIndex,
runState: state.runState,
programUiExecution: state.programUiExecution,
gcodeExecutionProcess: {
status: state.programAxisPreviewPath?.gcodeExecutionProcess?.status || null,
executionStepCount: state.programAxisPreviewPath?.gcodeExecutionProcess?.executionStepCount || 0,
summary: state.programAxisPreviewPath?.gcodeExecutionProcess?.summary || null,
},
lineExecutionTraceCount: state.programAxisPreviewPath?.lineExecutionTrace?.length || 0,
axisValuesByLineCount: state.programAxisPreviewPath?.axisValuesByLine?.length || 0,
programExecutionSourceMode: state.programExecutionSourceMode,
taskHalRuntimeReadiness: state.taskHalRuntimeReadiness,
};
});
const manifest = {
apiName: "web-rtcp-5axis-xyzbc-trt-full-gcode-process-frame-capture",
status: frameCount === sampleCount ? "complete" : "partial",
capturedAt: new Date().toISOString(),
projectRoot,
outputDir,
url: `http://127.0.0.1:${server.port}${appUrlPath}`,
sourceRel,
captureMethod: "Playwright drives the AXIS UI state one real programAxisPreviewPath sample at a time and screenshots after each 50ms sample.",
samplePeriodMs,
expectedSampleCount: sampleCount,
capturedFrameCount: frames.length,
firstFrame: frames[0] || null,
lastFrame: frames[frames.length - 1] || null,
gcodeExecutionProcess: finalState.gcodeExecutionProcess,
lineExecutionTraceCount: finalState.lineExecutionTraceCount,
axisValuesByLineCount: finalState.axisValuesByLineCount,
finalState,
frames,
note: "executionStepCount is the expanded semantic G-code step count; capturedFrameCount is the 50ms visual frame count.",
};
await writeFile(resolve(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
console.log(`gcode_full_50ms_screenshots=${outputDir}`);
console.log(`captured_frames=${frames.length}`);
console.log(`sample_period_ms=${samplePeriodMs}`);
console.log(`manifest=${resolve(outputDir, "manifest.json")}`);
} finally {
await browser?.close().catch(() => {});
await server.close();
}
async function waitForState(page, predicate, label, timeoutMs = 30000, context = {}) {
const predicateText = predicate.toString();
await page.waitForFunction(
([source, selectedSourceRel, selectedSamplePeriodMs, selectedContext]) => {
const state = window.webRtcp5AxisSimulation?.getState?.();
if (!state) return false;
const sourceRel = selectedSourceRel;
const samplePeriodMs = selectedSamplePeriodMs;
const context = selectedContext || {};
return Function("state", "sourceRel", "samplePeriodMs", "context", `return (${source})(state, sourceRel, samplePeriodMs, context);`)(
state,
sourceRel,
samplePeriodMs,
context,
);
},
[predicateText, sourceRel, samplePeriodMs, context],
{ timeout: timeoutMs },
).catch(async (error) => {
const state = await page.evaluate(() => window.webRtcp5AxisSimulation?.getState?.());
throw new Error(`Timed out waiting for ${label}: ${error.message}\n${JSON.stringify({
runState: state?.runState,
activeLine: state?.activeLine,
selectedGcodeSourceRel: state?.machineFileStaging?.selectedGcodeSourceRel,
sampleIndex: state?.programExecutionSampleIndex,
sampleCount: state?.programAxisPreviewPath?.sampleCount,
operatorMessage: state?.operatorMessage,
taskHalRuntimeReadiness: state?.taskHalRuntimeReadiness,
}, null, 2)}`);
});
}
function startStaticServer(root) {
const server = createServer((request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
const decoded = decodeURIComponent(url.pathname);
const relative = decoded === "/" ? "/index.html" : decoded;
const target = resolve(root, `.${relative}`);
if (!target.startsWith(root)) {
response.writeHead(403);
response.end("Forbidden");
return;
}
let stat;
try {
stat = statSync(target);
if (!stat.isFile()) throw new Error("not file");
} catch {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"content-type": contentType(target),
"content-length": stat.size,
"cache-control": "no-store",
});
createReadStream(target).pipe(response);
});
return new Promise((resolveStart, rejectStart) => {
server.on("error", rejectStart);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
resolveStart({
port: address.port,
close: () => new Promise((resolveClose) => server.close(resolveClose)),
});
});
});
}
function contentType(path) {
if (path.endsWith(".html")) return "text/html; charset=utf-8";
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".wasm")) return "application/wasm";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
function findSystemChromium() {
return [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => existsSync(candidate)) || null;
}