feat: sync latest run execution updates

This commit is contained in:
2026-06-22 21:47:16 -04:00
parent 0b1aad39e1
commit 8d3177cb73
92 changed files with 22837 additions and 246 deletions

13
AGENTS.md Normal file
View File

@@ -0,0 +1,13 @@
# AGENTS.md
## Scope
This file defines workspace-level agent operating rules for
`/home/meswork/cnc_wams`.
## Logging Rule
After every GPT/Codex execution completes, append the full execution process
log to:
`/home/meswork/cnc_wams/web-rtcp-5axis-sim-plan/gptlog-process/gpdlog.md`

View File

@@ -0,0 +1,585 @@
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { createHash } from "node:crypto";
import puppeteer from "puppeteer-core";
import { PNG } from "pngjs";
import {
AlignmentType,
Document,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from "docx";
const REPO_ROOT = path.resolve("/home/meswork/cnc_wams");
const QA_ROOT = path.join(REPO_ROOT, "qa/web-rtcp-5axis-site-test");
const OUTPUT_DIR = path.join(QA_ROOT, "output");
const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "test-linuxcnc-source-run");
const TEST_SOURCE_DIR = path.join(REPO_ROOT, "web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source");
const TEST_INI_PATH = path.join(TEST_SOURCE_DIR, "xyzac-trt.ini");
const TEST_GCODE_PATH = path.join(TEST_SOURCE_DIR, "impeller-7bl-xyzac.ngc");
const VENDORED_GCODE_REL = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc";
const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome";
const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html";
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
const iniText = await fs.readFile(TEST_INI_PATH, "utf8");
const gcodeText = await fs.readFile(TEST_GCODE_PATH, "utf8");
const sourceEvidence = {
iniPath: TEST_INI_PATH,
iniSha256: sha256(iniText),
gcodePath: TEST_GCODE_PATH,
gcodeSha256: sha256(gcodeText),
gcodeLineCount: gcodeText.split(/\r?\n/).length,
gcodeBytes: Buffer.byteLength(gcodeText),
};
const server = createStaticServer(REPO_ROOT);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("failed to start static server");
const baseUrl = `http://127.0.0.1:${address.port}`;
const browser = await puppeteer.launch({
headless: true,
executablePath: CHROME_PATH,
defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 },
args: [
"--disable-gpu",
"--enable-webgl",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--no-sandbox",
],
});
const page = await browser.newPage();
const consoleErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
const report = {
generatedAt: new Date().toISOString(),
targetUrl: `${baseUrl}${FIXTURE_URL}`,
chromePath: CHROME_PATH,
sourceEvidence,
screenshots: {},
steps: [],
checks: [],
samples: [],
consoleErrors,
};
try {
await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 });
await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 });
await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState), { timeout: 15000 });
await waitForState((state) => state.kinematicsRuntimeReadiness?.loaded === true, 20000, "kinematics runtime ready");
await waitForCanvasReady();
await wait(800);
await captureStep("01-initial-ui", "初始界面", "确认浏览器应用、五轴预览区、G-code 区和 DRO 面板已渲染。");
await page.evaluate(async ({ ini }) => {
await window.webRtcp5AxisSimulation.stageMachineFiles({ iniText: ini, storageMode: "memory" });
}, { ini: iniText });
await waitForState((state) => state.machineFileStaging?.status === "staged", 20000, "test INI staged");
await captureStep("02-stage-test-ini", "Stage test_linuxcnc_source INI", "使用 working_run/test_linuxcnc_source/xyzac-trt.ini 重新 staging machine files。");
await page.select('[data-action="select-linuxcnc-gcode-source"]', VENDORED_GCODE_REL);
await waitForState((state) => (
state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc") &&
state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" &&
state.taskHalSession?.programPath?.endsWith("impeller-7bl-xyzac.ngc")
), 25000, "impeller program loaded");
await waitForCanvasReady();
await wait(1200);
await captureStep("03-toolpath-preview", "刀具路径预览", "加载 test_linuxcnc_source/impeller-7bl-xyzac.ngc 对应程序,确认 canonical motion 预览路径、TCP 球和刀轴线可见。");
await setMachineReady();
await captureStep("04-ready-before-run", "RUN 前准备", "POWER ON、HOME、AUTO、TCP 模式就绪RUN gate 所需条件已满足。");
await page.evaluate(() => document.querySelector('[data-action="RUN"]').click());
await waitForState((state) => (
["running", "complete"].includes(state.runState) &&
state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm"
), 15000, "RUN feedback started");
const startedAt = Date.now();
for (const [elapsedMs, title] of [
[200, "G-code 执行 200ms"],
[500, "G-code 执行 500ms"],
[1000, "G-code 执行 1000ms"],
[2000, "G-code 执行 2000ms"],
[5000, "G-code 执行 5000ms"],
[17000, "G-code 执行 17000ms"],
]) {
const delay = Math.max(startedAt + elapsedMs - Date.now(), 0);
if (delay > 0) await wait(delay);
await waitForCanvasReady();
await captureRunSample(elapsedMs, `05-run-${String(elapsedMs).padStart(4, "0")}ms`, title);
}
await page.evaluate(() => document.querySelector('[data-action="STOP"]').click());
await waitForState((state) => state.taskHalStatusLoop?.active === false, 10000, "RUN stopped");
await captureStep("06-stop-after-run", "STOP 后状态", "停止 RUN确认 status loop 停止且执行证据已保留。");
addChecks();
report.status = report.checks.every((check) => check.pass) ? "PASS" : "FAIL";
const jsonPath = path.join(OUTPUT_DIR, "test-linuxcnc-source-run-report.json");
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
report.jsonPath = jsonPath;
const docxPath = await writeDocxReport(report);
report.docxPath = docxPath;
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`test_linuxcnc_source_run_status=${report.status}`);
console.log(`test_linuxcnc_source_run_json=${jsonPath}`);
console.log(`test_linuxcnc_source_run_docx=${docxPath}`);
} finally {
await page.close().catch(() => {});
await browser.close().catch(() => {});
await new Promise((resolve) => server.close(resolve));
}
async function setMachineReady() {
await page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
if (state.machine?.taskState !== "on") document.querySelector('[data-action="power"]').click();
});
await waitForState((state) => state.machine.taskState === "on", 10000, "machine power on");
await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click());
await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode");
await page.evaluate(() => document.querySelector('[data-action="HOME"]').click());
await waitForState((state) => state.machine.allHomed === true, 10000, "homed");
await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click());
await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode");
await page.evaluate(() => document.querySelector('[data-action="kins-tcp"]').click());
await waitForState((state) => state.rtcpState === "on" && state.kinsType === "tcp-xyzac", 10000, "tcp mode");
}
async function captureRunSample(elapsedMs, name, title) {
const step = await captureStep(name, title, "采集 G-code 当前高亮行、执行轨迹、实时 DRO/axisPose 和 task/HAL feedback。");
const state = step.state;
const sample = {
elapsedMs,
screenshotPath: step.screenshotPath,
activeLine: state.activeLine,
activeUiLine: state.activeUiLine,
activeLineMatchesUi: state.activeLine === state.activeUiLine,
runState: state.runState,
axisPose: state.axisPose,
dro: state.dro,
droMatchesAxisPose: axesMatch(state.dro, state.programRuntimeFeedback?.axisPose),
velocity: state.programRuntimeFeedback?.currentVelocityMmPerMin ?? null,
expectedVelocity: state.currentTimingSegment?.velocityMmPerMin ?? null,
feedRate: state.currentTimingSegment?.feedRate ?? null,
feedMode: state.currentTimingSegment?.feedMode ?? null,
segmentDurationSeconds: state.currentTimingSegment?.durationSeconds ?? null,
distanceToGo: state.programRuntimeFeedback?.distanceToGo ?? null,
feedbackSource: state.programRuntimeFeedback?.sourceMode ?? null,
taskCycle: state.programRuntimeFeedback?.taskCycle ?? null,
servoCycle: state.programRuntimeFeedback?.cycle ?? null,
historyLength: state.programRuntimeFeedbackHistory?.length || 0,
executedPathPoints: Number(step.dataset.threeExecutedPathPoints || 0),
currentSegmentHighlight: step.dataset.threeCurrentSegmentHighlight,
};
report.samples.push(sample);
return sample;
}
async function captureStep(name, title, description) {
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
const dataset = await getCanvasDataset();
const state = await getStateWithUi();
const pixelStats = await analyzePng(screenshotPath);
const step = {
name,
title,
description,
screenshotPath,
dataset,
pixelStats,
state: summarizeState(state),
};
report.screenshots[name] = screenshotPath;
report.steps.push(step);
return step;
}
function addChecks() {
const finalState = report.steps.at(-1)?.state || {};
const previewStep = report.steps.find((step) => step.name === "03-toolpath-preview");
const runSamples = report.samples;
const movingVelocities = runSamples
.map((sample) => Number(sample.velocity || 0))
.filter((velocity) => velocity > 0);
const distinctVelocities = new Set(movingVelocities.map((velocity) => Math.round(velocity * 1000) / 1000));
const sourceState = previewStep?.state || {};
report.checks.push(
check("test_linuxcnc_source INI used", sourceState.iniPath?.endsWith("xyzac-trt.ini"), sourceState.iniPath || "-"),
check("test_linuxcnc_source G-code hash matches loaded source", sourceEvidence.gcodeSha256 === "e90f0b4b6c43809da94a8170ee1029b5afc3e1bbe9bf2ae66298a8baefad013c", sourceEvidence.gcodeSha256),
check("刀具路径预览点可见", Number(previewStep?.dataset?.threePathPoints || 0) > 100, `pathPoints=${previewStep?.dataset?.threePathPoints}`),
check("刀具路径使用完整 canonical motion 点", Number(previewStep?.dataset?.threePathPoints || 0) === Number(sourceState.programSummary?.motionEventCount || 0), `threePathPoints=${previewStep?.dataset?.threePathPoints}, motionEventCount=${sourceState.programSummary?.motionEventCount}`),
check("TCP/刀轴线可见", previewStep?.dataset?.threeTcpMarker === "sphere" && previewStep?.dataset?.threeToolAxisMarker === "line", `tcp=${previewStep?.dataset?.threeTcpMarker}, axis=${previewStep?.dataset?.threeToolAxisMarker}`),
check("RUN 采样数量", runSamples.length >= 5, `samples=${runSamples.length}`),
check("每行执行高亮同步", runSamples.every((sample) => sample.activeLineMatchesUi), runSamples.map((sample) => `${sample.activeLine}/${sample.activeUiLine}`).join(", ")),
check("执行轨迹可见", runSamples.every((sample) => sample.executedPathPoints >= 1 && sample.currentSegmentHighlight === "ok"), runSamples.map((sample) => `${sample.executedPathPoints}/${sample.currentSegmentHighlight}`).join(", ")),
check("实时轴值来自 task/HAL feedback", runSamples.every((sample) => sample.feedbackSource === "linuxcnc-task-motion-hal-wasm" && sample.droMatchesAxisPose), runSamples.map((sample) => `${sample.feedbackSource}/${sample.droMatchesAxisPose}`).join(", ")),
check("RUN feed 不是固定 3600 mm/min", movingVelocities.length > 0 && movingVelocities.every((velocity) => Math.abs(velocity - 3600) > 0.001), runSamples.map((sample) => `${sample.elapsedMs}ms=${sample.velocity}`).join(", ")),
check("RUN feed 随实际 G-code F/G93 段变化", distinctVelocities.size >= 3, [...distinctVelocities].join(", ")),
check("RUN 使用 G93 inverse-time feed", runSamples.some((sample) => sample.feedMode === "inverse-time" && Number(sample.segmentDurationSeconds || 0) > 0), runSamples.map((sample) => `${sample.elapsedMs}ms F${sample.feedRate} ${sample.feedMode} ${sample.segmentDurationSeconds}s`).join(", ")),
check("task/HAL cycle 推进", runSamples.some((sample) => Number(sample.taskCycle || 0) > 0 && Number(sample.servoCycle || 0) > 0), runSamples.map((sample) => `${sample.taskCycle}/${sample.servoCycle}`).join(", ")),
check("STOP 后 loop 停止", finalState.taskHalStatusLoop?.active === false, `active=${finalState.taskHalStatusLoop?.active}, stopReason=${finalState.taskHalStatusLoop?.stopReason}`),
);
}
function summarizeState(state) {
const currentTimingSegment = currentTimingSegmentForState(state);
return {
activeProgram: state.activeProgram,
programSource: state.programSource,
programExecutionSourceMode: state.programExecutionSourceMode,
programSummary: state.programExecution?.summary || null,
machineProfile: state.machineProfile,
iniPath: state.iniConfigReadiness?.path || state.linuxCncIniConfig?.path || null,
selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null,
taskHalProgramPath: state.taskHalSession?.programPath || null,
runState: state.runState,
activeLine: state.activeLine,
activeUiLine: state.__activeUiLine,
machine: state.machine,
rtcpState: state.rtcpState,
kinsType: state.kinsType,
dro: pickAxes(state.dro),
axisPose: pickAxes(state.axisPose),
programRuntimeFeedback: state.programRuntimeFeedback,
currentTimingSegment,
feedbackHistoryLength: state.programRuntimeFeedbackHistory?.length || 0,
taskHalStatusLoop: state.taskHalStatusLoop,
taskHalStatus: state.taskHalStatus ? {
taskState: state.taskHalStatus.ui?.taskState || state.taskHalStatus.task?.state || null,
taskMode: state.taskHalStatus.ui?.taskMode || state.taskHalStatus.task?.mode || null,
interpState: state.taskHalStatus.ui?.interpState || state.taskHalStatus.task?.interpState || null,
activeLine: state.taskHalStatus.ui?.activeLine || null,
taskCycle: state.taskHalStatus.ui?.taskCycle || null,
servoCycle: state.taskHalStatus.ui?.servoCycle || null,
} : null,
};
}
function currentTimingSegmentForState(state) {
const activeLine = Number(state.activeLine || 0);
const segments = state.programExecutionTiming?.segments || [];
if (!Number.isFinite(activeLine) || !Array.isArray(segments) || segments.length === 0) {
return null;
}
return segments.find((segment) => Number(segment.line) === activeLine)
|| [...segments].reverse().find((segment) => Number(segment.line) <= activeLine)
|| null;
}
async function wait(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForState(predicate, timeoutMs, label) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = await getStateWithUi();
if (predicate(state)) return state;
await wait(100);
}
throw new Error(`timeout waiting for ${label}`);
}
async function waitForCanvasReady(timeoutMs = 20000) {
await page.waitForFunction(() => {
const canvas = document.querySelector("[data-five-axis-canvas]");
return canvas?.dataset?.threeReady === "true"
&& canvas?.dataset?.threePreviewScope === "machine-reference-and-toolpath";
}, { timeout: timeoutMs });
}
async function getStateWithUi() {
return page.evaluate(() => {
const state = JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()));
state.__activeUiLine = Number(document.querySelector(".gcode-row.active")?.dataset.programLine || 0);
return state;
});
}
async function getCanvasDataset() {
return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset }));
}
function createStaticServer(rootDir) {
return http.createServer(async (request, response) => {
try {
const requestPath = decodeURIComponent(new URL(request.url || "/", "http://127.0.0.1").pathname);
const relativePath = requestPath === "/" ? "/index.html" : requestPath;
const targetPath = path.resolve(rootDir, `.${relativePath}`);
if (!targetPath.startsWith(rootDir)) {
response.writeHead(403);
response.end("forbidden");
return;
}
let filePath = targetPath;
let stat = await fs.stat(filePath).catch(() => null);
if (stat?.isDirectory()) {
filePath = path.join(filePath, "index.html");
stat = await fs.stat(filePath).catch(() => null);
}
if (!stat?.isFile()) {
response.writeHead(404);
response.end("not found");
return;
}
const body = await fs.readFile(filePath);
response.writeHead(200, {
"Content-Type": contentTypeFor(filePath),
"Content-Length": String(body.byteLength),
"Cache-Control": "no-store",
});
response.end(body);
} catch (error) {
response.writeHead(500);
response.end(error instanceof Error ? error.message : String(error));
}
});
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
return {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".xml": "application/xml; charset=utf-8",
}[ext] || "application/octet-stream";
}
async function analyzePng(filePath) {
const png = PNG.sync.read(await fs.readFile(filePath));
let luminanceSum = 0;
let nonBlack = 0;
for (let index = 0; index < png.data.length; index += 4) {
const luminance = png.data[index] * 0.2126 + png.data[index + 1] * 0.7152 + png.data[index + 2] * 0.0722;
luminanceSum += luminance;
if (luminance > 8) nonBlack += 1;
}
const total = png.width * png.height;
return {
width: png.width,
height: png.height,
averageLuminance: Number((luminanceSum / total).toFixed(2)),
nonBlackRatio: Number((nonBlack / total).toFixed(4)),
};
}
async function writeDocxReport(data) {
const generated = new Date(data.generatedAt);
const ymd = generated.toISOString().slice(0, 10);
const docxPath = path.join(OUTPUT_DIR, `test-linuxcnc-source-run-report-${ymd}.docx`);
const children = [
new Paragraph({
text: "test_linuxcnc_source RUN 功能测试报告",
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
}),
centered(`测试对象:${data.targetUrl}`),
centered(`生成时间:${formatDateTime(generated)}`),
blank(),
heading("1. 测试结论"),
para(`本次使用 working_run/test_linuxcnc_source 中的 INI 与 G-code 文件执行 RUN 测试,结果:${data.status}`),
checksTable(data.checks),
heading("2. 测试源文件"),
kvTable([
["INI", data.sourceEvidence.iniPath],
["INI SHA-256", data.sourceEvidence.iniSha256],
["G-code", data.sourceEvidence.gcodePath],
["G-code SHA-256", data.sourceEvidence.gcodeSha256],
["G-code 行数/字节", `${data.sourceEvidence.gcodeLineCount} / ${data.sourceEvidence.gcodeBytes}`],
]),
heading("3. 测试过程"),
];
for (const step of data.steps) {
children.push(...(await stepBlock(step)));
}
children.push(
heading("4. RUN 采样明细"),
sampleTable(data.samples),
heading("5. 原始证据"),
kvTable([
["JSON", data.jsonPath || path.join(OUTPUT_DIR, "test-linuxcnc-source-run-report.json")],
["截图目录", SCREENSHOT_DIR],
["Word", docxPath],
["Console error", String(data.consoleErrors.length)],
["Chrome", data.chromePath],
]),
);
if (data.consoleErrors.length > 0) {
children.push(heading("6. Console Error"), ...data.consoleErrors.map((entry) => para(entry)));
}
const doc = new Document({ sections: [{ properties: {}, children }] });
await fs.writeFile(docxPath, await Packer.toBuffer(doc));
return docxPath;
}
async function stepBlock(step) {
const image = await fs.readFile(step.screenshotPath);
return [
new Paragraph({
text: `${step.name} - ${step.title}`,
heading: HeadingLevel.HEADING_2,
spacing: { before: 180, after: 120 },
}),
para(step.description),
kvTable([
["activeProgram", step.state.activeProgram],
["runState / activeLine", `${step.state.runState} / ${step.state.activeLine}`],
["program source", `${step.state.programSource} / ${step.state.programExecutionSourceMode}`],
["selected G-code", step.state.selectedGcodeSourceRel],
["task/HAL program", step.state.taskHalProgramPath],
["DRO XYZAC", axesText(step.state.dro)],
["axisPose XYZAC", axesText(step.state.axisPose)],
["path/executed", `${step.dataset.threePathPoints || 0} / ${step.dataset.threeExecutedPathPoints || 0}`],
["RTCP / kins", `${step.state.rtcpState} / ${step.state.kinsType}`],
["pixel", `luma=${step.pixelStats.averageLuminance}, nonBlack=${step.pixelStats.nonBlackRatio}`],
]),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new ImageRun({
data: image,
type: "png",
transformation: { width: 520, height: 390 },
})],
}),
para(`截图文件:${step.screenshotPath}`),
];
}
function sampleTable(samples) {
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: ["时间", "行号", "状态", "DRO XYZAC", "速度/feed", "轨迹点", "反馈"].map((text) => cell(text, true)),
}),
...samples.map((sample) => new TableRow({
children: [
cell(`${sample.elapsedMs}ms`),
cell(`${sample.activeLine} / UI ${sample.activeUiLine}`),
cell(sample.runState),
cell(axesText(sample.dro)),
cell(`${sample.velocity ?? "-"} mm/min\nF${sample.feedRate ?? "-"} ${sample.feedMode ?? "-"}`),
cell(String(sample.executedPathPoints)),
cell(`${sample.feedbackSource}\ncycle=${sample.taskCycle}/${sample.servoCycle}`),
],
})),
],
});
}
function checksTable(checks) {
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({ children: [cell("检查项", true), cell("结果", true), cell("证据", true)] }),
...checks.map((item) => new TableRow({
children: [cell(item.name), cell(item.pass ? "PASS" : "FAIL"), cell(item.detail)],
})),
],
});
}
function kvTable(rows) {
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({ children: [cell("项目", true), cell("内容", true)] }),
...rows.map(([key, value]) => new TableRow({ children: [cell(key), cell(value)] })),
],
});
}
function centered(text) {
return new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun(String(text))] });
}
function heading(text) {
return new Paragraph({ text, heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 120 } });
}
function blank() {
return new Paragraph({ text: "" });
}
function para(text) {
return new Paragraph({ children: [new TextRun(String(text ?? "-"))], spacing: { after: 100 } });
}
function cell(text, bold = false) {
return new TableCell({
width: { size: 25, type: WidthType.PERCENTAGE },
children: String(text ?? "-").split("\n").map((line) => new Paragraph({
children: [new TextRun({ text: line, bold })],
})),
});
}
function check(name, pass, detail) {
return { name, pass: Boolean(pass), detail: String(detail ?? "-") };
}
function pickAxes(value = {}) {
return {
x: Number(value.x || 0),
y: Number(value.y || 0),
z: Number(value.z || 0),
a: Number(value.a || 0),
b: Number(value.b || 0),
c: Number(value.c || 0),
};
}
function axesMatch(left = {}, right = {}) {
return ["x", "y", "z", "a", "b", "c"].every((axis) => Math.abs(Number(left?.[axis] || 0) - Number(right?.[axis] || 0)) < 1e-9);
}
function axesText(value = {}) {
const axes = pickAxes(value);
return `X=${fmt(axes.x)} Y=${fmt(axes.y)} Z=${fmt(axes.z)} A=${fmt(axes.a)} B=${fmt(axes.b)} C=${fmt(axes.c)}`;
}
function fmt(value) {
return Number(value || 0).toFixed(3);
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function formatDateTime(date) {
return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
}

View File

@@ -8,11 +8,15 @@ const REPO_ROOT = path.resolve("/home/meswork/cnc_wams");
const QA_ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test"); const QA_ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test");
const OUTPUT_DIR = path.join(QA_ROOT, "output"); const OUTPUT_DIR = path.join(QA_ROOT, "output");
const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "toolpath-preview-cases"); const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "toolpath-preview-cases");
const RUN_FEEDBACK_SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "run-preconditions-feedback");
const METER_SCENE_SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "meter-scene-evidence");
const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome"; const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome";
const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html"; const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html";
await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.mkdir(SCREENSHOT_DIR, { recursive: true }); await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
await fs.mkdir(RUN_FEEDBACK_SCREENSHOT_DIR, { recursive: true });
await fs.mkdir(METER_SCENE_SCREENSHOT_DIR, { recursive: true });
const MIME_TYPES = { const MIME_TYPES = {
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
@@ -144,6 +148,22 @@ async function captureCase(name) {
return screenshotPath; return screenshotPath;
} }
async function captureRunFeedbackFrame(name) {
const screenshotPath = path.join(RUN_FEEDBACK_SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
return screenshotPath;
}
async function captureMeterSceneFrame(name) {
const screenshotPath = path.join(METER_SCENE_SCREENSHOT_DIR, `${name}.png`);
const canvas = await page.$("[data-five-axis-canvas]");
if (!canvas) {
throw new Error(`missing canvas for ${name}`);
}
await canvas.screenshot({ path: screenshotPath });
return screenshotPath;
}
async function recordCase(name, summary, checks = []) { async function recordCase(name, summary, checks = []) {
const dataset = await getCanvasDataset(); const dataset = await getCanvasDataset();
const state = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); const state = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())));
@@ -211,6 +231,44 @@ function previewChecks(dataset, {
return checks; return checks;
} }
function meterSceneChecks(dataset, pixelStats = null) {
const bounds = parseJson(dataset.threePathBoundsMeters);
return [
check("scene units are meters", dataset.threeSceneUnits === "m", `sceneUnits=${dataset.threeSceneUnits}`),
check("linear scale visible", Number(dataset.threeLinearUnitScaleToMeters || 0) > 0, `scale=${dataset.threeLinearUnitScaleToMeters}`),
check("path fit bounds", dataset.threePathFitBounds === "ok", `fit=${dataset.threePathFitBounds}`),
check("path bounds in meters", Number(bounds?.maxSpan || 0) > 0 && Number(bounds?.maxSpan || 0) < 5, `bounds=${dataset.threePathBoundsMeters}`),
check("canvas nonblank", !pixelStats || pixelStats.nonBlackRatio > 0.015, `nonBlack=${pixelStats?.nonBlackRatio ?? "-"}`),
];
}
function mixedUnitsChecks(dataset, state) {
const motion = state.programExecution?.motion || [];
const units = [...new Set(motion.map((event) => event.linearUnits).filter(Boolean))];
const xMeters = motion.map((event) => sceneMeterX(event)).filter(Number.isFinite);
return [
check("G20/G21 motion units", units.includes("inch") && units.includes("mm"), `units=${units.join(",")}`),
check("scene units are meters", dataset.threeSceneUnits === "m", `sceneUnits=${dataset.threeSceneUnits}`),
check("mixed unit path visible", Number(dataset.threePathPoints || 0) >= 2, `pathPoints=${dataset.threePathPoints}`),
check("1 inch equals 25.4 mm in scene", xMeters.some((value) => Math.abs(value - 0.0254) < 1e-9), `xMeters=${xMeters.join(",")}`),
check("no unit fallback in canonical motion", state.programExecutionSourceMode === "linuxcnc-interpreter-wasm", `source=${state.programExecutionSourceMode}`),
];
}
function sceneMeterX(event) {
const factor = event?.linearUnits === "inch" ? 0.0254 : event?.linearUnits === "m" ? 1 : 0.001;
const value = Number(event?.axes?.x);
return Number.isFinite(value) ? value * factor : null;
}
function parseJson(text) {
try {
return JSON.parse(text || "null");
} catch {
return null;
}
}
function check(name, pass, detail) { function check(name, pass, detail) {
return { name, pass: Boolean(pass), detail }; return { name, pass: Boolean(pass), detail };
} }
@@ -222,6 +280,111 @@ function assertChecks(caseName, checks) {
} }
} }
function summarizeRunFeedbackState(state, dataset, elapsedMs, screenshotPath) {
const activeRowLine = Number(documentActiveLineFromState(state));
return {
elapsedMs,
screenshotPath,
canvas: {
threeReady: dataset.threeReady,
rtcpState: dataset.threeRtcpState,
executedPathPoints: Number(dataset.threeExecutedPathPoints || 0),
currentSegmentHighlight: dataset.threeCurrentSegmentHighlight,
toolExecutionTraceSource: dataset.threeToolExecutionTraceSource,
},
state: {
profileId: state.machineProfile,
iniReady: state.iniConfigReadiness?.ready === true,
iniPath: state.iniConfigReadiness?.path || null,
coordinates: state.iniConfigReadiness?.coordinates || null,
kinematicsModuleId: state.profile?.kinematicsModuleId || null,
selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null,
taskHalSessionProgramPath: state.taskHalSession?.programPath || null,
taskHalStatusLoop: state.taskHalStatusLoop || null,
runState: state.runState,
activeLine: state.activeLine,
activeRowLine,
activeLineMatchesUi: activeRowLine === Number(state.activeLine),
droAxisPose: state.dro ? {
x: state.dro.x,
y: state.dro.y,
z: state.dro.z,
a: state.dro.a,
b: state.dro.b,
c: state.dro.c,
} : null,
feedbackAxisPose: state.programRuntimeFeedback?.axisPose || null,
droMatchesFeedback: axisPoseMatchesDro(state.dro, state.programRuntimeFeedback?.axisPose),
rtcpState: state.rtcpState,
kinsType: state.kinsType,
feedback: state.programRuntimeFeedback ? {
sourceMode: state.programRuntimeFeedback.sourceMode,
semanticBoundary: state.programRuntimeFeedback.semanticBoundary,
line: state.programRuntimeFeedback.line,
taskCycle: state.programRuntimeFeedback.taskCycle,
servoCycle: state.programRuntimeFeedback.cycle,
velocity: state.programRuntimeFeedback.currentVelocityMmPerMin,
distanceToGo: state.programRuntimeFeedback.distanceToGo,
} : null,
feedbackHistoryLength: state.programRuntimeFeedbackHistory?.length || 0,
feedbackHistorySourceModes: [...new Set((state.programRuntimeFeedbackHistory || []).map((entry) => entry.sourceMode))],
taskHalStatus: state.taskHalStatus ? {
taskState: state.taskHalStatus.ui?.taskState || state.taskHalStatus.task?.state || null,
taskMode: state.taskHalStatus.ui?.taskMode || state.taskHalStatus.task?.mode || null,
interpState: state.taskHalStatus.ui?.interpState || state.taskHalStatus.task?.interpState || null,
taskCycle: state.taskHalStatus.ui?.taskCycle || null,
servoCycle: state.taskHalStatus.ui?.servoCycle || null,
} : null,
},
};
}
function documentActiveLineFromState(state) {
return state.__activeRowLine ?? null;
}
function axisPoseMatchesDro(dro, axisPose) {
if (!dro || !axisPose) return false;
return ["x", "y", "z", "a", "b", "c"].every((axis) => (
Math.abs(Number(dro[axis] || 0) - Number(axisPose[axis] || 0)) < 1e-9
));
}
async function snapshotRunFeedback(elapsedMs, screenshotName) {
const screenshotPath = await captureRunFeedbackFrame(screenshotName);
const dataset = await getCanvasDataset();
const state = await page.evaluate(() => {
const snapshot = JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()));
snapshot.__activeRowLine = Number(document.querySelector(".gcode-row.active")?.dataset.programLine || 0);
return snapshot;
});
return summarizeRunFeedbackState(state, dataset, elapsedMs, screenshotPath);
}
function runFeedbackChecks(samples, baseline = null) {
const states = samples.map((sample) => sample.state);
const histories = states.map((state) => Number(state.feedbackHistoryLength || 0));
const ticks = states.map((state) => Number(state.taskHalStatusLoop?.tickCount || 0));
const baselineSequence = Number(baseline?.state?.taskHalStatusLoop?.sequence || 0);
const sourceModes = states.flatMap((state) => state.feedbackHistorySourceModes || []);
return [
check("INI ready", states.every((state) => state.iniReady), `iniReady=${states.map((state) => state.iniReady).join(",")}`),
check("selected LinuxCNC G-code", states.every((state) => state.selectedGcodeSourceRel?.endsWith("xyzac_switchkins_test_1.ngc")), states.at(-1)?.selectedGcodeSourceRel || "-"),
check("task/HAL session opened selected G-code", states.every((state) => state.taskHalSessionProgramPath?.endsWith("xyzac_switchkins_test_1.ngc")), states.at(-1)?.taskHalSessionProgramPath || "-"),
check("taskHalStatusLoop 新 RUN sequence", states.every((state) => Number(state.taskHalStatusLoop?.sequence || 0) > baselineSequence), `baselineSequence=${baselineSequence}, sequences=${states.map((state) => state.taskHalStatusLoop?.sequence || 0).join(",")}`),
check("taskHalStatusLoop 本次 RUN tickCount", Math.max(...ticks) >= 3, `ticks=${ticks.join(",")}`),
check("programRuntimeFeedbackHistory 本次 RUN 采样", Math.max(...histories) >= 3, `history=${histories.join(",")}`),
check("feedback sourceMode 来自 task/HAL", sourceModes.length > 0 && sourceModes.every((mode) => mode === "linuxcnc-task-motion-hal-wasm"), `sourceModes=${sourceModes.join(",")}`),
check("无 fixture-line-playback feedback", !sourceModes.includes("fixture-line-playback"), `sourceModes=${sourceModes.join(",")}`),
check("semantic boundary", states.every((state) => state.feedback?.semanticBoundary === "linuxcnc_task_motion_hal_wasm_simulation_runtime"), states.map((state) => state.feedback?.semanticBoundary || "-").join(",")),
check("activeLine 等于 UI 高亮行", states.every((state) => state.activeLineMatchesUi), `active=${states.map((state) => `${state.activeLine}/${state.activeRowLine}`).join(",")}`),
check("DRO 等于 runtime feedback axisPose", states.every((state) => state.droMatchesFeedback), "droMatchesFeedback=true for all samples"),
check("RTCP canvas 与 state 一致", samples.every((sample) => sample.canvas.rtcpState === sample.state.rtcpState), samples.map((sample) => `${sample.canvas.rtcpState}/${sample.state.rtcpState}`).join(",")),
check("runtime cycle 可见", states.every((state) => Number(state.feedback?.taskCycle || 0) > 0 && Number(state.feedback?.servoCycle || 0) > 0), states.map((state) => `${state.feedback?.taskCycle || 0}/${state.feedback?.servoCycle || 0}`).join(",")),
check("canvas 执行轨迹", samples.every((sample) => sample.canvas.threeReady === "true" && sample.canvas.currentSegmentHighlight === "ok" && sample.canvas.executedPathPoints >= 1), samples.map((sample) => `${sample.canvas.threeReady}/${sample.canvas.executedPathPoints}/${sample.canvas.currentSegmentHighlight}`).join(",")),
];
}
try { try {
await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 }); await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 });
await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 }); await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 });
@@ -303,6 +466,92 @@ try {
await captureCase("05-vendored-impeller-toolpath"); await captureCase("05-vendored-impeller-toolpath");
await recordCase("05-vendored-impeller-toolpath", "LinuxCNC vendored 五轴 impeller 程序验证长路径、switchkins/RTCP 状态、rapid/feed 图层和 TCP 执行轨迹", checks); await recordCase("05-vendored-impeller-toolpath", "LinuxCNC vendored 五轴 impeller 程序验证长路径、switchkins/RTCP 状态、rapid/feed 图层和 TCP 执行轨迹", checks);
const meterSceneSamples = [];
for (const viewport of [
{ name: "desktop", width: 1600, height: 1200, deviceScaleFactor: 1 },
{ name: "mobile", width: 390, height: 844, deviceScaleFactor: 2 },
]) {
await page.setViewport(viewport);
await waitForCanvasReady();
await wait(600);
const screenshotPath = await captureMeterSceneFrame(`08-meter-scene-${viewport.name}`);
const sampleDataset = await getCanvasDataset();
const pixelStats = await analyzePng(screenshotPath);
const sampleChecks = [
...previewChecks(sampleDataset, { requirePathPoints: true, requireExecutedPath: true }),
...meterSceneChecks(sampleDataset, pixelStats),
];
assertChecks(`08-meter-scene-${viewport.name}`, sampleChecks);
meterSceneSamples.push({
viewport,
screenshotPath,
pixelStats,
dataset: sampleDataset,
status: sampleChecks.every((item) => item.pass) ? "PASS" : "FAIL",
checks: sampleChecks,
});
}
await page.setViewport({ width: 1600, height: 1200, deviceScaleFactor: 1 });
const meterSceneChecksAll = meterSceneSamples.flatMap((sample) => sample.checks);
const meterSceneReport = {
generatedAt: new Date().toISOString(),
targetUrl: report.targetUrl,
caseName: "08-meter-scene-desktop-mobile",
summary: "浏览器米尺度 Three.js 证据desktop/mobile canvas 非空、路径 bounds 为米、预览稳定居中",
status: meterSceneChecksAll.every((item) => item.pass) ? "PASS" : "FAIL",
samples: meterSceneSamples,
consoleErrors,
};
await fs.writeFile(
path.join(OUTPUT_DIR, "meter-scene-evidence.json"),
`${JSON.stringify(meterSceneReport, null, 2)}\n`,
"utf8",
);
report.cases.push({
name: "08-meter-scene-desktop-mobile",
summary: meterSceneReport.summary,
status: meterSceneReport.status,
screenshotDir: METER_SCENE_SCREENSHOT_DIR,
output: path.join(OUTPUT_DIR, "meter-scene-evidence.json"),
sampleCount: meterSceneSamples.length,
});
await page.evaluate(() => {
window.webRtcp5AxisSimulation.dispatch({
type: "LOAD_PROGRAM",
filename: "operator-g20-g21-mixed-units.ngc",
content: [
"G90 G20",
"G1 X1.0 Y0 Z0 F10",
"G21",
"G1 X25.4 Y25.4 Z0 F254",
"M2",
].join("\n"),
});
});
await waitForState((state) => (
state.activeProgram === "operator-g20-g21-mixed-units.ngc" &&
state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" &&
(state.programExecution?.motion || []).some((event) => event.linearUnits === "inch") &&
(state.programExecution?.motion || []).some((event) => event.linearUnits === "mm")
), 20000, "G20/G21 mixed unit program loaded");
await waitForCanvasReady();
await wait(600);
dataset = await getCanvasDataset();
const mixedUnitsState = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())));
checks = [
...previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true }),
...mixedUnitsChecks(dataset, mixedUnitsState),
];
assertChecks("09-g20-g21-mixed-units-preview", checks);
await captureCase("09-g20-g21-mixed-units-preview");
await recordCase("09-g20-g21-mixed-units-preview", "G20/G21 混合单位程序:验证 canonical motion 保留 inch/mmThree.js 统一按米绘制", checks);
await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc");
await waitForState((state) => state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc"), 25000, "vendored impeller restored before run");
await waitForCanvasReady();
await wait(800);
await page.evaluate(() => document.querySelector('[data-action="power"]').click()); await page.evaluate(() => document.querySelector('[data-action="power"]').click());
await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on"); await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on");
await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click()); await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click());
@@ -323,6 +572,88 @@ try {
await captureCase("06-running-rtcp-toolpath"); await captureCase("06-running-rtcp-toolpath");
await recordCase("06-running-rtcp-toolpath", "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", checks); await recordCase("06-running-rtcp-toolpath", "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", checks);
await page.evaluate(() => document.querySelector('[data-action="STOP"]').click());
await waitForState((state) => state.taskHalStatusLoop?.active === false, 10000, "previous run stopped");
await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc");
await waitForState((state) => (
state.activeProgram?.endsWith("xyzac_switchkins_test_1.ngc") &&
state.taskHalSession?.programPath?.endsWith("xyzac_switchkins_test_1.ngc") &&
state.iniConfigReadiness?.ready === true &&
state.profile?.kinematicsModuleId === "xyzac-trt"
), 25000, "run feedback selected program ready");
await waitForCanvasReady();
await page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
if (state.machine?.taskState !== "on") {
document.querySelector('[data-action="power"]').click();
}
});
await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on for run feedback");
await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click());
await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode selected for run feedback");
await page.evaluate(() => document.querySelector('[data-action="HOME"]').click());
await waitForState((state) => state.machine.allHomed === true, 10000, "machine homed for run feedback");
await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click());
await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode selected for run feedback");
const runFeedbackBaseline = await snapshotRunFeedback(0, "07-run-preconditions-and-feedback-0000ms-before-run");
await page.evaluate(() => document.querySelector('[data-action="RUN"]').click());
try {
await waitForState((state) => (
["running", "complete"].includes(state.runState) &&
(state.taskHalStatusLoop?.tickCount > runFeedbackBaseline.state.taskHalStatusLoop.tickCount ||
(state.programRuntimeFeedbackHistory?.length || 0) >= 3) &&
state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm"
), 15000, "run feedback status loop produced feedback");
} catch (error) {
const failureSnapshot = await snapshotRunFeedback(15000, "07-run-preconditions-and-feedback-timeout");
await fs.writeFile(
path.join(OUTPUT_DIR, "run-preconditions-feedback-timeout.json"),
`${JSON.stringify({
error: error instanceof Error ? error.message : String(error),
baseline: runFeedbackBaseline,
failureSnapshot,
consoleErrors,
}, null, 2)}\n`,
"utf8",
);
throw error;
}
const runFeedbackStartedAt = Date.now();
const runFeedbackSamples = [];
for (const elapsedMs of [200, 500, 1000, 2000, 5000]) {
const waitMs = Math.max(runFeedbackStartedAt + elapsedMs - Date.now(), 0);
if (waitMs > 0) await wait(waitMs);
await waitForCanvasReady();
runFeedbackSamples.push(await snapshotRunFeedback(elapsedMs, `07-run-preconditions-and-feedback-${elapsedMs}ms`));
}
const runFeedbackCaseChecks = runFeedbackChecks(runFeedbackSamples, runFeedbackBaseline);
assertChecks("07-run-preconditions-and-feedback", runFeedbackCaseChecks);
const runFeedbackReport = {
generatedAt: new Date().toISOString(),
targetUrl: report.targetUrl,
caseName: "07-run-preconditions-and-feedback",
summary: "浏览器 RUN 证据INI/profile/kinematics/task-HAL 同一上下文taskHalStatusLoop 持续推进UI 消费 task/HAL/motion feedback",
status: runFeedbackCaseChecks.every((item) => item.pass) ? "PASS" : "FAIL",
checks: runFeedbackCaseChecks,
baseline: runFeedbackBaseline,
samples: runFeedbackSamples,
consoleErrors,
};
await fs.writeFile(
path.join(OUTPUT_DIR, "run-preconditions-feedback.json"),
`${JSON.stringify(runFeedbackReport, null, 2)}\n`,
"utf8",
);
report.cases.push({
name: "07-run-preconditions-and-feedback",
summary: runFeedbackReport.summary,
status: runFeedbackReport.status,
checks: runFeedbackCaseChecks,
screenshotDir: RUN_FEEDBACK_SCREENSHOT_DIR,
output: path.join(OUTPUT_DIR, "run-preconditions-feedback.json"),
sampleCount: runFeedbackSamples.length,
});
report.consoleErrors = consoleErrors; report.consoleErrors = consoleErrors;
await fs.writeFile( await fs.writeFile(
path.join(OUTPUT_DIR, "toolpath-preview-cases.json"), path.join(OUTPUT_DIR, "toolpath-preview-cases.json"),

View File

@@ -0,0 +1,385 @@
{
"generatedAt": "2026-06-23T01:38:37.231Z",
"url": "http://127.0.0.1:35283/web-rtcp-5axis-sim-plan/app/index.html",
"sourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
"activeLines": [
9,
10,
12
],
"historyLines": [
9,
10,
11,
12
],
"synced": true,
"samples": [
{
"elapsedMs": 100,
"activeLine": 9,
"uiLine": 9,
"runState": "running",
"velocity": 2100,
"line": 9,
"motionProgramLine": 9,
"halProgramLine": 9,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 9,
"motion": 9,
"hal": 9,
"source": "motion-status",
"synced": true
}
]
},
{
"elapsedMs": 250,
"activeLine": 10,
"uiLine": 10,
"runState": "running",
"velocity": 2100,
"line": 10,
"motionProgramLine": 10,
"halProgramLine": 10,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 9,
"motion": 9,
"hal": 9,
"source": "motion-status",
"synced": true
}
]
},
{
"elapsedMs": 500,
"activeLine": 10,
"uiLine": 10,
"runState": "running",
"velocity": 2100,
"line": 10,
"motionProgramLine": 10,
"halProgramLine": 10,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 9,
"motion": 9,
"hal": 9,
"source": "motion-status",
"synced": true
}
]
},
{
"elapsedMs": 1000,
"activeLine": 12,
"uiLine": 12,
"runState": "running",
"velocity": 100.0002,
"line": 12,
"motionProgramLine": 12,
"halProgramLine": 12,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 11,
"motion": 11,
"hal": 11,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 9,
"motion": 9,
"hal": 9,
"source": "motion-status",
"synced": true
}
]
},
{
"elapsedMs": 2000,
"activeLine": 12,
"uiLine": 12,
"runState": "running",
"velocity": 100.0002,
"line": 12,
"motionProgramLine": 12,
"halProgramLine": 12,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 11,
"motion": 11,
"hal": 11,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 10,
"motion": 10,
"hal": 10,
"source": "motion-status",
"synced": true
},
{
"line": 9,
"motion": 9,
"hal": 9,
"source": "motion-status",
"synced": true
}
]
},
{
"elapsedMs": 4000,
"activeLine": 12,
"uiLine": 12,
"runState": "running",
"velocity": 100.0002,
"line": 12,
"motionProgramLine": 12,
"halProgramLine": 12,
"activeLineSource": "motion-status",
"activeLineHalSynced": true,
"history": [
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
},
{
"line": 12,
"motion": 12,
"hal": 12,
"source": "motion-status",
"synced": true
}
]
}
],
"final": {
"runState": "running",
"activeLine": 12,
"feedbackHistoryLength": 24
},
"consoleErrors": [
"Failed to load resource: the server responded with a status of 404 (Not Found)",
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
}

View File

@@ -0,0 +1,267 @@
{
"generatedAt": "2026-06-22T22:09:41.825Z",
"targetUrl": "http://127.0.0.1:45757/web-rtcp-5axis-sim-plan/app/index.html",
"caseName": "08-meter-scene-desktop-mobile",
"summary": "浏览器米尺度 Three.js 证据desktop/mobile canvas 非空、路径 bounds 为米、预览稳定居中",
"status": "PASS",
"samples": [
{
"viewport": {
"name": "desktop",
"width": 1600,
"height": 1200,
"deviceScaleFactor": 1
},
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/meter-scene-evidence/08-meter-scene-desktop.png",
"pixelStats": {
"width": 803,
"height": 874,
"averageLuminance": 65.18,
"nonBlackRatio": 0.8173
},
"dataset": {
"fiveAxisCanvas": "true",
"engine": "three.js r183",
"threeReady": "true",
"threeRevision": "183",
"threePathPoints": "1498",
"threeExecutedPathPoints": "1",
"threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0.558,\"y\":0.769,\"z\":0.312}",
"threeTcpPose": "{\"x\":36.474,\"y\":-22.486,\"z\":-13.749,\"a\":-71.841,\"c\":-35.93}",
"threeRtcpState": "on",
"threeSelectedView": "iso",
"threeFrameApi": "web-rtcp-5axis-motion-frame",
"threeRenderer": "webgl",
"threeSceneMode": "program-preview-and-tool-execution",
"threePreviewScope": "machine-reference-and-toolpath",
"threeMachineReferenceModel": "webgl-five-axis-reference",
"threeCameraControls": "orbit-pan-zoom",
"threeProgramPreviewSource": "linuxcnc-interpreter-wasm",
"threeToolExecutionMarker": "true",
"threeTcpMarker": "sphere",
"threeToolAxisMarker": "line",
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}",
"threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok",
"threeRapidPathPoints": "186",
"threeFeedPathPoints": "1436",
"threeArcPathPoints": "0",
"threeCurrentSegmentPoints": "1",
"threeCurrentSegmentType": "STRAIGHT_TRAVERSE"
},
"status": "PASS",
"checks": [
{
"name": "canvas ready",
"pass": true,
"detail": "threeReady=true"
},
{
"name": "WebGL renderer",
"pass": true,
"detail": "renderer=webgl"
},
{
"name": "机床参考模型",
"pass": true,
"detail": "scope=machine-reference-and-toolpath, model=webgl-five-axis-reference"
},
{
"name": "TCP 球标记",
"pass": true,
"detail": "tcp=sphere, marker=true"
},
{
"name": "刀轴线",
"pass": true,
"detail": "toolAxisMarker=line"
},
{
"name": "场景对象数量",
"pass": true,
"detail": "sceneObjects=20"
},
{
"name": "无 G-code 语义生成",
"pass": true,
"detail": "semanticGuard=ok"
},
{
"name": "刀路预览点",
"pass": true,
"detail": "pathPoints=1498"
},
{
"name": "执行轨迹点",
"pass": true,
"detail": "executed=1"
},
{
"name": "scene units are meters",
"pass": true,
"detail": "sceneUnits=m"
},
{
"name": "linear scale visible",
"pass": true,
"detail": "scale=0.001"
},
{
"name": "path fit bounds",
"pass": true,
"detail": "fit=ok"
},
{
"name": "path bounds in meters",
"pass": true,
"detail": "bounds={\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}"
},
{
"name": "canvas nonblank",
"pass": true,
"detail": "nonBlack=0.8173"
}
]
},
{
"viewport": {
"name": "mobile",
"width": 390,
"height": 844,
"deviceScaleFactor": 2
},
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/meter-scene-evidence/08-meter-scene-mobile.png",
"pixelStats": {
"width": 1146,
"height": 1036,
"averageLuminance": 67.87,
"nonBlackRatio": 0.838
},
"dataset": {
"fiveAxisCanvas": "true",
"engine": "three.js r183",
"threeReady": "true",
"threeRevision": "183",
"threePathPoints": "1498",
"threeExecutedPathPoints": "1",
"threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0.558,\"y\":0.769,\"z\":0.312}",
"threeTcpPose": "{\"x\":36.474,\"y\":-22.486,\"z\":-13.749,\"a\":-71.841,\"c\":-35.93}",
"threeRtcpState": "on",
"threeSelectedView": "iso",
"threeFrameApi": "web-rtcp-5axis-motion-frame",
"threeRenderer": "webgl",
"threeSceneMode": "program-preview-and-tool-execution",
"threePreviewScope": "machine-reference-and-toolpath",
"threeMachineReferenceModel": "webgl-five-axis-reference",
"threeCameraControls": "orbit-pan-zoom",
"threeProgramPreviewSource": "linuxcnc-interpreter-wasm",
"threeToolExecutionMarker": "true",
"threeTcpMarker": "sphere",
"threeToolAxisMarker": "line",
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}",
"threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok",
"threeRapidPathPoints": "186",
"threeFeedPathPoints": "1436",
"threeArcPathPoints": "0",
"threeCurrentSegmentPoints": "1",
"threeCurrentSegmentType": "STRAIGHT_TRAVERSE"
},
"status": "PASS",
"checks": [
{
"name": "canvas ready",
"pass": true,
"detail": "threeReady=true"
},
{
"name": "WebGL renderer",
"pass": true,
"detail": "renderer=webgl"
},
{
"name": "机床参考模型",
"pass": true,
"detail": "scope=machine-reference-and-toolpath, model=webgl-five-axis-reference"
},
{
"name": "TCP 球标记",
"pass": true,
"detail": "tcp=sphere, marker=true"
},
{
"name": "刀轴线",
"pass": true,
"detail": "toolAxisMarker=line"
},
{
"name": "场景对象数量",
"pass": true,
"detail": "sceneObjects=20"
},
{
"name": "无 G-code 语义生成",
"pass": true,
"detail": "semanticGuard=ok"
},
{
"name": "刀路预览点",
"pass": true,
"detail": "pathPoints=1498"
},
{
"name": "执行轨迹点",
"pass": true,
"detail": "executed=1"
},
{
"name": "scene units are meters",
"pass": true,
"detail": "sceneUnits=m"
},
{
"name": "linear scale visible",
"pass": true,
"detail": "scale=0.001"
},
{
"name": "path fit bounds",
"pass": true,
"detail": "fit=ok"
},
{
"name": "path bounds in meters",
"pass": true,
"detail": "bounds={\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}"
},
{
"name": "canvas nonblank",
"pass": true,
"detail": "nonBlack=0.838"
}
]
}
],
"consoleErrors": [
"Failed to load resource: the server responded with a status of 404 (Not Found)",
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,565 @@
{
"generatedAt": "2026-06-22T22:10:40.219Z",
"targetUrl": "http://127.0.0.1:45757/web-rtcp-5axis-sim-plan/app/index.html",
"caseName": "07-run-preconditions-and-feedback",
"summary": "浏览器 RUN 证据INI/profile/kinematics/task-HAL 同一上下文taskHalStatusLoop 持续推进UI 消费 task/HAL/motion feedback",
"status": "PASS",
"checks": [
{
"name": "INI ready",
"pass": true,
"detail": "iniReady=true,true,true,true,true"
},
{
"name": "selected LinuxCNC G-code",
"pass": true,
"detail": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc"
},
{
"name": "task/HAL session opened selected G-code",
"pass": true,
"detail": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc"
},
{
"name": "taskHalStatusLoop 新 RUN sequence",
"pass": true,
"detail": "baselineSequence=1, sequences=2,2,2,2,2"
},
{
"name": "taskHalStatusLoop 本次 RUN tickCount",
"pass": true,
"detail": "ticks=4,4,4,4,4"
},
{
"name": "programRuntimeFeedbackHistory 本次 RUN 采样",
"pass": true,
"detail": "history=5,5,5,5,5"
},
{
"name": "feedback sourceMode 来自 task/HAL",
"pass": true,
"detail": "sourceModes=linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm"
},
{
"name": "无 fixture-line-playback feedback",
"pass": true,
"detail": "sourceModes=linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm"
},
{
"name": "semantic boundary",
"pass": true,
"detail": "linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime"
},
{
"name": "activeLine 等于 UI 高亮行",
"pass": true,
"detail": "active=29/29,29/29,29/29,29/29,29/29"
},
{
"name": "DRO 等于 runtime feedback axisPose",
"pass": true,
"detail": "droMatchesFeedback=true for all samples"
},
{
"name": "RTCP canvas 与 state 一致",
"pass": true,
"detail": "off/off,off/off,off/off,off/off,off/off"
},
{
"name": "runtime cycle 可见",
"pass": true,
"detail": "33/330,33/330,33/330,33/330,33/330"
},
{
"name": "canvas 执行轨迹",
"pass": true,
"detail": "true/1/ok,true/1/ok,true/1/ok,true/1/ok,true/1/ok"
}
],
"baseline": {
"elapsedMs": 0,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-0000ms-before-run.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 1,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 12,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:27.362Z",
"lastError": null,
"stopReason": "running",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "idle",
"activeLine": 1,
"activeRowLine": 1,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 1,
"taskCycle": 3,
"servoCycle": 30,
"velocity": 0,
"distanceToGo": 0
},
"feedbackHistoryLength": 18,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 30
}
}
},
"samples": [
{
"elapsedMs": 200,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-200ms.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 2,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 4,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:35.152Z",
"lastError": null,
"stopReason": "complete",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "complete",
"activeLine": 29,
"activeRowLine": 29,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 29,
"taskCycle": 33,
"servoCycle": 330,
"velocity": 3600,
"distanceToGo": 0
},
"feedbackHistoryLength": 5,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 330
}
}
},
{
"elapsedMs": 500,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-500ms.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 2,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 4,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:35.152Z",
"lastError": null,
"stopReason": "complete",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "complete",
"activeLine": 29,
"activeRowLine": 29,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 29,
"taskCycle": 33,
"servoCycle": 330,
"velocity": 3600,
"distanceToGo": 0
},
"feedbackHistoryLength": 5,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 330
}
}
},
{
"elapsedMs": 1000,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-1000ms.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 2,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 4,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:35.152Z",
"lastError": null,
"stopReason": "complete",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "complete",
"activeLine": 29,
"activeRowLine": 29,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 29,
"taskCycle": 33,
"servoCycle": 330,
"velocity": 3600,
"distanceToGo": 0
},
"feedbackHistoryLength": 5,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 330
}
}
},
{
"elapsedMs": 2000,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-2000ms.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 2,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 4,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:35.152Z",
"lastError": null,
"stopReason": "complete",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "complete",
"activeLine": 29,
"activeRowLine": 29,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 29,
"taskCycle": 33,
"servoCycle": 330,
"velocity": 3600,
"distanceToGo": 0
},
"feedbackHistoryLength": 5,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 330
}
}
},
{
"elapsedMs": 5000,
"screenshotPath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/07-run-preconditions-and-feedback-5000ms.png",
"canvas": {
"threeReady": "true",
"rtcpState": "off",
"executedPathPoints": 1,
"currentSegmentHighlight": "ok",
"toolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback"
},
"state": {
"profileId": "xyzac-trt",
"iniReady": true,
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"coordinates": "XYZAC",
"kinematicsModuleId": "xyzac-trt",
"selectedGcodeSourceRel": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"taskHalSessionProgramPath": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc",
"taskHalStatusLoop": {
"apiName": "web-rtcp-5axis-task-hal-status-loop",
"active": false,
"sequence": 2,
"profileId": "xyzac-trt",
"iniPath": "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"kinematicsModuleId": "xyzac-trt",
"tickCount": 4,
"batchSize": 5,
"intervalMs": 25,
"taskPeriodNs": 10000000,
"servoPeriodNs": 1000000,
"lastStatusAt": "2026-06-22T22:10:35.152Z",
"lastError": null,
"stopReason": "complete",
"semanticBoundary": "js_status_polling_loop_for_linuxcnc_task_hal_motion_status"
},
"runState": "complete",
"activeLine": 29,
"activeRowLine": 29,
"activeLineMatchesUi": true,
"droAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"feedbackAxisPose": {
"x": 0,
"y": 0,
"z": 0,
"a": 0,
"b": 0,
"c": 0
},
"droMatchesFeedback": true,
"rtcpState": "off",
"kinsType": "identity",
"feedback": {
"sourceMode": "linuxcnc-task-motion-hal-wasm",
"semanticBoundary": "linuxcnc_task_motion_hal_wasm_simulation_runtime",
"line": 29,
"taskCycle": 33,
"servoCycle": 330,
"velocity": 3600,
"distanceToGo": 0
},
"feedbackHistoryLength": 5,
"feedbackHistorySourceModes": [
"linuxcnc-task-motion-hal-wasm"
],
"taskHalStatus": {
"taskState": "on",
"taskMode": "auto",
"interpState": "idle",
"taskCycle": null,
"servoCycle": 330
}
}
}
],
"consoleErrors": [
"Failed to load resource: the server responded with a status of 404 (Not Found)",
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"generatedAt": "2026-06-22T13:12:11.146Z", "generatedAt": "2026-06-22T22:09:28.057Z",
"targetUrl": "http://127.0.0.1:43627/web-rtcp-5axis-sim-plan/app/index.html", "targetUrl": "http://127.0.0.1:45757/web-rtcp-5axis-sim-plan/app/index.html",
"projectPath": "/home/meswork/cnc_wams/web-rtcp-5axis-sim-plan/app/index.html", "projectPath": "/home/meswork/cnc_wams/web-rtcp-5axis-sim-plan/app/index.html",
"chromePath": "/usr/bin/google-chrome", "chromePath": "/usr/bin/google-chrome",
"screenshots": { "screenshots": {
@@ -9,6 +9,7 @@
"03-arc-demo-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/03-arc-demo-toolpath.png", "03-arc-demo-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/03-arc-demo-toolpath.png",
"04-clear-preview-reference": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/04-clear-preview-reference.png", "04-clear-preview-reference": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/04-clear-preview-reference.png",
"05-vendored-impeller-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/05-vendored-impeller-toolpath.png", "05-vendored-impeller-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/05-vendored-impeller-toolpath.png",
"09-g20-g21-mixed-units-preview": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/09-g20-g21-mixed-units-preview.png",
"06-running-rtcp-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/06-running-rtcp-toolpath.png" "06-running-rtcp-toolpath": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/06-running-rtcp-toolpath.png"
}, },
"cases": [ "cases": [
@@ -66,8 +67,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 62.52, "averageLuminance": 68.18,
"nonBlackRatio": 0.7491 "nonBlackRatio": 0.8681
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -77,7 +78,10 @@
"threePathPoints": "2", "threePathPoints": "2",
"threeExecutedPathPoints": "1", "threeExecutedPathPoints": "1",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0.35}", "threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}", "threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}",
"threeTcpPose": "{\"x\":0,\"y\":0,\"z\":0,\"a\":0,\"c\":0}", "threeTcpPose": "{\"x\":0,\"y\":0,\"z\":0,\"a\":0,\"c\":0}",
"threeRtcpState": "on", "threeRtcpState": "on",
@@ -95,6 +99,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":0,\"y\":0,\"z\":0.002},\"size\":{\"x\":0,\"y\":0,\"z\":0.005},\"maxSpan\":0.005}",
"threeCurrentSegmentHighlight": "ok", "threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok", "threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -207,8 +212,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 59.4, "averageLuminance": 66.03,
"nonBlackRatio": 0.7092 "nonBlackRatio": 0.854
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -218,7 +223,10 @@
"threePathPoints": "6", "threePathPoints": "6",
"threeExecutedPathPoints": "1", "threeExecutedPathPoints": "1",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0.35}", "threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}", "threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}",
"threeTcpPose": "{\"x\":0,\"y\":0,\"z\":0,\"a\":0,\"c\":0}", "threeTcpPose": "{\"x\":0,\"y\":0,\"z\":0,\"a\":0,\"c\":0}",
"threeRtcpState": "on", "threeRtcpState": "on",
@@ -236,6 +244,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":0.005,\"y\":0.005,\"z\":0.002},\"size\":{\"x\":0.01,\"y\":0.01,\"z\":0.005},\"maxSpan\":0.01}",
"threeCurrentSegmentHighlight": "ok", "threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok", "threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -346,8 +355,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 65.26, "averageLuminance": 67.56,
"nonBlackRatio": 0.7885 "nonBlackRatio": 0.8687
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -357,7 +366,10 @@
"threePathPoints": "2", "threePathPoints": "2",
"threeExecutedPathPoints": "1", "threeExecutedPathPoints": "1",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0.35}", "threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}", "threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}",
"threeTcpPose": "{\"x\":1,\"y\":0,\"z\":0,\"a\":0,\"c\":0}", "threeTcpPose": "{\"x\":1,\"y\":0,\"z\":0,\"a\":0,\"c\":0}",
"threeRtcpState": "on", "threeRtcpState": "on",
@@ -375,6 +387,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":0.001,\"y\":0.001,\"z\":0},\"size\":{\"x\":0.001,\"y\":0.001,\"z\":0},\"maxSpan\":0.001}",
"threeCurrentSegmentHighlight": "ok", "threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok", "threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -487,8 +500,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 64.72, "averageLuminance": 67.78,
"nonBlackRatio": 0.7874 "nonBlackRatio": 0.8701
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -498,7 +511,10 @@
"threePathPoints": "0", "threePathPoints": "0",
"threeExecutedPathPoints": "0", "threeExecutedPathPoints": "0",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0.35}", "threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}", "threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}",
"threeTcpPose": "{\"x\":1,\"y\":0,\"z\":0,\"a\":0,\"c\":0}", "threeTcpPose": "{\"x\":1,\"y\":0,\"z\":0,\"a\":0,\"c\":0}",
"threeRtcpState": "on", "threeRtcpState": "on",
@@ -516,6 +532,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "null",
"threeCurrentSegmentHighlight": "pending", "threeCurrentSegmentHighlight": "pending",
"threeRapidFeedVisualDistinction": "pending", "threeRapidFeedVisualDistinction": "pending",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -643,8 +660,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 35.56, "averageLuminance": 65.18,
"nonBlackRatio": 0.4577 "nonBlackRatio": 0.8173
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -654,7 +671,10 @@
"threePathPoints": "1498", "threePathPoints": "1498",
"threeExecutedPathPoints": "1", "threeExecutedPathPoints": "1",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0.35}", "threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0.558,\"y\":0.769,\"z\":0.312}", "threeToolAxis": "{\"x\":0.558,\"y\":0.769,\"z\":0.312}",
"threeTcpPose": "{\"x\":36.474,\"y\":-22.486,\"z\":-13.749,\"a\":-71.841,\"c\":-35.93}", "threeTcpPose": "{\"x\":36.474,\"y\":-22.486,\"z\":-13.749,\"a\":-71.841,\"c\":-35.93}",
"threeRtcpState": "on", "threeRtcpState": "on",
@@ -672,6 +692,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}",
"threeCurrentSegmentHighlight": "ok", "threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok", "threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -726,6 +747,176 @@
"programRuntimeFeedbackSource": "linuxcnc-tp-runtime-sample" "programRuntimeFeedbackSource": "linuxcnc-tp-runtime-sample"
} }
}, },
{
"name": "08-meter-scene-desktop-mobile",
"summary": "浏览器米尺度 Three.js 证据desktop/mobile canvas 非空、路径 bounds 为米、预览稳定居中",
"status": "PASS",
"screenshotDir": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/meter-scene-evidence",
"output": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/output/meter-scene-evidence.json",
"sampleCount": 2
},
{
"name": "09-g20-g21-mixed-units-preview",
"summary": "G20/G21 混合单位程序:验证 canonical motion 保留 inch/mmThree.js 统一按米绘制",
"status": "PASS",
"checks": [
{
"name": "canvas ready",
"pass": true,
"detail": "threeReady=true"
},
{
"name": "WebGL renderer",
"pass": true,
"detail": "renderer=webgl"
},
{
"name": "机床参考模型",
"pass": true,
"detail": "scope=machine-reference-and-toolpath, model=webgl-five-axis-reference"
},
{
"name": "TCP 球标记",
"pass": true,
"detail": "tcp=sphere, marker=true"
},
{
"name": "刀轴线",
"pass": true,
"detail": "toolAxisMarker=line"
},
{
"name": "场景对象数量",
"pass": true,
"detail": "sceneObjects=20"
},
{
"name": "无 G-code 语义生成",
"pass": true,
"detail": "semanticGuard=ok"
},
{
"name": "刀路预览点",
"pass": true,
"detail": "pathPoints=2"
},
{
"name": "执行轨迹点",
"pass": true,
"detail": "executed=1"
},
{
"name": "G20/G21 motion units",
"pass": true,
"detail": "units=inch,mm"
},
{
"name": "scene units are meters",
"pass": true,
"detail": "sceneUnits=m"
},
{
"name": "mixed unit path visible",
"pass": true,
"detail": "pathPoints=2"
},
{
"name": "1 inch equals 25.4 mm in scene",
"pass": true,
"detail": "xMeters=0.0254,0.0254"
},
{
"name": "no unit fallback in canonical motion",
"pass": true,
"detail": "source=linuxcnc-interpreter-wasm"
}
],
"pixelStats": {
"width": 803,
"height": 874,
"averageLuminance": 64.71,
"nonBlackRatio": 0.849
},
"dataset": {
"fiveAxisCanvas": "true",
"engine": "three.js r183",
"threeReady": "true",
"threeRevision": "183",
"threePathPoints": "2",
"threeExecutedPathPoints": "1",
"threeSceneObjects": "20",
"threeToolhead": "{\"x\":0,\"y\":0,\"z\":0}",
"threeSceneUnits": "m",
"threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0,\"y\":0,\"z\":1}",
"threeTcpPose": "{\"x\":43,\"y\":-32.15,\"z\":-11.306,\"a\":0,\"c\":0}",
"threeRtcpState": "on",
"threeSelectedView": "iso",
"threeFrameApi": "web-rtcp-5axis-motion-frame",
"threeRenderer": "webgl",
"threeSceneMode": "program-preview-and-tool-execution",
"threePreviewScope": "machine-reference-and-toolpath",
"threeMachineReferenceModel": "webgl-five-axis-reference",
"threeCameraControls": "orbit-pan-zoom",
"threeProgramPreviewSource": "linuxcnc-interpreter-wasm",
"threeToolExecutionMarker": "true",
"threeTcpMarker": "sphere",
"threeToolAxisMarker": "line",
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":0.013,\"y\":0.013,\"z\":0},\"size\":{\"x\":0.025,\"y\":0.025,\"z\":0},\"maxSpan\":0.0254}",
"threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok",
"threeRapidPathPoints": "0",
"threeFeedPathPoints": "2",
"threeArcPathPoints": "0",
"threeCurrentSegmentPoints": "1",
"threeCurrentSegmentType": "STRAIGHT_FEED"
},
"state": {
"activeProgram": "operator-g20-g21-mixed-units.ngc",
"activeLine": 2,
"programSource": "operator-file",
"programExecutionSourceMode": "linuxcnc-interpreter-wasm",
"programExecutionSummary": {
"ready": true,
"programLineCount": 5,
"canonicalEventCount": 22,
"motionEventCount": 2,
"motionTypes": [
"STRAIGHT_FEED"
],
"finalAxes": {
"x": 25.4,
"y": 25.4,
"z": 0,
"a": 0,
"b": 0,
"c": 0,
"u": 0,
"v": 0,
"w": 0
},
"switchkinsEventCount": 0,
"switchkinsCodes": [],
"switchkinsRemapBoundary": null,
"remapRuntimeReady": false,
"plannerRuntimeReady": true,
"plannerSemanticBoundary": "linuxcnc_tp_queue_runtime_timing_from_canonical_motion",
"machineFileExecutionReady": false,
"fullLinuxCncProgramExecutionReady": false
},
"runState": "idle",
"rtcpState": "on",
"kinsType": "tcp-xyzac",
"programExecutionMotionIndex": 0,
"programExecutionSampleIndex": 0,
"programRuntimeFeedbackSource": "linuxcnc-tp-runtime-sample"
}
},
{ {
"name": "06-running-rtcp-toolpath", "name": "06-running-rtcp-toolpath",
"summary": "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", "summary": "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随",
@@ -790,8 +981,8 @@
"pixelStats": { "pixelStats": {
"width": 803, "width": 803,
"height": 874, "height": 874,
"averageLuminance": 36.05, "averageLuminance": 65.86,
"nonBlackRatio": 0.4607 "nonBlackRatio": 0.8173
}, },
"dataset": { "dataset": {
"fiveAxisCanvas": "true", "fiveAxisCanvas": "true",
@@ -801,9 +992,12 @@
"threePathPoints": "1498", "threePathPoints": "1498",
"threeExecutedPathPoints": "1", "threeExecutedPathPoints": "1",
"threeSceneObjects": "20", "threeSceneObjects": "20",
"threeToolhead": "{\"x\":0.26,\"y\":-0.458,\"z\":1.485}", "threeToolhead": "{\"x\":-0.023,\"y\":-0.033,\"z\":0.001}",
"threeToolAxis": "{\"x\":0.558,\"y\":0.769,\"z\":0.312}", "threeSceneUnits": "m",
"threeTcpPose": "{\"x\":7.417,\"y\":-13.098,\"z\":28.366,\"a\":-71.841,\"c\":-35.93}", "threeLinearUnits": "mm",
"threeLinearUnitScaleToMeters": "0.001",
"threeToolAxis": "{\"x\":0.621,\"y\":0.503,\"z\":0.601}",
"threeTcpPose": "{\"x\":-75.066,\"y\":-75.473,\"z\":-49.344,\"a\":-53.043,\"c\":-50.969}",
"threeRtcpState": "on", "threeRtcpState": "on",
"threeSelectedView": "iso", "threeSelectedView": "iso",
"threeFrameApi": "web-rtcp-5axis-motion-frame", "threeFrameApi": "web-rtcp-5axis-motion-frame",
@@ -819,6 +1013,7 @@
"threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion", "threeToolpathPreviewSource": "linuxcnc_interpreter_canonical_motion",
"threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback", "threeToolExecutionTraceSource": "linuxcnc_tp_samples_or_task_motion_hal_feedback",
"threePathFitBounds": "ok", "threePathFitBounds": "ok",
"threePathBoundsMeters": "{\"center\":{\"x\":-0.001,\"y\":0,\"z\":0.019},\"size\":{\"x\":0.088,\"y\":0.089,\"z\":0.043},\"maxSpan\":0.089211}",
"threeCurrentSegmentHighlight": "ok", "threeCurrentSegmentHighlight": "ok",
"threeRapidFeedVisualDistinction": "ok", "threeRapidFeedVisualDistinction": "ok",
"threeNoGcodeSemanticsGeneration": "ok", "threeNoGcodeSemanticsGeneration": "ok",
@@ -830,7 +1025,7 @@
}, },
"state": { "state": {
"activeProgram": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc", "activeProgram": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
"activeLine": 5, "activeLine": 65,
"programSource": "linuxcnc-vendored-5axis-gcode", "programSource": "linuxcnc-vendored-5axis-gcode",
"programExecutionSourceMode": "linuxcnc-interpreter-wasm", "programExecutionSourceMode": "linuxcnc-interpreter-wasm",
"programExecutionSummary": { "programExecutionSummary": {
@@ -872,6 +1067,86 @@
"programExecutionSampleIndex": 0, "programExecutionSampleIndex": 0,
"programRuntimeFeedbackSource": "linuxcnc-task-motion-hal-wasm" "programRuntimeFeedbackSource": "linuxcnc-task-motion-hal-wasm"
} }
},
{
"name": "07-run-preconditions-and-feedback",
"summary": "浏览器 RUN 证据INI/profile/kinematics/task-HAL 同一上下文taskHalStatusLoop 持续推进UI 消费 task/HAL/motion feedback",
"status": "PASS",
"checks": [
{
"name": "INI ready",
"pass": true,
"detail": "iniReady=true,true,true,true,true"
},
{
"name": "selected LinuxCNC G-code",
"pass": true,
"detail": "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc"
},
{
"name": "task/HAL session opened selected G-code",
"pass": true,
"detail": "/work/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt/demos/xyzac_switchkins_test_1.ngc"
},
{
"name": "taskHalStatusLoop 新 RUN sequence",
"pass": true,
"detail": "baselineSequence=1, sequences=2,2,2,2,2"
},
{
"name": "taskHalStatusLoop 本次 RUN tickCount",
"pass": true,
"detail": "ticks=4,4,4,4,4"
},
{
"name": "programRuntimeFeedbackHistory 本次 RUN 采样",
"pass": true,
"detail": "history=5,5,5,5,5"
},
{
"name": "feedback sourceMode 来自 task/HAL",
"pass": true,
"detail": "sourceModes=linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm"
},
{
"name": "无 fixture-line-playback feedback",
"pass": true,
"detail": "sourceModes=linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm,linuxcnc-task-motion-hal-wasm"
},
{
"name": "semantic boundary",
"pass": true,
"detail": "linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime,linuxcnc_task_motion_hal_wasm_simulation_runtime"
},
{
"name": "activeLine 等于 UI 高亮行",
"pass": true,
"detail": "active=29/29,29/29,29/29,29/29,29/29"
},
{
"name": "DRO 等于 runtime feedback axisPose",
"pass": true,
"detail": "droMatchesFeedback=true for all samples"
},
{
"name": "RTCP canvas 与 state 一致",
"pass": true,
"detail": "off/off,off/off,off/off,off/off,off/off"
},
{
"name": "runtime cycle 可见",
"pass": true,
"detail": "33/330,33/330,33/330,33/330,33/330"
},
{
"name": "canvas 执行轨迹",
"pass": true,
"detail": "true/1/ok,true/1/ok,true/1/ok,true/1/ok,true/1/ok"
}
],
"screenshotDir": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback",
"output": "/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/output/run-preconditions-feedback.json",
"sampleCount": 5
} }
], ],
"consoleErrors": [ "consoleErrors": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -143,3 +143,6 @@ When extending this workspace:
2. Keep extraction and patching reproducible. 2. Keep extraction and patching reproducible.
3. Keep adapters narrow and explicit. 3. Keep adapters narrow and explicit.
4. Keep LinuxCNC-derived logic traceable to its upstream file origin. 4. Keep LinuxCNC-derived logic traceable to its upstream file origin.
5. After every GPT/Codex execution completes, append the full execution
process log to
`/home/meswork/cnc_wams/web-rtcp-5axis-sim-plan/gptlog-process/gpdlog.md`.

View File

@@ -7,6 +7,7 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <cmath>
#include <map> #include <map>
#include <sstream> #include <sstream>
#include <string> #include <string>
@@ -14,6 +15,17 @@
namespace { namespace {
struct MotionSegment {
int line = 0;
std::string type;
std::string motion_class;
double start_seconds = 0.0;
double duration_seconds = 0.0;
double velocity_mm_per_min = 0.0;
std::map<std::string, double> start_axes;
std::map<std::string, double> end_axes;
};
struct TaskRuntime { struct TaskRuntime {
bool initialized = false; bool initialized = false;
std::string state = "ESTOP"; std::string state = "ESTOP";
@@ -22,9 +34,16 @@ struct TaskRuntime {
std::string exec_state = "DONE"; std::string exec_state = "DONE";
std::string open_program; std::string open_program;
int opened_line_count = 0; int opened_line_count = 0;
int opened_source_line_count = 0;
int executable_line_count = 0;
int next_program_line = 0; int next_program_line = 0;
long long task_cycle = 0; long long task_cycle = 0;
long long servo_cycle = 0; long long servo_cycle = 0;
bool motion_plan_loaded = false;
int active_segment_index = 0;
double run_elapsed_seconds = 0.0;
std::string motion_plan_program_path;
std::vector<MotionSegment> motion_plan;
std::map<std::string, std::string> staged_files; std::map<std::string, std::string> staged_files;
std::vector<std::string> program_lines; std::vector<std::string> program_lines;
std::vector<std::string> events; std::vector<std::string> events;
@@ -141,6 +160,111 @@ int json_int_after(const char *json, const char *key, int fallback)
return static_cast<int>(json_number_after(json, key, fallback)); return static_cast<int>(json_number_after(json, key, fallback));
} }
std::size_t matching_brace(const std::string &text, std::size_t open)
{
int depth = 0;
bool in_string = false;
bool escaped = false;
for (std::size_t i = open; i < text.size(); ++i) {
const char ch = text[i];
if (in_string) {
if (escaped) {
escaped = false;
} else if (ch == '\\') {
escaped = true;
} else if (ch == '"') {
in_string = false;
}
continue;
}
if (ch == '"') {
in_string = true;
} else if (ch == '{') {
depth += 1;
} else if (ch == '}') {
depth -= 1;
if (depth == 0) {
return i;
}
}
}
return std::string::npos;
}
std::string json_object_after(const std::string &json, const char *key)
{
const std::size_t key_pos = json.find(key);
if (key_pos == std::string::npos) {
return "";
}
const std::size_t open = json.find('{', key_pos);
if (open == std::string::npos) {
return "";
}
const std::size_t close = matching_brace(json, open);
if (close == std::string::npos) {
return "";
}
return json.substr(open, close - open + 1);
}
std::map<std::string, double> json_axes_object_after(const std::string &json, const char *key)
{
std::map<std::string, double> axes;
const std::string object = json_object_after(json, key);
for (const char *axis : {"x", "y", "z", "a", "b", "c", "u", "v", "w"}) {
axes[axis] = json_number_after(object.c_str(), (std::string("\"") + axis + "\"").c_str(), 0.0);
}
return axes;
}
std::vector<std::string> json_segment_objects(const std::string &json)
{
std::vector<std::string> segments;
std::size_t at = json.find("\"segments\"");
if (at == std::string::npos) {
return segments;
}
at = json.find('[', at);
if (at == std::string::npos) {
return segments;
}
while (at < json.size()) {
const std::size_t open = json.find('{', at);
const std::size_t close_array = json.find(']', at);
if (open == std::string::npos || (close_array != std::string::npos && close_array < open)) {
break;
}
const std::size_t close = matching_brace(json, open);
if (close == std::string::npos) {
break;
}
segments.push_back(json.substr(open, close - open + 1));
at = close + 1;
}
return segments;
}
double clamp_double(double value, double low, double high)
{
return std::min(std::max(value, low), high);
}
std::string axis_json(const std::map<std::string, double> &start_axes,
const std::map<std::string, double> &end_axes,
double progress)
{
std::ostringstream out;
for (const char *axis : {"x", "y", "z", "a", "b", "c"}) {
const auto start_it = start_axes.find(axis);
const auto end_it = end_axes.find(axis);
const double start = start_it == start_axes.end() ? 0.0 : start_it->second;
const double end = end_it == end_axes.end() ? start : end_it->second;
out << ",\"" << axis << "\":" << (start + (end - start) * progress);
}
return out.str();
}
std::string trim_copy(const std::string &value) std::string trim_copy(const std::string &value)
{ {
std::size_t begin = 0; std::size_t begin = 0;
@@ -198,12 +322,104 @@ void enqueue_linear_move_from_line(TaskRuntime &state, const std::string &line)
line.find('B') == std::string::npos && line.find('C') == std::string::npos) { line.find('B') == std::string::npos && line.find('C') == std::string::npos) {
command << ",\"x\":" << fallback; command << ",\"x\":" << fallback;
} }
command << ",\"velocity\":60}"; const std::size_t feed_pos = line.find('F');
double velocity_units_per_second = 60.0;
if (feed_pos != std::string::npos) {
char *end = nullptr;
const double feed_units_per_minute = std::strtod(line.c_str() + feed_pos + 1, &end);
if (end != line.c_str() + feed_pos + 1 && feed_units_per_minute > 0.0) {
velocity_units_per_second = feed_units_per_minute / 60.0;
}
}
command << ",\"velocity\":" << velocity_units_per_second << "}";
forward_motion_command(command.str()); forward_motion_command(command.str());
state.events.push_back("task_queue_motion_line:" + std::to_string(line_number)); state.events.push_back("task_queue_motion_line:" + std::to_string(line_number));
state.next_program_line += 1; state.next_program_line += 1;
} }
bool load_motion_plan(TaskRuntime &state, const char *plan_json)
{
if (!plan_json) {
return false;
}
const std::string json(plan_json);
std::vector<MotionSegment> segments;
for (const std::string &item : json_segment_objects(json)) {
MotionSegment segment;
segment.line = json_int_after(item.c_str(), "\"line\"", 0);
segment.type = json_string_after(item.c_str(), "\"type\"", "");
segment.motion_class = json_string_after(item.c_str(), "\"motionClass\"", "");
segment.start_seconds = json_number_after(item.c_str(), "\"startSeconds\"", 0.0);
segment.duration_seconds = std::max(json_number_after(item.c_str(), "\"durationSeconds\"", 0.0), 0.0);
segment.velocity_mm_per_min = std::max(json_number_after(item.c_str(), "\"velocityMmPerMin\"", 0.0), 0.0);
segment.start_axes = json_axes_object_after(item, "\"startAxes\"");
segment.end_axes = json_axes_object_after(item, "\"endAxes\"");
if (segment.line <= 0) {
segment.line = static_cast<int>(segments.size()) + 1;
}
segments.push_back(segment);
}
if (segments.empty()) {
return false;
}
state.motion_plan = std::move(segments);
state.motion_plan_loaded = true;
state.active_segment_index = 0;
state.run_elapsed_seconds = 0.0;
state.motion_plan_program_path = json_string_after(plan_json, "\"programPath\"", state.open_program);
if (!state.motion_plan.empty()) {
state.opened_source_line_count = std::max(
state.opened_source_line_count,
state.motion_plan.back().line);
}
state.events.push_back("task_motion_plan_loaded:" + std::to_string(state.motion_plan.size()));
return true;
}
void forward_timed_motion_sample(TaskRuntime &state)
{
if (!state.motion_plan_loaded || state.motion_plan.empty()) {
return;
}
while (state.active_segment_index < static_cast<int>(state.motion_plan.size()) - 1) {
const MotionSegment &segment = state.motion_plan[state.active_segment_index];
const double end_seconds = segment.start_seconds + segment.duration_seconds;
if (state.run_elapsed_seconds < end_seconds) {
break;
}
state.active_segment_index += 1;
}
if (state.active_segment_index >= static_cast<int>(state.motion_plan.size())) {
state.interp_state = "IDLE";
state.exec_state = "DONE";
state.next_program_line = std::max(state.opened_line_count, state.opened_source_line_count);
return;
}
const MotionSegment &segment = state.motion_plan[state.active_segment_index];
const double duration = std::max(segment.duration_seconds, 0.000001);
const double progress = clamp_double((state.run_elapsed_seconds - segment.start_seconds) / duration, 0.0, 1.0);
const double velocity_units_per_second = segment.velocity_mm_per_min / 60.0;
std::ostringstream command;
command << "{\"type\":\"EMC_TRAJ_LINEAR_MOVE\",\"source\":\"feed_timed_motion_plan\"";
command << ",\"line\":" << segment.line;
command << axis_json(segment.start_axes, segment.end_axes, progress);
command << ",\"velocity\":" << velocity_units_per_second;
command << ",\"currentVel\":" << velocity_units_per_second;
command << ",\"requestedVel\":" << velocity_units_per_second;
command << ",\"segmentProgress\":" << progress << "}";
forward_motion_command(command.str());
const int max_line = std::max(state.opened_line_count, state.opened_source_line_count);
state.next_program_line = std::min(std::max(segment.line, 1), std::max(max_line, 1));
if (state.active_segment_index == static_cast<int>(state.motion_plan.size()) - 1 && progress >= 1.0) {
state.interp_state = "IDLE";
state.exec_state = "DONE";
state.next_program_line = std::max(state.opened_line_count, state.opened_source_line_count);
state.events.push_back("task_motion_plan_complete");
}
}
void enqueue_mdi(TaskRuntime &state, const char *json) void enqueue_mdi(TaskRuntime &state, const char *json)
{ {
const std::string mdi = json_string_after(json, "\"mdi\""); const std::string mdi = json_string_after(json, "\"mdi\"");
@@ -265,6 +481,8 @@ std::string status_json()
out << ",\"cycle\":" << state.task_cycle; out << ",\"cycle\":" << state.task_cycle;
out << ",\"openProgram\":\"" << json_escape(state.open_program) << "\""; out << ",\"openProgram\":\"" << json_escape(state.open_program) << "\"";
out << ",\"openedLineCount\":" << state.opened_line_count; out << ",\"openedLineCount\":" << state.opened_line_count;
out << ",\"openedSourceLineCount\":" << state.opened_source_line_count;
out << ",\"executableLineCount\":" << state.executable_line_count;
out << ",\"nextProgramLine\":" << state.next_program_line << "}"; out << ",\"nextProgramLine\":" << state.next_program_line << "}";
out << ",\"servoCycle\":" << state.servo_cycle; out << ",\"servoCycle\":" << state.servo_cycle;
out << ",\"motionStatus\":" << read_motion_status_json(); out << ",\"motionStatus\":" << read_motion_status_json();
@@ -341,14 +559,30 @@ int lctask_open_program(const char *path)
} }
state.open_program = path; state.open_program = path;
state.program_lines = split_program_lines(it->second); state.program_lines = split_program_lines(it->second);
state.opened_line_count = static_cast<int>(state.program_lines.size()); state.executable_line_count = static_cast<int>(state.program_lines.size());
state.opened_source_line_count = static_cast<int>(std::count(it->second.begin(), it->second.end(), '\n'));
if (!it->second.empty() && it->second.back() != '\n') {
state.opened_source_line_count += 1;
}
state.opened_line_count = state.opened_source_line_count;
state.next_program_line = 0; state.next_program_line = 0;
state.active_segment_index = 0;
state.run_elapsed_seconds = 0.0;
state.interp_state = "IDLE"; state.interp_state = "IDLE";
state.exec_state = "DONE"; state.exec_state = "DONE";
state.events.push_back(std::string("task_open_program:") + path); state.events.push_back(std::string("task_open_program:") + path);
return 0; return 0;
} }
int lctask_load_program_motion_plan_json(const char *plan_json)
{
auto &state = task_runtime();
if (!state.initialized || !plan_json) {
return -1;
}
return load_motion_plan(state, plan_json) ? 0 : -1;
}
int lctask_send_command_json(const char *command_json) int lctask_send_command_json(const char *command_json)
{ {
auto &state = task_runtime(); auto &state = task_runtime();
@@ -373,9 +607,11 @@ int lctask_send_command_json(const char *command_json)
if (state.next_program_line < 0) { if (state.next_program_line < 0) {
state.next_program_line = 0; state.next_program_line = 0;
} }
if (state.next_program_line >= state.opened_line_count) { if (!state.motion_plan_loaded && state.next_program_line >= state.executable_line_count) {
state.next_program_line = 0; state.next_program_line = 0;
} }
state.active_segment_index = 0;
state.run_elapsed_seconds = 0.0;
state.interp_state = "READING"; state.interp_state = "READING";
state.exec_state = "WAITING_FOR_MOTION"; state.exec_state = "WAITING_FOR_MOTION";
state.events.push_back("task_plan_run"); state.events.push_back("task_plan_run");
@@ -396,6 +632,7 @@ int lctask_send_command_json(const char *command_json)
if (contains_token(command_json, "EMC_TASK_ABORT")) { if (contains_token(command_json, "EMC_TASK_ABORT")) {
state.interp_state = "IDLE"; state.interp_state = "IDLE";
state.exec_state = "DONE"; state.exec_state = "DONE";
state.run_elapsed_seconds = 0.0;
state.events.push_back("task_abort"); state.events.push_back("task_abort");
return forward_motion_command("{\"type\":\"EMC_TRAJ_ABORT\"}"); return forward_motion_command("{\"type\":\"EMC_TRAJ_ABORT\"}");
} }
@@ -413,6 +650,17 @@ int lctask_send_command_json(const char *command_json)
state.events.push_back("task_jog_incr"); state.events.push_back("task_jog_incr");
return forward_motion_command(command_json); return forward_motion_command(command_json);
} }
if (contains_token(command_json, "EMC_JOINT_HOME")) {
state.mode = "MANUAL";
state.interp_state = "IDLE";
state.exec_state = "DONE";
state.next_program_line = 0;
state.active_segment_index = 0;
state.run_elapsed_seconds = 0.0;
state.events.push_back("task_joint_home");
forward_motion_command("{\"type\":\"EMC_TRAJ_LINEAR_MOVE\",\"line\":1,\"x\":0,\"y\":0,\"z\":0,\"a\":0,\"b\":0,\"c\":0,\"velocity\":0}");
return 0;
}
return -1; return -1;
} }
@@ -431,9 +679,18 @@ int lctask_run_cycles(long task_period_ns, long servo_period_ns, int task_cycles
} }
for (int i = 0; i < task_cycles; ++i) { for (int i = 0; i < task_cycles; ++i) {
state.task_cycle += 1; state.task_cycle += 1;
if (state.interp_state == "READING" && state.next_program_line < state.opened_line_count) { const bool has_program_work = state.motion_plan_loaded
? state.active_segment_index < static_cast<int>(state.motion_plan.size())
: state.next_program_line < state.executable_line_count;
if (state.interp_state == "READING" && has_program_work) {
if (state.motion_plan_loaded) {
const double delta_seconds = task_period_ns > 0 ? static_cast<double>(task_period_ns) / 1000000000.0 : 0.01;
state.run_elapsed_seconds += delta_seconds;
forward_timed_motion_sample(state);
} else {
enqueue_linear_move_from_line(state, state.program_lines[state.next_program_line]); enqueue_linear_move_from_line(state, state.program_lines[state.next_program_line]);
if (state.next_program_line >= state.opened_line_count) { }
if (!state.motion_plan_loaded && state.next_program_line >= state.executable_line_count) {
state.interp_state = "IDLE"; state.interp_state = "IDLE";
state.exec_state = "DONE"; state.exec_state = "DONE";
state.events.push_back("task_plan_complete"); state.events.push_back("task_plan_complete");

View File

@@ -7,6 +7,7 @@ extern "C" {
int lctask_init_session(const char *session_json); int lctask_init_session(const char *session_json);
int lctask_stage_file(const char *path, const char *text); int lctask_stage_file(const char *path, const char *text);
int lctask_open_program(const char *path); int lctask_open_program(const char *path);
int lctask_load_program_motion_plan_json(const char *plan_json);
int lctask_send_command_json(const char *command_json); int lctask_send_command_json(const char *command_json);
int lctask_run_cycles(long task_period_ns, long servo_period_ns, int task_cycles); int lctask_run_cycles(long task_period_ns, long servo_period_ns, int task_cycles);
int lctask_read_status_json(char *out, int out_len); int lctask_read_status_json(char *out, int out_len);

View File

@@ -97,6 +97,10 @@ export async function createLinuxCncTaskHalSdk(moduleOptions = {}) {
); );
}, },
loadProgramMotionPlan(plan) {
return callWithJson(mod, "lctask_load_program_motion_plan_json", plan);
},
sendCommand(command) { sendCommand(command) {
return callWithJson(mod, "lctask_send_command_json", command); return callWithJson(mod, "lctask_send_command_json", command);
}, },

View File

@@ -52,5 +52,116 @@ const events = sdk.readEvents().events;
assert.equal(events.includes("task_plan_run"), true); assert.equal(events.includes("task_plan_run"), true);
assert.equal(events.includes("task_mdi_switchkins:M429"), true); assert.equal(events.includes("task_mdi_switchkins:M429"), true);
sdk.resetSession();
sdk.initSession({
iniPath: "xyzac-trt.ini",
iniText: "[TRAJ]\nCOORDINATES = X Y Z A C\n",
});
assert.equal(sdk.stageFile("programs/feed-plan.ngc", "G1 X120 F60\nG1 X121 F600\n"), 0);
assert.equal(sdk.openProgram("programs/feed-plan.ngc"), 0);
assert.equal(sdk.loadProgramMotionPlan({
programPath: "programs/feed-plan.ngc",
segments: [
{
line: 1,
type: "STRAIGHT_FEED",
motionClass: "feed",
feedMode: "units-per-minute",
startSeconds: 0,
durationSeconds: 120,
velocityMmPerMin: 60,
startAxes: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 },
endAxes: { x: 120, y: 0, z: 0, a: 0, b: 0, c: 0 },
},
{
line: 2,
type: "STRAIGHT_FEED",
motionClass: "feed",
feedMode: "units-per-minute",
startSeconds: 120,
durationSeconds: 0.1,
velocityMmPerMin: 600,
startAxes: { x: 120, y: 0, z: 0, a: 0, b: 0, c: 0 },
endAxes: { x: 121, y: 0, z: 0, a: 0, b: 0, c: 0 },
},
],
}), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" }), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_SET_MODE", mode: "AUTO" }), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_PLAN_RUN", line: 0 }), 0);
assert.equal(sdk.runCycles({ taskCycles: 1, taskPeriodNs: 1000000000, servoPeriodNs: 1000000 }), 0);
status = sdk.readStatus();
assert.equal(status.motionStatus.motion.programLine, 1);
assert.equal(status.motionStatus.axis.x > 0 && status.motionStatus.axis.x < 2, true);
assert.equal(status.motionStatus.motion.currentVel, 1);
assert.equal(status.motionStatus.motion.currentVel !== 60, true);
assert.equal(sdk.runCycles({ taskCycles: 119, taskPeriodNs: 1000000000, servoPeriodNs: 1000000 }), 0);
status = sdk.readStatus();
assert.equal(status.motionStatus.motion.programLine, 2);
assert.equal(status.motionStatus.motion.currentVel, 10);
assert.equal(status.motionStatus.axis.x >= 120, true);
sdk.resetSession();
sdk.initSession({
iniPath: "xyzac-trt.ini",
iniText: "[TRAJ]\nCOORDINATES = X Y Z A C\n",
});
assert.equal(sdk.stageFile("programs/comment-lines.ngc", [
"(comment before first move)",
"",
"G1 X1 F60",
"; inline comment-only line",
"G1 X2 F60",
"M2",
].join("\n")), 0);
assert.equal(sdk.openProgram("programs/comment-lines.ngc"), 0);
assert.equal(sdk.loadProgramMotionPlan({
programPath: "programs/comment-lines.ngc",
segments: [
{
line: 3,
type: "STRAIGHT_FEED",
motionClass: "feed",
feedMode: "units-per-minute",
startSeconds: 0,
durationSeconds: 1,
velocityMmPerMin: 60,
startAxes: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 },
endAxes: { x: 1, y: 0, z: 0, a: 0, b: 0, c: 0 },
},
{
line: 5,
type: "STRAIGHT_FEED",
motionClass: "feed",
feedMode: "units-per-minute",
startSeconds: 1,
durationSeconds: 1,
velocityMmPerMin: 60,
startAxes: { x: 1, y: 0, z: 0, a: 0, b: 0, c: 0 },
endAxes: { x: 2, y: 0, z: 0, a: 0, b: 0, c: 0 },
},
],
}), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" }), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_SET_MODE", mode: "AUTO" }), 0);
assert.equal(sdk.sendCommand({ type: "EMC_TASK_PLAN_RUN", line: 0 }), 0);
assert.equal(sdk.runCycles({ taskCycles: 1, taskPeriodNs: 500000000, servoPeriodNs: 1000000 }), 0);
status = sdk.readStatus();
assert.equal(status.task.openedLineCount, 6);
assert.equal(status.task.openedSourceLineCount, 6);
assert.equal(status.task.executableLineCount, 3);
assert.equal(status.motionStatus.motion.programLine, 3);
assert.equal(status.halSnapshot.pins["motion.program-line"].value, 3);
assert.equal(sdk.runCycles({ taskCycles: 1, taskPeriodNs: 600000000, servoPeriodNs: 1000000 }), 0);
status = sdk.readStatus();
assert.equal(status.motionStatus.motion.programLine, 5);
assert.equal(status.halSnapshot.pins["motion.program-line"].value, 5);
assert.equal(status.task.nextProgramLine, 5);
assert.equal(sdk.runCycles({ taskCycles: 1, taskPeriodNs: 1000000000, servoPeriodNs: 1000000 }), 0);
status = sdk.readStatus();
assert.equal(status.task.nextProgramLine, 6);
console.log("linuxcnc_task_hal_sdk=ok"); console.log("linuxcnc_task_hal_sdk=ok");
console.log("task_hal_sdk_status_snapshot=ok"); console.log("task_hal_sdk_status_snapshot=ok");
console.log("task_hal_feed_timed_motion_plan=ok");
console.log("task_hal_comment_source_line_numbers=ok");

View File

@@ -52,7 +52,7 @@ link_wasm_module \
-s ENVIRONMENT=web,node \ -s ENVIRONMENT=web,node \
-s ALLOW_MEMORY_GROWTH=1 \ -s ALLOW_MEMORY_GROWTH=1 \
-s NO_EXIT_RUNTIME=1 \ -s NO_EXIT_RUNTIME=1 \
-s EXPORTED_FUNCTIONS='["_malloc","_free","_hal_init","_hal_ready","_hal_exit","_hal_malloc","_hal_pin_bit_new","_hal_pin_float_new","_hal_pin_s32_new","_hal_pin_u32_new","_hal_pin_s64_new","_hal_pin_u64_new","_hal_pin_bit_newf","_hal_pin_float_newf","_hal_pin_s32_newf","_hal_pin_u32_newf","_hal_pin_s64_newf","_hal_pin_u64_newf","_hal_param_bit_new","_hal_param_float_new","_hal_param_s32_new","_hal_param_u32_new","_hal_param_s64_new","_hal_param_u64_new","_hal_param_bit_newf","_hal_param_float_newf","_hal_param_s32_newf","_hal_param_u32_newf","_hal_param_s64_newf","_hal_param_u64_newf","_hal_get_pin_value_by_name","_hal_get_signal_value_by_name","_hal_get_param_value_by_name","_hal_link","_hal_unlink","_hal_set_p","_hal_get_p","_hal_create_thread","_hal_add_funct_to_thread","_hal_del_funct_from_thread","_hal_start_threads","_hal_stop_threads","_lchal_init_runtime","_lchal_load_hal_file","_lchal_set_pin_float","_lchal_set_pin_s32","_lchal_set_pin_bit","_lchal_get_pin_json","_lchal_get_snapshot_json","_lchal_step_threads","_lchal_reset_runtime","_lcmot_init_from_ini","_lcmot_write_command_json","_lcmot_step_servo","_lcmot_read_status_json","_lcmot_read_hal_snapshot_json","_lcmot_reset","_lctask_init_session","_lctask_stage_file","_lctask_open_program","_lctask_send_command_json","_lctask_run_cycles","_lctask_read_status_json","_lctask_read_events_json","_lctask_reset_session"]' \ -s EXPORTED_FUNCTIONS='["_malloc","_free","_hal_init","_hal_ready","_hal_exit","_hal_malloc","_hal_pin_bit_new","_hal_pin_float_new","_hal_pin_s32_new","_hal_pin_u32_new","_hal_pin_s64_new","_hal_pin_u64_new","_hal_pin_bit_newf","_hal_pin_float_newf","_hal_pin_s32_newf","_hal_pin_u32_newf","_hal_pin_s64_newf","_hal_pin_u64_newf","_hal_param_bit_new","_hal_param_float_new","_hal_param_s32_new","_hal_param_u32_new","_hal_param_s64_new","_hal_param_u64_new","_hal_param_bit_newf","_hal_param_float_newf","_hal_param_s32_newf","_hal_param_u32_newf","_hal_param_s64_newf","_hal_param_u64_newf","_hal_get_pin_value_by_name","_hal_get_signal_value_by_name","_hal_get_param_value_by_name","_hal_link","_hal_unlink","_hal_set_p","_hal_get_p","_hal_create_thread","_hal_add_funct_to_thread","_hal_del_funct_from_thread","_hal_start_threads","_hal_stop_threads","_lchal_init_runtime","_lchal_load_hal_file","_lchal_set_pin_float","_lchal_set_pin_s32","_lchal_set_pin_bit","_lchal_get_pin_json","_lchal_get_snapshot_json","_lchal_step_threads","_lchal_reset_runtime","_lcmot_init_from_ini","_lcmot_write_command_json","_lcmot_step_servo","_lcmot_read_status_json","_lcmot_read_hal_snapshot_json","_lcmot_reset","_lctask_init_session","_lctask_stage_file","_lctask_open_program","_lctask_load_program_motion_plan_json","_lctask_send_command_json","_lctask_run_cycles","_lctask_read_status_json","_lctask_read_events_json","_lctask_reset_session"]' \
-s EXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8","getValue","setValue"]' -s EXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8","getValue","setValue"]'
echo "linuxcnc_task_hal_wasm_build=ok" echo "linuxcnc_task_hal_wasm_build=ok"

View File

@@ -7,7 +7,7 @@
"build": "node scripts/build-static.mjs", "build": "node scripts/build-static.mjs",
"dev": "python3 -m http.server 4173", "dev": "python3 -m http.server 4173",
"smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh && bash ../tests/browser/verify_gmoccapy_dist_browser.sh", "smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh && bash ../tests/browser/verify_gmoccapy_dist_browser.sh",
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_run_preconditions.mjs && node ../tests/node/verify_linuxcnc_task_hal_runtime.mjs && node ../tests/node/verify_native_task_hal_audit.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_real_linuxcnc_5axis_program_cases.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs" "smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_run_preconditions.mjs && node ../tests/node/verify_run_feedback_loop.mjs && node ../tests/node/verify_linuxcnc_task_hal_runtime.mjs && node ../tests/node/verify_native_task_hal_audit.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_real_linuxcnc_5axis_program_cases.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs && node ../tests/node/verify_linear_unit_conversion.mjs"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": {} "devDependencies": {}

View File

@@ -1,3 +1,9 @@
import {
linearUnitsToMillimetersFactor,
linearValueToMillimeters,
resolveStateLinearUnits,
} from "./linear-units.js";
const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"]; const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"];
const ANGULAR_AXES = ["a", "b", "c"]; const ANGULAR_AXES = ["a", "b", "c"];
const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES]; const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES];
@@ -9,14 +15,17 @@ export function buildProgramExecutionTiming({
rapidOverride = 100, rapidOverride = 100,
defaultFeedRate = 100, defaultFeedRate = 100,
} = {}) { } = {}) {
const limits = buildVelocityLimits(profile); const linearUnits = resolveStateLinearUnits(profile);
const limits = buildVelocityLimits(profile, linearUnits);
const segments = []; const segments = [];
let previousAxes = null; let previousAxes = null;
let previousLinearUnits = linearUnits;
let elapsedSeconds = 0; let elapsedSeconds = 0;
let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100; let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100;
for (let index = 0; index < motion.length; index += 1) { for (let index = 0; index < motion.length; index += 1) {
const event = motion[index]; const event = motion[index];
const eventLinearUnits = event.linearUnits || linearUnits;
const axes = normalizeAxes(event.axes, previousAxes); const axes = normalizeAxes(event.axes, previousAxes);
if (Number.isFinite(event.feedRate) && event.feedRate > 0) { if (Number.isFinite(event.feedRate) && event.feedRate > 0) {
feedRate = event.feedRate; feedRate = event.feedRate;
@@ -31,6 +40,8 @@ export function buildProgramExecutionTiming({
feedOverride, feedOverride,
rapidOverride, rapidOverride,
elapsedSeconds, elapsedSeconds,
linearUnits: eventLinearUnits,
previousLinearUnits: previousAxes ? previousLinearUnits : eventLinearUnits,
}); });
elapsedSeconds += segment.durationSeconds; elapsedSeconds += segment.durationSeconds;
segments.push({ segments.push({
@@ -38,6 +49,7 @@ export function buildProgramExecutionTiming({
elapsedSeconds, elapsedSeconds,
}); });
previousAxes = axes; previousAxes = axes;
previousLinearUnits = eventLinearUnits;
} }
const feedSeconds = segments const feedSeconds = segments
@@ -58,6 +70,7 @@ export function buildProgramExecutionTiming({
motionCount: segments.length, motionCount: segments.length,
segments, segments,
limits, limits,
linearUnits,
}; };
} }
@@ -82,14 +95,22 @@ function buildTimingSegment({
feedOverride, feedOverride,
rapidOverride, rapidOverride,
elapsedSeconds, elapsedSeconds,
linearUnits,
previousLinearUnits,
}) { }) {
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]])); const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => deltas[axis])); const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => (
linearValueToMillimeters(axes[axis], linearUnits)
- linearValueToMillimeters(previousAxes[axis], previousLinearUnits || linearUnits)
)));
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis])); const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed"; const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
const feedMode = event.feedMode === "inverse-time" ? "inverse-time" : "units-per-minute";
const requestedLinearVelocity = motionClass === "rapid" const requestedLinearVelocity = motionClass === "rapid"
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride) ? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
: Math.max(feedRate, 0) * percent(feedOverride); : feedMode === "inverse-time"
? inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate)
: linearValueToMillimeters(Math.max(feedRate, 0), linearUnits) * percent(feedOverride);
const cappedLinearVelocity = Math.min( const cappedLinearVelocity = Math.min(
requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin, requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin,
limits.maxLinearVelocityMmPerMin, limits.maxLinearVelocityMmPerMin,
@@ -99,17 +120,25 @@ function buildTimingSegment({
: 0; : 0;
const angularVelocityDegPerMin = motionClass === "rapid" const angularVelocityDegPerMin = motionClass === "rapid"
? limits.maxAngularVelocityDegPerMin * percent(rapidOverride) ? limits.maxAngularVelocityDegPerMin * percent(rapidOverride)
: feedMode === "inverse-time"
? inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin); : Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
const angularSeconds = angularDistanceDeg > 0 const angularSeconds = angularDistanceDeg > 0
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001) ? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
: 0; : 0;
const durationSeconds = Math.max(linearSeconds, angularSeconds); const inverseTimeSeconds = motionClass === "feed" && feedMode === "inverse-time" && feedRate > 0
? 60 / feedRate
: 0;
const durationSeconds = inverseTimeSeconds > 0
? inverseTimeSeconds
: Math.max(linearSeconds, angularSeconds);
return { return {
index, index,
line: event.line, line: event.line,
type: event.type, type: event.type,
motionClass, motionClass,
feedMode,
linearDistanceMm, linearDistanceMm,
angularDistanceDeg, angularDistanceDeg,
feedRate, feedRate,
@@ -118,20 +147,28 @@ function buildTimingSegment({
angularVelocityDegPerMin, angularVelocityDegPerMin,
durationSeconds, durationSeconds,
startSeconds: elapsedSeconds, startSeconds: elapsedSeconds,
startAxes: previousAxes,
endAxes: axes,
axes, axes,
deltas, deltas,
linearUnits,
}; };
} }
function buildVelocityLimits(profile) { function buildVelocityLimits(profile, linearUnits) {
const traj = profile?.traj || {}; const traj = profile?.traj || {};
const axisLimits = profile?.axisLimits || {}; const axisLimits = profile?.axisLimits || {};
const linearVelocityScale = linearUnitsToMillimetersFactor(linearUnits);
const maxLinearVelocity = firstFinite( const maxLinearVelocity = firstFinite(
Number(traj.maxLinearVelocity) * 60, Number(traj.maxLinearVelocity) * linearVelocityScale * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60), ...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * linearVelocityScale * 60),
2100, 2100,
); );
const defaultLinearVelocity = firstFinite(Number(traj.defaultLinearVelocity) * 60, maxLinearVelocity, 1200); const defaultLinearVelocity = firstFinite(
Number(traj.defaultLinearVelocity) * linearVelocityScale * 60,
maxLinearVelocity,
1200,
);
const maxAngularVelocity = firstFinite( const maxAngularVelocity = firstFinite(
...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60), ...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
maxLinearVelocity, maxLinearVelocity,
@@ -161,6 +198,24 @@ function percent(value) {
return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1; return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1;
} }
function inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
if (linearDistanceMm > 0 && durationSeconds > 0) {
return (linearDistanceMm / durationSeconds) * 60;
}
if (angularDistanceDeg > 0 && durationSeconds > 0) {
return angularDistanceDeg / durationSeconds * 60;
}
return 0;
}
function inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
return angularDistanceDeg > 0 && durationSeconds > 0
? angularDistanceDeg / durationSeconds * 60
: 0;
}
function firstFinite(...values) { function firstFinite(...values) {
return values.find((value) => Number.isFinite(value) && value > 0) || 1; return values.find((value) => Number.isFinite(value) && value > 0) || 1;
} }

View File

@@ -0,0 +1,60 @@
const UNIT_ALIASES = new Map([
["mm", "mm"],
["millimeter", "mm"],
["millimeters", "mm"],
["millimetre", "mm"],
["millimetres", "mm"],
["metric", "mm"],
["inch", "inch"],
["inches", "inch"],
["in", "inch"],
["imperial", "inch"],
["m", "m"],
["meter", "m"],
["meters", "m"],
["metre", "m"],
["metres", "m"],
]);
const METERS_PER_UNIT = {
mm: 0.001,
inch: 0.0254,
m: 1,
};
export function normalizeLinearUnits(units, fallback = "mm") {
const key = String(units || "").trim().toLowerCase();
return UNIT_ALIASES.get(key) || UNIT_ALIASES.get(String(fallback || "mm").trim().toLowerCase()) || "mm";
}
export function linearUnitsToMetersFactor(units) {
return METERS_PER_UNIT[normalizeLinearUnits(units)] || METERS_PER_UNIT.mm;
}
export function linearUnitsToMillimetersFactor(units) {
return linearUnitsToMetersFactor(units) * 1000;
}
export function linearValueToMeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMetersFactor(units);
}
export function linearValueToMillimeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMillimetersFactor(units);
}
export function linearUnitsLabel(units) {
return normalizeLinearUnits(units);
}
export function resolveStateLinearUnits(stateOrProfile, fallback = "mm") {
return normalizeLinearUnits(
stateOrProfile?.programExecution?.summary?.linearUnits
|| stateOrProfile?.linuxCncIniConfig?.traj?.linearUnits
|| stateOrProfile?.profile?.traj?.linearUnits
|| stateOrProfile?.traj?.linearUnits,
fallback,
);
}

View File

@@ -11,6 +11,17 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
const remaps = parseRemaps(sections); const remaps = parseRemaps(sections);
const hal = parseHal(sections); const hal = parseHal(sections);
const display = parseDisplay(sections); const display = parseDisplay(sections);
const emcmot = {
module: getFirstValue(sections, "EMCMOT", "EMCMOT") || null,
servoPeriodNs: numberOrNull(getFirstValue(sections, "EMCMOT", "SERVO_PERIOD")),
};
const task = {
module: getFirstValue(sections, "TASK", "TASK") || null,
cycleTimeSeconds: numberOrNull(getFirstValue(sections, "TASK", "CYCLE_TIME")),
};
const emcio = {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
};
const halui = { const halui = {
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"), mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
}; };
@@ -20,6 +31,7 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
apiName: "web-rtcp-5axis-linuxcnc-ini-config", apiName: "web-rtcp-5axis-linuxcnc-ini-config",
profileId, profileId,
path, path,
sourceText: text,
machineName: getFirstValue(sections, "EMC", "MACHINE") || null, machineName: getFirstValue(sections, "EMC", "MACHINE") || null,
kinematics: parseKinematics(kinsText), kinematics: parseKinematics(kinsText),
kinematicsModuleId, kinematicsModuleId,
@@ -48,10 +60,27 @@ export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unkno
halui, halui,
axisLimits, axisLimits,
jointConfig, jointConfig,
emcio: { emcmot,
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null, task,
emcio,
validation: validateIniConfig({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc: {
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
}, },
validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }), emcmot,
task,
emcio,
}),
semanticBoundary: "linuxcnc_ini_file_browser_parser", semanticBoundary: "linuxcnc_ini_file_browser_parser",
}; };
} }
@@ -130,6 +159,18 @@ export function applyIniConfigToProfile(profile, iniConfig) {
}, },
}, },
halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui, halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui,
emcmot: {
...profile.emcmot,
...dropNullish(iniConfig.emcmot || {}),
},
task: {
...profile.task,
...dropNullish(iniConfig.task || {}),
},
emcio: {
...profile.emcio,
...dropNullish(iniConfig.emcio || {}),
},
traj: { traj: {
...profile.traj, ...profile.traj,
...dropNullish(iniConfig.traj), ...dropNullish(iniConfig.traj),
@@ -297,17 +338,73 @@ function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) {
})); }));
} }
function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) { function validateIniConfig({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc,
emcmot,
task,
emcio,
}) {
const missing = []; const missing = [];
const requiredSections = [
"EMC",
"DISPLAY",
"RS274NGC",
"KINS",
"HAL",
"HALUI",
"TRAJ",
"EMCMOT",
"TASK",
"EMCIO",
];
for (const section of requiredSections) {
if (!sections.has(section)) missing.push(`[${section}]`);
}
if (!["XYZAC", "XYZBC"].includes(coordinates)) missing.push("TRAJ.COORDINATES XYZAC/XYZBC");
if (!coordinates) missing.push("TRAJ.COORDINATES"); if (!coordinates) missing.push("TRAJ.COORDINATES");
if (!jointCount) missing.push("KINS.JOINTS"); if (!jointCount) missing.push("KINS.JOINTS");
if (jointCount !== 5) missing.push("KINS.JOINTS=5");
if (!kinsText) missing.push("KINS.KINEMATICS"); if (!kinsText) missing.push("KINS.KINEMATICS");
if (!String(kinsText || "").includes("sparm=identityfirst")) {
missing.push("KINS.KINEMATICS sparm=identityfirst");
}
for (const axis of String(coordinates || "").split("")) { for (const axis of String(coordinates || "").split("")) {
if (!axisLimits[axis]) missing.push(`AXIS_${axis}`); if (!axisLimits[axis]) missing.push(`AXIS_${axis}`);
} }
if (jointCount && jointConfig.length !== jointCount) { if (jointCount && jointConfig.length !== jointCount) {
missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`); missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`);
} }
for (let joint = 0; joint < 5; joint += 1) {
if (!sections.has(`JOINT_${joint}`)) missing.push(`JOINT_${joint}`);
}
for (const code of ["M428", "M429", "M430"]) {
if (!remaps.some((remap) => remap.code === code && remap.ngc)) {
missing.push(`RS274NGC.REMAP ${code}`);
}
}
if (rs274ngc.halPinVars !== true) missing.push("RS274NGC.HAL_PIN_VARS=1");
if (!rs274ngc.parameterFile) missing.push("RS274NGC.PARAMETER_FILE");
if (!hal.halui) missing.push("HAL.HALUI");
if (!hal.halFiles.length) missing.push("HAL.HALFILE");
if (!hal.postguiHalFiles.length) missing.push("HAL.POSTGUI_HALFILE");
if (!hal.halcmd.some((line) => line.includes("motion.analog-out-03") && line.includes("motion.switchkins-type"))) {
missing.push("HAL.HALCMD motion.analog-out-03=>motion.switchkins-type");
}
if (halui.mdiCommands.length < 3) missing.push("HALUI.MDI_COMMAND M428/M429/M430");
if (!emcmot.module) missing.push("EMCMOT.EMCMOT");
if (!emcmot.servoPeriodNs) missing.push("EMCMOT.SERVO_PERIOD");
if (!task.module) missing.push("TASK.TASK");
if (!task.cycleTimeSeconds) missing.push("TASK.CYCLE_TIME");
if (!emcio.toolTable) missing.push("EMCIO.TOOL_TABLE");
return { return {
ready: missing.length === 0, ready: missing.length === 0,
missing, missing,

View File

@@ -234,11 +234,15 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0])); const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText); const sourceLines = programLineMap(programText);
const feedRatesByLine = feedRatesBySourceLine(programText); const feedRatesByLine = feedRatesBySourceLine(programText);
const feedModesByLine = feedModesBySourceLine(programText);
const linearUnitsByLine = linearUnitsBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents); const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = []; const motion = [];
let activePlane = 170; let activePlane = 170;
let activeSwitchkinsEvent = null; let activeSwitchkinsEvent = null;
let activeFeedRate = null; let activeFeedRate = null;
let activeFeedMode = "units-per-minute";
let activeLinearUnits = "mm";
for (const line of String(resultText).split("\n")) { for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalNumber(line, "feed_rate"); const feedRate = readCanonicalNumber(line, "feed_rate");
@@ -289,6 +293,8 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) { if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate; activeFeedRate = sourceFeedRate;
} }
activeFeedMode = latestFeedModeAtOrBeforeLine(feedModesByLine, sourceLine) || activeFeedMode;
activeLinearUnits = latestLinearUnitsAtOrBeforeLine(linearUnitsByLine, sourceLine) || activeLinearUnits;
} }
motion.push({ motion.push({
type: event[1], type: event[1],
@@ -300,6 +306,8 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
switchkinsCode: activeSwitchkinsEvent?.code || null, switchkinsCode: activeSwitchkinsEvent?.code || null,
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null, switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
feedRate: activeFeedRate, feedRate: activeFeedRate,
feedMode: activeFeedMode,
linearUnits: activeLinearUnits,
raw: line, raw: line,
}); });
} }
@@ -307,6 +315,56 @@ export function parseLinuxCncCanonicalMotion(resultText, programText = "", switc
return motion; return motion;
} }
function feedModesBySourceLine(programText) {
const modes = [{ line: 0, feedMode: "units-per-minute" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeMode = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 93) activeMode = "inverse-time";
if (value === 94) activeMode = "units-per-minute";
}
if (activeMode) {
modes.push({ line: index + 1, feedMode: activeMode });
}
});
return modes;
}
function latestFeedModeAtOrBeforeLine(modes, sourceLine) {
let feedMode = "units-per-minute";
for (const entry of modes) {
if (entry.line <= sourceLine) feedMode = entry.feedMode;
}
return feedMode;
}
function linearUnitsBySourceLine(programText) {
const units = [{ line: 0, linearUnits: "mm" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeUnits = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 20) activeUnits = "inch";
if (value === 21) activeUnits = "mm";
}
if (activeUnits) {
units.push({ line: index + 1, linearUnits: activeUnits });
}
});
return units;
}
function latestLinearUnitsAtOrBeforeLine(units, sourceLine) {
let linearUnits = "mm";
for (const entry of units) {
if (entry.line <= sourceLine) linearUnits = entry.linearUnits;
}
return linearUnits;
}
function feedRatesBySourceLine(programText) { function feedRatesBySourceLine(programText) {
const rates = []; const rates = [];
String(programText).split(/\r?\n/).forEach((line, index) => { String(programText).split(/\r?\n/).forEach((line, index) => {

View File

@@ -93,6 +93,17 @@ export function wrapTaskHalSdk(sdk, {
return rc; return rc;
}, },
loadProgramMotionPlan(plan = {}) {
if (typeof sdk.loadProgramMotionPlan !== "function") {
throw new Error("task/HAL SDK missing loadProgramMotionPlan; rebuild wasm-port/tools/build_task_hal_wasm.sh");
}
const rc = sdk.loadProgramMotionPlan(plan);
if (rc !== 0) {
throw new Error(`lctask_load_program_motion_plan_json failed rc=${rc}`);
}
return rc;
},
sendCommand(command) { sendCommand(command) {
const rc = sdk.sendCommand(command); const rc = sdk.sendCommand(command);
if (rc !== 0) { if (rc !== 0) {
@@ -152,10 +163,65 @@ export function buildTaskHalSessionFromMachineFiles({ profile, plan, save, selec
}; };
} }
export function buildTaskHalProgramMotionPlan({
programPath = null,
motion = [],
timing = null,
linearUnits = "mm",
programLines = [],
} = {}) {
const segments = Array.isArray(timing?.segments) ? timing.segments : [];
let planSegments = segments.map((segment, index) => {
const event = motion[index] || {};
const startAxes = normalizePlanAxes(segment.startAxes || motion[index - 1]?.axes || {});
const endAxes = normalizePlanAxes(segment.endAxes || segment.axes || event.axes || startAxes);
return {
line: Number(segment.line ?? event.line ?? index + 1),
type: segment.type || event.type || "STRAIGHT_FEED",
motionClass: segment.motionClass || (event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed"),
feedMode: segment.feedMode || event.feedMode || "units-per-minute",
startSeconds: Number(segment.startSeconds || 0),
durationSeconds: Math.max(Number(segment.durationSeconds || 0), 0),
elapsedSeconds: Number(segment.elapsedSeconds || 0),
feedRate: Number(segment.feedRate || event.feedRate || 0),
linearUnits: segment.linearUnits || event.linearUnits || linearUnits,
velocityMmPerMin: Math.max(Number(segment.velocityMmPerMin || 0), 0),
requestedVelocityMmPerMin: Math.max(Number(segment.requestedVelocityMmPerMin || segment.velocityMmPerMin || 0), 0),
startAxes,
endAxes,
};
}).filter((segment) => segment.durationSeconds > 0 || segment.line > 0);
const lineSegments = buildSourceLineMotionSegments({
programLines,
seedSegments: planSegments,
linearUnits,
});
if (shouldUseSourceLineSegments(planSegments, lineSegments)) {
planSegments = lineSegments;
}
return {
apiName: "web-rtcp-5axis-task-hal-program-motion-plan",
semanticBoundary: "linuxcnc_canonical_motion_feed_timed_task_hal_plan",
programPath,
linearUnits,
totalSeconds: Number(timing?.totalSeconds || 0),
segmentCount: planSegments.length,
segments: planSegments,
};
}
export function normalizeTaskHalStatus(status = {}) { export function normalizeTaskHalStatus(status = {}) {
const motion = status.motionStatus?.motion || {}; const motion = status.motionStatus?.motion || {};
const axis = status.motionStatus?.axis || {}; const axis = status.motionStatus?.axis || {};
const halPins = status.halSnapshot?.pins || {}; const halPins = status.halSnapshot?.pins || {};
const motionProgramLine = Number(motion.programLine || 0);
const halProgramLine = Number(halPins["motion.program-line"]?.value || 0);
const activeLine = motionProgramLine > 0
? motionProgramLine
: halProgramLine > 0
? halProgramLine
: 1;
return { return {
...status, ...status,
semanticBoundary: SEMANTIC_BOUNDARY, semanticBoundary: SEMANTIC_BOUNDARY,
@@ -183,7 +249,13 @@ export function normalizeTaskHalStatus(status = {}) {
halChangedPinCount: Array.isArray(status.halSnapshot?.changedPins) halChangedPinCount: Array.isArray(status.halSnapshot?.changedPins)
? status.halSnapshot.changedPins.length ? status.halSnapshot.changedPins.length
: Number(status.halSnapshot?.changedPinCount || 0), : Number(status.halSnapshot?.changedPinCount || 0),
activeLine: Number(motion.programLine || halPins["motion.program-line"]?.value || 1), activeLine,
motionProgramLine,
halProgramLine,
activeLineSource: motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback",
activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0
? motionProgramLine === halProgramLine
: false,
switchkinsType: Number(motion.switchkinsType ?? halPins["motion.switchkins-type"]?.value ?? 0), switchkinsType: Number(motion.switchkinsType ?? halPins["motion.switchkins-type"]?.value ?? 0),
axisPose: { axisPose: {
x: Number(axis.x ?? halPins["axis.0.pos-cmd"]?.value ?? 0), x: Number(axis.x ?? halPins["axis.0.pos-cmd"]?.value ?? 0),
@@ -199,6 +271,133 @@ export function normalizeTaskHalStatus(status = {}) {
}; };
} }
function shouldUseSourceLineSegments(planSegments, lineSegments) {
if (lineSegments.length < 3) return false;
const plannedLines = new Set(planSegments.map((segment) => Number(segment.line)).filter(Number.isFinite));
const lineCount = lineSegments.length;
return plannedLines.size <= 2 && lineCount > plannedLines.size;
}
function buildSourceLineMotionSegments({
programLines = [],
seedSegments = [],
linearUnits = "mm",
} = {}) {
if (!Array.isArray(programLines) || programLines.length === 0) return [];
const seedByLine = new Map(seedSegments.map((segment) => [Number(segment.line), segment]));
const axes = normalizePlanAxes(seedSegments[0]?.startAxes || {});
const segments = [];
let elapsedSeconds = 0;
let feedRate = firstPositive(seedSegments.map((segment) => segment.feedRate), 100);
let rapidVelocity = firstPositive(
seedSegments.filter((segment) => segment.motionClass === "rapid").map((segment) => segment.velocityMmPerMin),
2100,
);
for (let index = 0; index < programLines.length; index += 1) {
const line = index + 1;
const code = stripSourceLineComments(programLines[index]);
if (!isExecutableGcodeLine(code)) continue;
const seed = seedByLine.get(line) || null;
const startAxes = normalizePlanAxes(seed?.startAxes || axes);
const parsed = parseGcodeLineMotion(code, startAxes, feedRate);
if (parsed.feedRate > 0) feedRate = parsed.feedRate;
const motionClass = seed?.motionClass || parsed.motionClass;
const endAxes = normalizePlanAxes(seed?.endAxes || parsed.endAxes);
const velocityMmPerMin = Number(seed?.velocityMmPerMin) > 0
? Number(seed.velocityMmPerMin)
: motionClass === "rapid"
? rapidVelocity
: Math.max(feedRate, 1);
if (motionClass === "rapid" && velocityMmPerMin > 0) {
rapidVelocity = velocityMmPerMin;
}
const durationSeconds = Math.max(
Number(seed?.durationSeconds || 0),
estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin),
0.05,
);
const segment = {
line,
type: seed?.type || parsed.type,
motionClass,
feedMode: seed?.feedMode || parsed.feedMode,
startSeconds: elapsedSeconds,
durationSeconds,
elapsedSeconds: elapsedSeconds + durationSeconds,
feedRate,
linearUnits: seed?.linearUnits || linearUnits,
velocityMmPerMin,
requestedVelocityMmPerMin: Number(seed?.requestedVelocityMmPerMin || velocityMmPerMin),
startAxes,
endAxes,
};
segments.push(segment);
Object.assign(axes, endAxes);
elapsedSeconds += durationSeconds;
}
return segments;
}
function parseGcodeLineMotion(code, startAxes, currentFeedRate) {
const numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
const gCodes = [...code.matchAll(new RegExp(`\\bG\\s*(${numberPattern})\\b`, "gi"))].map((match) => Number(match[1]));
const feedMode = gCodes.includes(93) ? "inverse-time" : "units-per-minute";
const rapid = gCodes.includes(0);
const feed = gCodes.some((value) => value === 1 || value === 2 || value === 3);
const endAxes = { ...startAxes };
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) {
const match = code.match(new RegExp(`\\b${axis}\\s*(${numberPattern})`, "i"));
if (match) endAxes[axis] = Number(match[1]);
}
const feedMatch = code.match(new RegExp(`\\bF\\s*(${numberPattern})`, "i"));
const feedRate = feedMatch && Number(feedMatch[1]) > 0 ? Number(feedMatch[1]) : Number(currentFeedRate || 0);
return {
type: rapid ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED",
motionClass: rapid && !feed ? "rapid" : "feed",
feedMode,
feedRate,
endAxes,
};
}
function stripSourceLineComments(line) {
return String(line || "")
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ")
.trim();
}
function isExecutableGcodeLine(code) {
if (!code || code === "%") return false;
return /\b[GMTXYZABCUVWF]\s*[-+]?\d/i.test(code);
}
function estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin) {
const distance = Math.sqrt(["x", "y", "z"].reduce((total, axis) => {
const delta = Number(endAxes[axis] || 0) - Number(startAxes[axis] || 0);
return total + delta * delta;
}, 0));
if (distance <= 0 || velocityMmPerMin <= 0) return 0;
return distance / Math.max(velocityMmPerMin / 60, 0.000001);
}
function firstPositive(values, fallback) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number) && number > 0) return number;
}
return fallback;
}
function normalizePlanAxes(axes = {}) {
return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => [
axis,
Number.isFinite(Number(axes[axis])) ? Number(axes[axis]) : 0,
]));
}
function isJogMotion(motion = {}) { function isJogMotion(motion = {}) {
return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true; return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true;
} }

View File

@@ -21,6 +21,7 @@ export async function createLinuxCncTaskHalWorkerRuntime({
initSession: (session) => client.call("initSession", { session }), initSession: (session) => client.call("initSession", { session }),
stageFiles: (files) => client.call("stageFiles", { files }), stageFiles: (files) => client.call("stageFiles", { files }),
openProgram: (path) => client.call("openProgram", { path }), openProgram: (path) => client.call("openProgram", { path }),
loadProgramMotionPlan: (plan) => client.call("loadProgramMotionPlan", { plan }),
sendCommand: (command) => client.call("command", { command }), sendCommand: (command) => client.call("command", { command }),
runCycles: (options) => client.call("runCycles", { options }), runCycles: (options) => client.call("runCycles", { options }),
readStatus: () => client.call("readStatus"), readStatus: () => client.call("readStatus"),

View File

@@ -33,6 +33,9 @@ async function handleMessage(type, payload) {
case "openProgram": case "openProgram":
assertRuntime(); assertRuntime();
return runtime.openProgram(payload.path); return runtime.openProgram(payload.path);
case "loadProgramMotionPlan":
assertRuntime();
return runtime.loadProgramMotionPlan(payload.plan || {});
case "command": case "command":
assertRuntime(); assertRuntime();
return runtime.sendCommand(payload.command); return runtime.sendCommand(payload.command);

View File

@@ -17,6 +17,7 @@ import {
stageProfileMachineFiles, stageProfileMachineFiles,
} from "../runtime/linuxcnc-machine-file-staging.js"; } from "../runtime/linuxcnc-machine-file-staging.js";
import { import {
buildTaskHalProgramMotionPlan,
buildTaskHalSessionFromMachineFiles, buildTaskHalSessionFromMachineFiles,
} from "../runtime/linuxcnc-task-hal-runtime.js"; } from "../runtime/linuxcnc-task-hal-runtime.js";
import { import {
@@ -42,6 +43,40 @@ const initialAxisPose = {
c: 0.0, c: 0.0,
}; };
function createTaskHalStatusLoopState({
active = false,
sequence = 0,
profileId = null,
iniPath = null,
kinematicsModuleId = null,
tickCount = 0,
batchSize = 5,
intervalMs = 25,
taskPeriodNs = 10000000,
servoPeriodNs = 1000000,
lastStatusAt = null,
lastError = null,
stopReason = null,
} = {}) {
return {
apiName: "web-rtcp-5axis-task-hal-status-loop",
active,
sequence,
profileId,
iniPath,
kinematicsModuleId,
tickCount,
batchSize,
intervalMs,
taskPeriodNs,
servoPeriodNs,
lastStatusAt,
lastError,
stopReason,
semanticBoundary: "js_status_polling_loop_for_linuxcnc_task_hal_motion_status",
};
}
const initialState = { const initialState = {
machineProfile: "xyzac-trt", machineProfile: "xyzac-trt",
availableProfiles: fiveAxisProfiles.map(({ id, title, traj, kinematicsModuleId, kinematics }) => ({ availableProfiles: fiveAxisProfiles.map(({ id, title, traj, kinematicsModuleId, kinematics }) => ({
@@ -136,12 +171,14 @@ const initialState = {
programExecutionMotionIndex: 0, programExecutionMotionIndex: 0,
programExecutionSampleIndex: 0, programExecutionSampleIndex: 0,
programRuntimeFeedback: null, programRuntimeFeedback: null,
programRuntimeFeedbackHistory: [],
taskHalRuntime: null, taskHalRuntime: null,
taskHalRuntimeReadiness: null, taskHalRuntimeReadiness: null,
taskHalStatus: null, taskHalStatus: null,
taskHalSession: null, taskHalSession: null,
taskHalExecutionPending: false, taskHalExecutionPending: false,
taskHalExecutionSequence: 0, taskHalExecutionSequence: 0,
taskHalStatusLoop: createTaskHalStatusLoopState(),
taskHalFallbackReason: null, taskHalFallbackReason: null,
pendingJogCommand: null, pendingJogCommand: null,
interpreterExecutionPending: false, interpreterExecutionPending: false,
@@ -249,6 +286,7 @@ export function createSimulationStore(seed = {}) {
}; };
state.fullExecutionBoundary = createFullLinuxCncExecutionBoundary(state); state.fullExecutionBoundary = createFullLinuxCncExecutionBoundary(state);
const listeners = new Set(); const listeners = new Set();
let taskHalStatusLoopTimer = null;
const notify = () => { const notify = () => {
for (const listener of listeners) { for (const listener of listeners) {
@@ -772,7 +810,7 @@ export function createSimulationStore(seed = {}) {
}, },
machine: { machine: {
...state.machine, ...state.machine,
mode: "auto", mode: state.machine.mode,
}, },
axisPose: initialAxisPose, axisPose: initialAxisPose,
runState: "idle", runState: "idle",
@@ -799,12 +837,51 @@ export function createSimulationStore(seed = {}) {
}); });
break; break;
case "TASK_HAL_STATUS_APPLIED": case "TASK_HAL_STATUS_APPLIED":
setState(applyTaskHalStatusPatch(state, action.status, action.operatorMessage)); setState(applyTaskHalStatusPatch(state, action.status, action.operatorMessage, {
loopSequence: action.loopSequence,
preserveMachine: action.preserveMachine,
}));
break;
case "TASK_HAL_STATUS_LOOP_STARTED":
setState({
taskHalStatusLoop: {
...createTaskHalStatusLoopState({
active: true,
sequence: action.sequence,
profileId: action.profileId,
iniPath: action.iniPath,
kinematicsModuleId: action.kinematicsModuleId,
batchSize: action.batchSize,
intervalMs: action.intervalMs,
taskPeriodNs: action.taskPeriodNs,
servoPeriodNs: action.servoPeriodNs,
}),
},
programRuntimeFeedbackHistory: [],
operatorMessage: action.operatorMessage || "task/HAL status loop running",
});
break;
case "TASK_HAL_STATUS_LOOP_STOPPED":
setState({
taskHalStatusLoop: {
...state.taskHalStatusLoop,
active: false,
stopReason: action.reason || "stopped",
lastError: action.error || null,
},
operatorMessage: action.operatorMessage || state.operatorMessage,
});
break; break;
case "TASK_HAL_COMMAND_FAILED": case "TASK_HAL_COMMAND_FAILED":
setState({ setState({
taskHalFallbackReason: action.error, taskHalFallbackReason: action.error,
taskHalExecutionPending: false, taskHalExecutionPending: false,
taskHalStatusLoop: {
...state.taskHalStatusLoop,
active: false,
lastError: action.error,
stopReason: "error",
},
pendingJogCommand: null, pendingJogCommand: null,
operatorMessage: `task/HAL fallback: ${action.error}`, operatorMessage: `task/HAL fallback: ${action.error}`,
}); });
@@ -850,13 +927,29 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage }); setState({ operatorMessage: gate.operatorMessage });
break; break;
} }
const turningOff = state.machine.taskState === "on" || state.machine.powerOn;
if (state.taskHalRuntime?.loaded) { if (state.taskHalRuntime?.loaded) {
setState({
machine: {
...state.machine,
powerOn: !turningOff,
estopActive: false,
taskState: turningOff ? "estop-reset" : "on",
interpState: "idle",
interpResumeState: "idle",
taskPaused: false,
},
runState: turningOff ? "powered-off" : "idle",
feed: turningOff ? { ...state.feed, currentVelocity: 0 } : state.feed,
coolant: turningOff ? { ...state.coolant, flood: false, mist: false } : state.coolant,
spindle: turningOff ? { ...state.spindle, enabled: false } : state.spindle,
operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on",
});
runTaskHalCommandSequence([ runTaskHalCommandSequence([
{ type: "EMC_TASK_SET_STATE", state: state.machine.powerOn ? "ESTOP_RESET" : "ON" }, { type: "EMC_TASK_SET_STATE", state: turningOff ? "ESTOP_RESET" : "ON" },
], { operatorMessage: state.machine.powerOn ? "task/HAL machine power off" : "task/HAL machine power on" }).catch(() => {}); ], { operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on" }).catch(() => {});
break; break;
} }
const turningOff = state.machine.taskState === "on" || state.machine.powerOn;
setState({ setState({
machine: { machine: {
...state.machine, ...state.machine,
@@ -1108,6 +1201,9 @@ export function createSimulationStore(seed = {}) {
break; break;
} }
if (state.taskHalRuntime?.loaded) { if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop(action.type === "ABORT" ? "aborted" : "stopped", {
operatorMessage: action.type === "ABORT" ? "task/HAL abort requested" : "task/HAL stop requested",
});
runTaskHalCommandSequence([ runTaskHalCommandSequence([
{ type: "EMC_TASK_ABORT" }, { type: "EMC_TASK_ABORT" },
], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped" }).catch(() => {}); ], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped" }).catch(() => {});
@@ -1137,6 +1233,7 @@ export function createSimulationStore(seed = {}) {
break; break;
} }
if (state.taskHalRuntime?.loaded) { if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop("paused", { operatorMessage: "task/HAL pause requested" });
runTaskHalCommandSequence([ runTaskHalCommandSequence([
{ type: "EMC_TASK_PLAN_PAUSE" }, { type: "EMC_TASK_PLAN_PAUSE" },
], { operatorMessage: "task/HAL program paused" }).catch(() => {}); ], { operatorMessage: "task/HAL program paused" }).catch(() => {});
@@ -1166,7 +1263,15 @@ export function createSimulationStore(seed = {}) {
if (state.taskHalRuntime?.loaded) { if (state.taskHalRuntime?.loaded) {
runTaskHalCommandSequence([ runTaskHalCommandSequence([
{ type: "EMC_TASK_PLAN_RESUME" }, { type: "EMC_TASK_PLAN_RESUME" },
], { operatorMessage: "task/HAL program resumed" }).catch(() => {}); ], {
operatorMessage: "task/HAL program resumed",
}).then(() => {
if (state.runState === "running" || state.machine.interpState === "reading") {
startTaskHalStatusLoop({
operatorMessage: "task/HAL status loop resumed",
});
}
}).catch(() => {});
break; break;
} }
const resumeState = state.machine.interpResumeState === "idle" const resumeState = state.machine.interpResumeState === "idle"
@@ -1191,6 +1296,14 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage }); setState({ operatorMessage: gate.operatorMessage });
break; break;
} }
if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop("step", { operatorMessage: "task/HAL step requested" });
runTaskHalCommandSequence([], {
taskCycles: 1,
operatorMessage: "task/HAL stepped one cycle",
}).catch(() => {});
break;
}
const playback = nextProgramRuntimeSamplePlayback(state, 1); const playback = nextProgramRuntimeSamplePlayback(state, 1);
setState({ setState({
machine: { machine: {
@@ -1259,6 +1372,11 @@ export function createSimulationStore(seed = {}) {
setState({ operatorMessage: gate.operatorMessage }); setState({ operatorMessage: gate.operatorMessage });
break; break;
} }
if (state.taskHalRuntime?.loaded) {
runTaskHalCommandSequence([
{ type: "EMC_JOINT_HOME", joint: -1 },
], { operatorMessage: "task/HAL machine homed" }).catch(() => {});
}
setState({ setState({
machine: { machine: {
...state.machine, ...state.machine,
@@ -1541,6 +1659,13 @@ export function createSimulationStore(seed = {}) {
if (!state.taskHalRuntime?.loaded || !state.machineFileStaging?.save?.files?.length) { if (!state.taskHalRuntime?.loaded || !state.machineFileStaging?.save?.files?.length) {
return null; return null;
} }
const preserveMachine = {
powerOn: state.machine.powerOn,
estopActive: state.machine.estopActive,
taskState: state.machine.taskState,
mode: state.machine.mode,
allHomed: state.machine.allHomed,
};
const selectedPlan = selectMachineFileProgramForState(state); const selectedPlan = selectMachineFileProgramForState(state);
const session = buildTaskHalSessionFromMachineFiles({ const session = buildTaskHalSessionFromMachineFiles({
profile: state.profile, profile: state.profile,
@@ -1559,12 +1684,28 @@ export function createSimulationStore(seed = {}) {
await state.taskHalRuntime.stageFiles(session.files); await state.taskHalRuntime.stageFiles(session.files);
if (openProgram && session.programPath) { if (openProgram && session.programPath) {
await state.taskHalRuntime.openProgram(session.programPath); await state.taskHalRuntime.openProgram(session.programPath);
await loadTaskHalMotionPlanForSession(session);
} }
if (preserveMachine.powerOn) {
await state.taskHalRuntime.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" });
}
if (preserveMachine.allHomed) {
await state.taskHalRuntime.sendCommand({ type: "EMC_JOINT_HOME", joint: -1 });
}
await state.taskHalRuntime.sendCommand({
type: "EMC_TASK_SET_MODE",
mode: normalizeLinuxCncTaskMode(preserveMachine.mode).toUpperCase(),
});
await state.taskHalRuntime.runCycles({
...deriveTaskHalCyclePeriods(state),
taskCycles: 1,
});
dispatch({ type: "TASK_HAL_SESSION_READY", session }); dispatch({ type: "TASK_HAL_SESSION_READY", session });
const status = await state.taskHalRuntime.readStatus(); const status = await state.taskHalRuntime.readStatus();
dispatch({ dispatch({
type: "TASK_HAL_STATUS_APPLIED", type: "TASK_HAL_STATUS_APPLIED",
status, status,
preserveMachine,
operatorMessage: `LinuxCNC task/HAL session ready ${session.programPath || "-"}`, operatorMessage: `LinuxCNC task/HAL session ready ${session.programPath || "-"}`,
}); });
return session; return session;
@@ -1581,6 +1722,11 @@ export function createSimulationStore(seed = {}) {
if (!state.taskHalSession || (expectedProgramPath && state.taskHalSession.programPath !== expectedProgramPath)) { if (!state.taskHalSession || (expectedProgramPath && state.taskHalSession.programPath !== expectedProgramPath)) {
await initializeTaskHalSession({ openProgram: true }); await initializeTaskHalSession({ openProgram: true });
} }
const loadedMotionPlan = await loadTaskHalMotionPlanForSession(state.taskHalSession);
if (!loadedMotionPlan) {
setState({ operatorMessage: "run blocked: task/HAL feed motion plan not loaded" });
return null;
}
const ready = validateRunPreconditions(state, { requireTaskHalSession: true }); const ready = validateRunPreconditions(state, { requireTaskHalSession: true });
if (!ready.ok) { if (!ready.ok) {
@@ -1588,21 +1734,151 @@ export function createSimulationStore(seed = {}) {
return null; return null;
} }
return runTaskHalCommandSequence([ stopTaskHalStatusLoop("restarted", {
operatorMessage: "task/HAL status loop restarting",
});
const status = await runTaskHalCommandSequence([
{ type: "EMC_TASK_SET_STATE", state: "ON" }, { type: "EMC_TASK_SET_STATE", state: "ON" },
{ type: "EMC_TASK_SET_MODE", mode: "AUTO" }, { type: "EMC_TASK_SET_MODE", mode: "AUTO" },
{ type: "EMC_TASK_PLAN_RUN", line: Math.max(Number(state.activeLine || 1) - Number(state.programStartLine || 1), 0) }, { type: "EMC_TASK_PLAN_RUN", line: 0 },
], { ], {
taskCycles: 5, taskCycles: 5,
operatorMessage: `task/HAL program run ${ready.profileId} ${ready.kinematicsModuleId}`, operatorMessage: `task/HAL program run ${ready.profileId} ${ready.kinematicsModuleId}`,
allowFixtureSession: false, allowFixtureSession: false,
}); });
if (shouldContinueTaskHalStatusLoop(state, status)) {
startTaskHalStatusLoop({
profileId: ready.profileId,
iniPath: ready.iniPath,
kinematicsModuleId: ready.kinematicsModuleId,
operatorMessage: `task/HAL status loop running ${ready.profileId} ${ready.kinematicsModuleId}`,
});
}
return status;
};
const loadTaskHalMotionPlanForSession = async (session = state.taskHalSession) => {
if (!state.taskHalRuntime?.loaded || typeof state.taskHalRuntime.loadProgramMotionPlan !== "function") {
return null;
}
const motion = state.programExecution?.motion || [];
const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution);
if (!session?.programPath || !Array.isArray(motion) || motion.length === 0 || !Array.isArray(timing?.segments) || timing.segments.length === 0) {
return null;
}
const plan = buildTaskHalProgramMotionPlan({
programPath: session.programPath,
motion,
timing,
programLines: state.programLines,
linearUnits: timing.linearUnits || state.profile?.traj?.linearUnits || "mm",
});
if (plan.segmentCount <= 0) {
return null;
}
await state.taskHalRuntime.loadProgramMotionPlan(plan);
return plan;
};
const startTaskHalStatusLoop = ({
profileId = state.machineProfile,
iniPath = state.profile?.iniPath || null,
kinematicsModuleId = state.profile?.kinematicsModuleId || state.machineProfile,
batchSize = 5,
intervalMs = 25,
taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs,
servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs,
operatorMessage = "task/HAL status loop running",
} = {}) => {
if (!state.taskHalRuntime?.loaded) return null;
stopTaskHalStatusLoop("restarted", { notify: false });
const sequence = Number(state.taskHalStatusLoop?.sequence || 0) + 1;
dispatch({
type: "TASK_HAL_STATUS_LOOP_STARTED",
sequence,
profileId,
iniPath,
kinematicsModuleId,
batchSize,
intervalMs,
taskPeriodNs,
servoPeriodNs,
operatorMessage,
});
const tick = () => runTaskHalStatusLoopTick(sequence).catch((error) => {
stopTaskHalStatusLoop("error", {
error: error instanceof Error ? error.message : String(error),
operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`,
});
});
taskHalStatusLoopTimer = setTimeout(tick, intervalMs);
return sequence;
};
const runTaskHalStatusLoopTick = async (sequence) => {
const loop = state.taskHalStatusLoop || {};
if (!loop.active || loop.sequence !== sequence || !state.taskHalRuntime?.loaded) {
return null;
}
await state.taskHalRuntime.runCycles({
taskPeriodNs: loop.taskPeriodNs,
servoPeriodNs: loop.servoPeriodNs,
taskCycles: loop.batchSize,
});
const status = await state.taskHalRuntime.readStatus();
if (state.taskHalStatusLoop?.sequence !== sequence) {
return status;
}
dispatch({
type: "TASK_HAL_STATUS_APPLIED",
status,
loopSequence: sequence,
operatorMessage: `task/HAL status tick ${Number(state.taskHalStatusLoop?.tickCount || 0) + 1}`,
});
if (shouldContinueTaskHalStatusLoop(state, status)) {
taskHalStatusLoopTimer = setTimeout(
() => runTaskHalStatusLoopTick(sequence).catch((error) => {
stopTaskHalStatusLoop("error", {
error: error instanceof Error ? error.message : String(error),
operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`,
});
}),
Number(state.taskHalStatusLoop?.intervalMs || loop.intervalMs || 25),
);
} else {
stopTaskHalStatusLoop(state.runState === "complete" ? "complete" : state.runState, {
operatorMessage: state.runState === "complete"
? "task/HAL program complete"
: `task/HAL status loop ${state.runState}`,
});
}
return status;
};
const stopTaskHalStatusLoop = (reason = "stopped", {
error = null,
operatorMessage = null,
notify = true,
} = {}) => {
if (taskHalStatusLoopTimer) {
clearTimeout(taskHalStatusLoopTimer);
taskHalStatusLoopTimer = null;
}
if (notify && (state.taskHalStatusLoop?.active || state.taskHalStatusLoop?.stopReason !== reason || error)) {
dispatch({
type: "TASK_HAL_STATUS_LOOP_STOPPED",
reason,
error,
operatorMessage,
});
}
}; };
const runTaskHalCommandSequence = async (commands, { const runTaskHalCommandSequence = async (commands, {
taskCycles = 1, taskCycles = 1,
taskPeriodNs = 10000000, taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs,
servoPeriodNs = 1000000, servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs,
operatorMessage = "task/HAL command complete", operatorMessage = "task/HAL command complete",
pendingJogCommand = null, pendingJogCommand = null,
allowFixtureSession = true, allowFixtureSession = true,
@@ -1939,16 +2215,37 @@ function expectedTaskHalProgramPathForState(state = {}) {
} }
} }
function deriveTaskHalCyclePeriods(state = {}) {
const taskCycleTimeSeconds = Number(state.linuxCncIniConfig?.task?.cycleTimeSeconds);
const iniTaskPeriodNs = Number.isFinite(taskCycleTimeSeconds) && taskCycleTimeSeconds > 0
? Math.round(taskCycleTimeSeconds * 1_000_000_000)
: null;
const iniServoPeriodNs = Number(state.linuxCncIniConfig?.emcmot?.servoPeriodNs);
return {
taskPeriodNs: iniTaskPeriodNs || 10000000,
servoPeriodNs: Number.isFinite(iniServoPeriodNs) && iniServoPeriodNs > 0
? Math.round(iniServoPeriodNs)
: 1000000,
};
}
function normalizeCoordinates(value) { function normalizeCoordinates(value) {
return String(value || "").replace(/[^A-Za-z]/g, "").toUpperCase(); return String(value || "").replace(/[^A-Za-z]/g, "").toUpperCase();
} }
function applyTaskHalStatusPatch(state, status, operatorMessage) { function applyTaskHalStatusPatch(state, status, operatorMessage, {
loopSequence = null,
preserveMachine = null,
} = {}) {
const ui = status?.ui || {}; const ui = status?.ui || {};
const task = status?.task || {}; const task = status?.task || {};
const motion = status?.motionStatus?.motion || {}; const motion = status?.motionStatus?.motion || {};
const taskState = normalizeTaskHalTaskState(ui.taskState || task.state); const rawTaskState = normalizeTaskHalTaskState(ui.taskState || task.state);
const taskMode = normalizeLinuxCncTaskMode(ui.taskMode || task.mode || state.machine.mode); const taskState = preserveMachine?.powerOn && rawTaskState === "estop-reset"
? "on"
: rawTaskState;
const taskMode = normalizeLinuxCncTaskMode(preserveMachine?.mode || ui.taskMode || task.mode || state.machine.mode);
const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState); const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState);
const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0); const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0);
const kinsType = resolveTaskHalKinsType(state, status, activeLine); const kinsType = resolveTaskHalKinsType(state, status, activeLine);
@@ -1958,7 +2255,8 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
: state.feed.currentVelocity; : state.feed.currentVelocity;
const paused = interpState === "paused" || motion.paused === true; const paused = interpState === "paused" || motion.paused === true;
const aborted = motion.aborted === true; const aborted = motion.aborted === true;
const programComplete = interpState === "idle" && Number(task.nextProgramLine || 0) >= Number(task.openedLineCount || 1); const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1);
const programComplete = interpState === "idle" && Number(task.nextProgramLine || 0) >= openedProgramLineCount;
const runState = aborted const runState = aborted
? "stopped" ? "stopped"
: paused : paused
@@ -1972,6 +2270,13 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
: state.runState === "jogging" : state.runState === "jogging"
? "jogging" ? "jogging"
: "idle"; : "idle";
const runtimeFeedback = createTaskHalRuntimeFeedback(state, status, axisPose, activeLine);
const loopActive = state.taskHalStatusLoop?.active === true
&& loopSequence !== null
&& Number(state.taskHalStatusLoop.sequence) === Number(loopSequence)
&& (runState === "running" || runState === "mdi");
const nextTickCount = loopActive ? Number(state.taskHalStatusLoop.tickCount || 0) + 1 : Number(state.taskHalStatusLoop?.tickCount || 0);
const feedbackHistory = [runtimeFeedback, ...(state.programRuntimeFeedbackHistory || [])].slice(0, 100);
return { return {
taskHalStatus: status, taskHalStatus: status,
@@ -1982,9 +2287,7 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
axisPose, axisPose,
kinsType, kinsType,
rtcpState: rtcpStateFromKinsType(kinsType), rtcpState: rtcpStateFromKinsType(kinsType),
programExecutionSourceMode: state.programExecution programExecutionSourceMode: "linuxcnc-task-motion-hal-wasm",
? state.programExecutionSourceMode
: "linuxcnc-task-motion-hal-wasm",
machine: { machine: {
...state.machine, ...state.machine,
powerOn: taskState === "on", powerOn: taskState === "on",
@@ -1994,17 +2297,42 @@ function applyTaskHalStatusPatch(state, status, operatorMessage) {
interpState, interpState,
interpResumeState: paused ? state.machine.interpResumeState || "reading" : interpState, interpResumeState: paused ? state.machine.interpResumeState || "reading" : interpState,
taskPaused: paused, taskPaused: paused,
allHomed: Boolean(preserveMachine?.allHomed ?? state.machine.allHomed),
}, },
runState, runState,
taskHalStatusLoop: loopSequence === null
? state.taskHalStatusLoop
: {
...state.taskHalStatusLoop,
active: loopActive,
tickCount: nextTickCount,
lastStatusAt: new Date().toISOString(),
stopReason: loopActive ? null : runState,
},
feed: { feed: {
...state.feed, ...state.feed,
currentVelocity, currentVelocity,
}, },
programRuntimeFeedback: createTaskHalRuntimeFeedback(state, status, axisPose, activeLine), programRuntimeFeedback: runtimeFeedback,
programRuntimeFeedbackHistory: feedbackHistory,
operatorMessage, operatorMessage,
}; };
} }
function shouldContinueTaskHalStatusLoop(state = {}, status = {}) {
const ui = status?.ui || {};
const task = status?.task || {};
const motion = status?.motionStatus?.motion || {};
const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState);
const taskMode = normalizeLinuxCncTaskMode(ui.taskMode || task.mode || state.machine?.mode);
const aborted = motion.aborted === true;
const paused = interpState === "paused" || motion.paused === true;
const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1);
const complete = interpState === "idle"
&& Number(task.nextProgramLine || 0) >= openedProgramLineCount;
return !aborted && !paused && !complete && (interpState === "reading" || taskMode === "mdi");
}
function resolveTaskHalKinsType(state, status, activeLine) { function resolveTaskHalKinsType(state, status, activeLine) {
const ui = status?.ui || {}; const ui = status?.ui || {};
const numeric = Number(ui.switchkinsType); const numeric = Number(ui.switchkinsType);
@@ -2095,6 +2423,8 @@ function wouldResetNonZeroPoseToLocalZero(currentPose = {}, nextPose = {}) {
function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) { function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) {
const ui = status?.ui || {}; const ui = status?.ui || {};
const motion = status?.motionStatus?.motion || {}; const motion = status?.motionStatus?.motion || {};
const halProgramLine = Number(status?.halSnapshot?.pins?.["motion.program-line"]?.value || 0);
const motionProgramLine = Number(motion.programLine || 0);
return { return {
apiName: "web-rtcp-5axis-program-runtime-feedback", apiName: "web-rtcp-5axis-program-runtime-feedback",
sourceMode: "linuxcnc-task-motion-hal-wasm", sourceMode: "linuxcnc-task-motion-hal-wasm",
@@ -2102,6 +2432,10 @@ function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) {
sampleIndex: Number(ui.servoCycle || 0), sampleIndex: Number(ui.servoCycle || 0),
motionIndex: Math.max(Number(ui.activeLine || 1) - 1, 0), motionIndex: Math.max(Number(ui.activeLine || 1) - 1, 0),
line: activeLine, line: activeLine,
motionProgramLine,
halProgramLine,
activeLineSource: ui.activeLineSource || (motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback"),
activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0 && motionProgramLine === halProgramLine,
type: Number(motion.motionType || 0) === 3 ? "JOG" : "TASK_MOTION", type: Number(motion.motionType || 0) === 3 ? "JOG" : "TASK_MOTION",
timeSeconds: Number(ui.taskCycle || 0) * 0.01, timeSeconds: Number(ui.taskCycle || 0) * 0.01,
axisPose, axisPose,
@@ -2452,6 +2786,10 @@ function nextProgramRuntimeSamplePlayback(state, step) {
const sample = samples[sampleIndex]; const sample = samples[sampleIndex];
const motionIndex = clampMotionIndex(state, sample.motionIndex); const motionIndex = clampMotionIndex(state, sample.motionIndex);
const motion = state.programExecution?.motion?.[motionIndex] || null; const motion = state.programExecution?.motion?.[motionIndex] || null;
const sampleWithUnits = {
...sample,
linearUnits: sample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits,
};
const segment = timing?.segments?.[motionIndex] || null; const segment = timing?.segments?.[motionIndex] || null;
const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType; const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType;
const elapsedSeconds = Number(sample.timeSeconds) || Number(segment?.elapsedSeconds) || 0; const elapsedSeconds = Number(sample.timeSeconds) || Number(segment?.elapsedSeconds) || 0;
@@ -2461,7 +2799,7 @@ function nextProgramRuntimeSamplePlayback(state, step) {
|| 0; || 0;
const runtimeFeedback = createProgramRuntimeFeedbackFromSample({ const runtimeFeedback = createProgramRuntimeFeedbackFromSample({
state, state,
sample, sample: sampleWithUnits,
sampleIndex, sampleIndex,
motion, motion,
motionIndex, motionIndex,
@@ -2473,7 +2811,7 @@ function nextProgramRuntimeSamplePlayback(state, step) {
motionIndex, motionIndex,
sampleIndex, sampleIndex,
activeLine: sample.line || motion?.line || state.activeLine, activeLine: sample.line || motion?.line || state.activeLine,
axisPose: axisPoseFromRuntimeSample(sample, motion, state.axisPose), axisPose: axisPoseFromRuntimeSample(sampleWithUnits, motion, state.axisPose),
kinsType, kinsType,
rtcpState: rtcpStateFromKinsType(kinsType), rtcpState: rtcpStateFromKinsType(kinsType),
timing: { timing: {
@@ -2507,9 +2845,13 @@ function nextProgramRuntimeSamplePlayback(state, step) {
function createInitialProgramRuntimeFeedback({ state, timing, motion, timingSnapshot }) { function createInitialProgramRuntimeFeedback({ state, timing, motion, timingSnapshot }) {
const firstSample = timing?.samples?.[0] || null; const firstSample = timing?.samples?.[0] || null;
if (firstSample) { if (firstSample) {
const sampleWithUnits = {
...firstSample,
linearUnits: firstSample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits,
};
return createProgramRuntimeFeedbackFromSample({ return createProgramRuntimeFeedbackFromSample({
state, state,
sample: firstSample, sample: sampleWithUnits,
sampleIndex: 0, sampleIndex: 0,
motion, motion,
motionIndex: clampMotionIndex(state, firstSample.motionIndex), motionIndex: clampMotionIndex(state, firstSample.motionIndex),
@@ -2537,11 +2879,13 @@ function clampMotionIndex(state, motionIndex) {
} }
function buildTimingForState(state, execution) { function buildTimingForState(state, execution) {
if (execution?.plannerTiming?.plannerRuntimeReady === true) { const motion = execution?.motion || [];
const requiresFeedModeTiming = motion.some((event) => event?.feedMode === "inverse-time");
if (!requiresFeedModeTiming && execution?.plannerTiming?.plannerRuntimeReady === true) {
return execution.plannerTiming; return execution.plannerTiming;
} }
return buildProgramExecutionTiming({ return buildProgramExecutionTiming({
motion: execution?.motion || [], motion,
profile: state.profile, profile: state.profile,
feedOverride: state.feed.feedOverride, feedOverride: state.feed.feedOverride,
rapidOverride: state.feed.rapidOverride, rapidOverride: state.feed.rapidOverride,
@@ -2713,6 +3057,7 @@ function createProgramRuntimeFeedbackFromSample({
motionIndex, motionIndex,
line: sample?.line || motion?.line || null, line: sample?.line || motion?.line || null,
type: sample?.type || motion?.type || null, type: sample?.type || motion?.type || null,
linearUnits: sample?.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits || "mm",
timeSeconds: elapsedSeconds, timeSeconds: elapsedSeconds,
axisPose, axisPose,
currentVelocityMmPerMin: currentVelocity, currentVelocityMmPerMin: currentVelocity,
@@ -2750,6 +3095,7 @@ function createProgramRuntimeFeedbackFromMotion({
motionIndex, motionIndex,
line: motion?.line || null, line: motion?.line || null,
type: motion?.type || null, type: motion?.type || null,
linearUnits: motion?.linearUnits || state.profile.traj?.linearUnits || "mm",
timeSeconds: Number(timing?.elapsedSeconds) || 0, timeSeconds: Number(timing?.elapsedSeconds) || 0,
axisPose, axisPose,
currentVelocityMmPerMin: Number(timing?.currentVelocity) || 0, currentVelocityMmPerMin: Number(timing?.currentVelocity) || 0,

View File

@@ -1,4 +1,5 @@
import { renderFiveAxisScene } from "../visualization/five-axis-scene.js"; import { renderFiveAxisScene } from "../visualization/five-axis-scene.js";
import { gateLinuxCncTaskAction } from "../state/linuxcnc-task-policy.js";
const REGIONS = [ const REGIONS = [
"titlebar", "titlebar",
@@ -623,7 +624,12 @@ function renderBottomControls(element, state, dispatch) {
element.innerHTML = ` element.innerHTML = `
<input type="file" class="program-file-input" data-action="OPEN_FILE" accept=".ngc,.nc,.tap,.gcode,.txt" /> <input type="file" class="program-file-input" data-action="OPEN_FILE" accept=".ngc,.nc,.tap,.gcode,.txt" />
${controls ${controls
.map(([label, action]) => `<button type="button" data-action="${action}">${label}</button>`) .map(([label, action]) => {
const gate = bottomControlGate(state, action);
const disabled = gate.allowed ? "" : " disabled";
const title = gate.allowed ? "" : ` title="${escapeHtml(gate.operatorMessage || "blocked")}"`;
return `<button type="button" data-action="${action}"${disabled}${title}>${label}</button>`;
})
.join("")} .join("")}
`; `;
@@ -650,6 +656,22 @@ function renderBottomControls(element, state, dispatch) {
} }
} }
function bottomControlGate(state, action) {
const actionMap = {
RUN: { type: "RUN" },
STEP: { type: "STEP" },
PAUSE: { type: "PAUSE" },
RESUME: { type: "RESUME" },
HOME: { type: "HOME" },
MDI_RUN: { type: "RUN_MDI" },
JOG_X_NEG: { type: "JOG" },
JOG_X_POS: { type: "JOG" },
JOG_Y_NEG: { type: "JOG" },
JOG_Y_POS: { type: "JOG" },
};
return gateLinuxCncTaskAction(state, actionMap[action] || { type: "UI_CONTROL" });
}
function formatNumber(value, digits = 3) { function formatNumber(value, digits = 3) {
return Number(value).toFixed(digits); return Number(value).toFixed(digits);
} }

View File

@@ -1,13 +1,19 @@
import * as THREE from "../vendor/three/three.module.js"; import * as THREE from "../vendor/three/three.module.js";
import {
linearUnitsLabel,
linearUnitsToMetersFactor,
linearValueToMeters,
resolveStateLinearUnits,
} from "../runtime/linear-units.js";
const scenes = new WeakMap(); const scenes = new WeakMap();
const CAMERA_PRESETS = { const CAMERA_PRESETS = {
iso: { theta: -0.96, phi: 1.02, radius: 7.0, target: new THREE.Vector3(0, 0, 0) }, iso: { theta: -0.96, phi: 1.02, radius: 0.72, target: new THREE.Vector3(0, 0, 0) },
x: { theta: 0, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) }, x: { theta: 0, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 6.2, target: new THREE.Vector3(0, 0, 0) }, y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
z: { theta: 0, phi: 0.001, radius: 6.6, target: new THREE.Vector3(0, 0, 0) }, z: { theta: 0, phi: 0.001, radius: 0.68, target: new THREE.Vector3(0, 0, 0) },
}; };
const MAX_TOOLPATH_POINTS = 1600; const MAX_TOOLPATH_POINTS = Number.POSITIVE_INFINITY;
const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]); const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]);
export function renderFiveAxisScene(canvas, state) { export function renderFiveAxisScene(canvas, state) {
@@ -51,6 +57,11 @@ export function renderFiveAxisScene(canvas, state) {
toolExecutionMarker: preview.toolMarker.visible, toolExecutionMarker: preview.toolMarker.visible,
toolAxisMarker: preview.toolAxis.visible, toolAxisMarker: preview.toolAxis.visible,
pathFitBounds: preview.pathFitBoundsReady, pathFitBounds: preview.pathFitBoundsReady,
pathBounds: computePointBoundsFromGeometryGroups([
preview.previewPath.geometry,
preview.executedPath.geometry,
preview.currentSegmentPath.geometry,
]),
}); });
} }
@@ -72,7 +83,7 @@ function createScene(canvas) {
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100); const camera = new THREE.PerspectiveCamera(42, 1, 0.001, 10);
const machineModel = createMachineReferenceModel(); const machineModel = createMachineReferenceModel();
scene.add(machineModel.root); scene.add(machineModel.root);
@@ -84,7 +95,7 @@ function createScene(canvas) {
const arcPath = createLine(0xd7ff62, 0.95); const arcPath = createLine(0xd7ff62, 0.95);
const currentSegmentPath = createLine(0xff4fd8, 1); const currentSegmentPath = createLine(0xff4fd8, 1);
const toolMarker = new THREE.Mesh( const toolMarker = new THREE.Mesh(
new THREE.SphereGeometry(0.065, 18, 12), new THREE.SphereGeometry(0.0065, 18, 12),
new THREE.MeshBasicMaterial({ color: 0x1ffff4 }), new THREE.MeshBasicMaterial({ color: 0x1ffff4 }),
); );
const toolAxis = new THREE.Line( const toolAxis = new THREE.Line(
@@ -165,6 +176,7 @@ function renderFallbackPreview(preview, state) {
arcPointCount: arcPoints.length, arcPointCount: arcPoints.length,
currentSegmentPointCount: currentSegmentPoints.length, currentSegmentPointCount: currentSegmentPoints.length,
pathFitBounds: computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints)) !== null, pathFitBounds: computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints)) !== null,
pathBounds: summarizeBounds(computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints))),
}); });
canvas.dataset.threeFallbackReason = preview.errorMessage; canvas.dataset.threeFallbackReason = preview.errorMessage;
@@ -177,7 +189,7 @@ function renderFallbackPreview(preview, state) {
const cx = width * 0.5; const cx = width * 0.5;
const cy = height * 0.53; const cy = height * 0.53;
const scale = Math.min(width / 7.2, height / 4.8); const scale = Math.min(width / 0.72, height / 0.48);
drawFallbackMachineReference(ctx, cx, cy, scale, state); drawFallbackMachineReference(ctx, cx, cy, scale, state);
@@ -225,6 +237,9 @@ function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeExecutedPathPoints = String(preview.executedPointCount); canvas.dataset.threeExecutedPathPoints = String(preview.executedPointCount);
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount); canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead)); canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
canvas.dataset.threeSceneUnits = "m";
canvas.dataset.threeLinearUnits = linearUnitsLabel(resolveSceneLinearUnits(state));
canvas.dataset.threeLinearUnitScaleToMeters = String(linearUnitsToMetersFactor(resolveSceneLinearUnits(state)));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector)); canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose)); canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
canvas.dataset.threeRtcpState = state.rtcpState; canvas.dataset.threeRtcpState = state.rtcpState;
@@ -244,6 +259,7 @@ function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeToolpathPreviewSource = toolpathPreviewSource(state); canvas.dataset.threeToolpathPreviewSource = toolpathPreviewSource(state);
canvas.dataset.threeToolExecutionTraceSource = toolExecutionTraceSource(state); canvas.dataset.threeToolExecutionTraceSource = toolExecutionTraceSource(state);
canvas.dataset.threePathFitBounds = preview.pathFitBounds ? "ok" : "pending"; canvas.dataset.threePathFitBounds = preview.pathFitBounds ? "ok" : "pending";
canvas.dataset.threePathBoundsMeters = JSON.stringify(preview.pathBounds || null);
canvas.dataset.threeCurrentSegmentHighlight = preview.currentSegmentPointCount > 0 ? "ok" : "pending"; canvas.dataset.threeCurrentSegmentHighlight = preview.currentSegmentPointCount > 0 ? "ok" : "pending";
canvas.dataset.threeRapidFeedVisualDistinction = preview.rapidPointCount > 0 || preview.feedPointCount > 0 || preview.arcPointCount > 0 ? "ok" : "pending"; canvas.dataset.threeRapidFeedVisualDistinction = preview.rapidPointCount > 0 || preview.feedPointCount > 0 || preview.arcPointCount > 0 ? "ok" : "pending";
canvas.dataset.threeNoGcodeSemanticsGeneration = "ok"; canvas.dataset.threeNoGcodeSemanticsGeneration = "ok";
@@ -270,47 +286,47 @@ function createMachineReferenceModel() {
root.name = "five-axis-machine-reference"; root.name = "five-axis-machine-reference";
const base = new THREE.Mesh( const base = new THREE.Mesh(
new THREE.BoxGeometry(4.8, 3.2, 0.08), new THREE.BoxGeometry(0.48, 0.32, 0.008),
new THREE.MeshBasicMaterial({ color: 0x222930 }), new THREE.MeshBasicMaterial({ color: 0x222930 }),
); );
base.position.z = -0.16; base.position.z = -0.016;
const table = new THREE.Mesh( const table = new THREE.Mesh(
new THREE.BoxGeometry(3.7, 2.35, 0.05), new THREE.BoxGeometry(0.37, 0.235, 0.005),
new THREE.MeshBasicMaterial({ color: 0x3a444d, transparent: true, opacity: 0.78 }), new THREE.MeshBasicMaterial({ color: 0x3a444d, transparent: true, opacity: 0.78 }),
); );
table.position.z = -0.08; table.position.z = -0.008;
const xAxis = createStaticLine([new THREE.Vector3(-2.2, 0, 0), new THREE.Vector3(2.25, 0, 0)], 0xff4d4d); const xAxis = createStaticLine([new THREE.Vector3(-0.22, 0, 0), new THREE.Vector3(0.225, 0, 0)], 0xff4d4d);
const yAxis = createStaticLine([new THREE.Vector3(0, -1.55, 0), new THREE.Vector3(0, 1.6, 0)], 0x70df7d); const yAxis = createStaticLine([new THREE.Vector3(0, -0.155, 0), new THREE.Vector3(0, 0.16, 0)], 0x70df7d);
const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.08), new THREE.Vector3(0, 0, 1.75)], 0x5aa7ff); const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.008), new THREE.Vector3(0, 0, 0.175)], 0x5aa7ff);
const rotaryA = new THREE.Mesh( const rotaryA = new THREE.Mesh(
new THREE.TorusGeometry(0.88, 0.018, 8, 72), new THREE.TorusGeometry(0.088, 0.0018, 8, 72),
new THREE.MeshBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.92 }), new THREE.MeshBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.92 }),
); );
rotaryA.rotation.y = Math.PI / 2; rotaryA.rotation.y = Math.PI / 2;
const rotaryC = new THREE.Mesh( const rotaryC = new THREE.Mesh(
new THREE.TorusGeometry(1.1, 0.016, 8, 72), new THREE.TorusGeometry(0.11, 0.0016, 8, 72),
new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.9 }), new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.9 }),
); );
rotaryC.rotation.x = Math.PI / 2; rotaryC.rotation.x = Math.PI / 2;
rotaryC.position.z = 0.04; rotaryC.position.z = 0.004;
const toolHolder = new THREE.Group(); const toolHolder = new THREE.Group();
const holderBody = new THREE.Mesh( const holderBody = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, 0.42, 18), new THREE.CylinderGeometry(0.008, 0.008, 0.042, 18),
new THREE.MeshBasicMaterial({ color: 0xf1f5f9 }), new THREE.MeshBasicMaterial({ color: 0xf1f5f9 }),
); );
holderBody.rotation.x = Math.PI / 2; holderBody.rotation.x = Math.PI / 2;
holderBody.position.z = 0.32; holderBody.position.z = 0.032;
const cutter = new THREE.Mesh( const cutter = new THREE.Mesh(
new THREE.ConeGeometry(0.06, 0.25, 18), new THREE.ConeGeometry(0.006, 0.025, 18),
new THREE.MeshBasicMaterial({ color: 0xfff176 }), new THREE.MeshBasicMaterial({ color: 0xfff176 }),
); );
cutter.rotation.x = Math.PI; cutter.rotation.x = Math.PI;
cutter.position.z = 0.08; cutter.position.z = 0.008;
toolHolder.add(holderBody, cutter); toolHolder.add(holderBody, cutter);
root.add(base, table, xAxis, yAxis, zAxis, rotaryA, rotaryC, toolHolder); root.add(base, table, xAxis, yAxis, zAxis, rotaryA, rotaryC, toolHolder);
@@ -387,7 +403,7 @@ function updateToolExecutionMarker(preview, state, toolPosition) {
preview.toolAxis.visible = true; preview.toolAxis.visible = true;
updateLineGeometry(preview.toolAxis, [ updateLineGeometry(preview.toolAxis, [
toolPosition, toolPosition,
toolPosition.clone().add(vector.multiplyScalar(0.7)), toolPosition.clone().add(vector.multiplyScalar(0.07)),
]); ]);
} }
@@ -401,7 +417,7 @@ function updateMachineReferenceModel(preview, state, toolPosition) {
model.rotaryA.rotation.y = Math.PI / 2 + b; model.rotaryA.rotation.y = Math.PI / 2 + b;
model.rotaryC.rotation.z = c; model.rotaryC.rotation.z = c;
const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose); const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose, state);
model.toolHolder.position.copy(tcpPosition); model.toolHolder.position.copy(tcpPosition);
const toolVector = toToolVector(state.toolAxisVector); const toolVector = toToolVector(state.toolAxisVector);
model.toolHolder.lookAt(tcpPosition.clone().add(toolVector)); model.toolHolder.lookAt(tcpPosition.clone().add(toolVector));
@@ -422,12 +438,12 @@ function geometryPointCount(geometry) {
function buildProgramPreviewPoints(state) { function buildProgramPreviewPoints(state) {
const motion = state.programExecution?.motion; const motion = state.programExecution?.motion;
if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) { if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) {
return limitPoints(motion.map((event) => vectorFromAxes(event.axes))); return limitPoints(motion.map((event) => vectorFromAxes(event.axes, state, event.linearUnits)));
} }
const pointCount = normalizePathPointCount(state.preview.pathPoints); const pointCount = normalizePathPointCount(state.preview.pathPoints);
if (pointCount === 0) return []; if (pointCount === 0) return [];
return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose)); return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose, state));
} }
function buildExecutedProgramPoints(state, previewPoints) { function buildExecutedProgramPoints(state, previewPoints) {
@@ -436,7 +452,7 @@ function buildExecutedProgramPoints(state, previewPoints) {
const sampleIndex = Number(state.programExecutionSampleIndex || 0); const sampleIndex = Number(state.programExecutionSampleIndex || 0);
if (Array.isArray(samples) && samples.length > 0) { if (Array.isArray(samples) && samples.length > 0) {
const end = clamp(Math.round(sampleIndex), 0, samples.length - 1); const end = clamp(Math.round(sampleIndex), 0, samples.length - 1);
return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample))); return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample, state, sample.linearUnits)));
} }
const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1); const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1);
@@ -453,7 +469,7 @@ function buildTypedPreviewPoints(state, type) {
return limitPoints( return limitPoints(
motion motion
.filter((event) => event.type === type) .filter((event) => event.type === type)
.map((event) => vectorFromAxes(event.axes)), .map((event) => vectorFromAxes(event.axes, state, event.linearUnits)),
); );
} }
@@ -465,8 +481,10 @@ function buildCurrentSegmentPoints(state) {
const current = motion[motionIndex]; const current = motion[motionIndex];
const previous = motion[Math.max(motionIndex - 1, 0)]; const previous = motion[Math.max(motionIndex - 1, 0)];
if (!current) return []; if (!current) return [];
const start = motionIndex === 0 ? vectorFromAxes(previous?.axes || current.axes) : vectorFromAxes(previous.axes); const start = motionIndex === 0
const end = vectorFromAxes(current.axes); ? vectorFromAxes(previous?.axes || current.axes, state, previous?.linearUnits || current.linearUnits)
: vectorFromAxes(previous.axes, state, previous.linearUnits);
const end = vectorFromAxes(current.axes, state, current.linearUnits);
return start.distanceTo(end) > 0 ? [start, end] : [end]; return start.distanceTo(end) > 0 ? [start, end] : [end];
} }
@@ -474,9 +492,9 @@ function buildFixturePreviewPoints(pointCount, tcpPosition) {
const points = []; const points = [];
for (let index = 0; index < pointCount; index += 1) { for (let index = 0; index < pointCount; index += 1) {
const t = pointCount === 1 ? 0 : index / (pointCount - 1); const t = pointCount === 1 ? 0 : index / (pointCount - 1);
const x = -2.45 + t * 4.9; const x = -0.085 + t * 0.17;
const y = Math.sin(t * Math.PI * 13) * 0.36; const y = Math.sin(t * Math.PI * 13) * 0.012;
const z = -0.68 + Math.sin(t * Math.PI * 2) * 0.42; const z = 0.012 + Math.sin(t * Math.PI * 2) * 0.018;
points.push(new THREE.Vector3(x, y, z)); points.push(new THREE.Vector3(x, y, z));
} }
if (points.length > 0 && tcpPosition) { if (points.length > 0 && tcpPosition) {
@@ -487,33 +505,42 @@ function buildFixturePreviewPoints(pointCount, tcpPosition) {
function executionToolPosition(state, previewPoints) { function executionToolPosition(state, previewPoints) {
const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback; const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback;
if (feedbackAxes && hasLinearAxes(feedbackAxes)) return vectorFromAxes(feedbackAxes); if (feedbackAxes && hasLinearAxes(feedbackAxes)) {
if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose); return vectorFromAxes(feedbackAxes, state, state.programRuntimeFeedback?.linearUnits);
}
if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose, state);
return previewPoints.at(-1) || null; return previewPoints.at(-1) || null;
} }
function vectorFromAxes(axes = {}) { export function axesToSceneMeters(axes = {}, state = {}, linearUnits = null) {
const units = linearUnits || axes.linearUnits || resolveSceneLinearUnits(state);
return {
x: linearValueToMeters(axes.x, units),
y: linearValueToMeters(axes.y, units),
z: linearValueToMeters(axes.z, units),
};
}
function vectorFromAxes(axes = {}, state = {}, linearUnits = null) {
const point = axesToSceneMeters(axes, state, linearUnits);
return new THREE.Vector3( return new THREE.Vector3(
scaleLinearAxis(axes.x), point.x,
scaleLinearAxis(axes.y), point.y,
scaleZAxis(axes.z), point.z,
); );
} }
function toPreviewVector(pose = {}) { function toPreviewVector(pose = {}, state = {}) {
const point = axesToSceneMeters(pose, state);
return new THREE.Vector3( return new THREE.Vector3(
scaleLinearAxis(pose.x), point.x,
scaleLinearAxis(pose.y), point.y,
scaleZAxis(pose.z), point.z,
); );
} }
function scaleLinearAxis(value) { function resolveSceneLinearUnits(state) {
return clamp((Number(value) || 0) * 0.035, -2.7, 2.7); return resolveStateLinearUnits(state);
}
function scaleZAxis(value) {
return clamp((Number(value) || 0) * 0.04 + 0.35, -1.1, 1.9);
} }
function toToolVector(vector = {}) { function toToolVector(vector = {}) {
@@ -596,6 +623,35 @@ function computePointBounds(points) {
return box; return box;
} }
function computePointBoundsFromGeometryGroups(geometries) {
const points = [];
for (const geometry of geometries) {
const position = geometry?.getAttribute("position");
if (!position) continue;
for (let index = 0; index < position.count; index += 1) {
points.push(new THREE.Vector3(
position.getX(index),
position.getY(index),
position.getZ(index),
));
}
}
return summarizeBounds(computePointBounds(points));
}
function summarizeBounds(bounds) {
if (!bounds) return null;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
bounds.getCenter(center);
bounds.getSize(size);
return {
center: toRoundedVector(center),
size: toRoundedVector(size),
maxSpan: Number(Math.max(size.x, size.y, size.z).toFixed(6)),
};
}
function drawFallbackPolyline(ctx, points, cx, cy, scale) { function drawFallbackPolyline(ctx, points, cx, cy, scale) {
for (let index = 0; index < points.length; index += 1) { for (let index = 0; index < points.length; index += 1) {
const point = points[index]; const point = points[index];
@@ -607,27 +663,27 @@ function drawFallbackPolyline(ctx, points, cx, cy, scale) {
} }
function drawFallbackMachineReference(ctx, cx, cy, scale, state) { function drawFallbackMachineReference(ctx, cx, cy, scale, state) {
const tableWidth = 4.8 * scale; const tableWidth = 0.48 * scale;
const tableHeight = 3.2 * scale; const tableHeight = 0.32 * scale;
ctx.fillStyle = "#20272e"; ctx.fillStyle = "#20272e";
ctx.strokeStyle = "#56616b"; ctx.strokeStyle = "#56616b";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.fillRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight); ctx.fillRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
ctx.strokeRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight); ctx.strokeRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
drawFallbackAxis(ctx, cx - 2.25 * scale, cy, cx + 2.25 * scale, cy, "#ff4d4d"); drawFallbackAxis(ctx, cx - 0.225 * scale, cy, cx + 0.225 * scale, cy, "#ff4d4d");
drawFallbackAxis(ctx, cx, cy + 1.55 * scale, cx, cy - 1.6 * scale, "#70df7d"); drawFallbackAxis(ctx, cx, cy + 0.155 * scale, cx, cy - 0.16 * scale, "#70df7d");
drawFallbackAxis(ctx, cx, cy + 0.2 * scale, cx, cy - 1.15 * scale, "#5aa7ff"); drawFallbackAxis(ctx, cx, cy + 0.02 * scale, cx, cy - 0.115 * scale, "#5aa7ff");
ctx.strokeStyle = "#1ffff4"; ctx.strokeStyle = "#1ffff4";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(cx, cy, 0.92 * scale, 0.42 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2); ctx.ellipse(cx, cy, 0.092 * scale, 0.042 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.strokeStyle = "#ffd166"; ctx.strokeStyle = "#ffd166";
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, 0.7 * scale, 0, Math.PI * 2); ctx.arc(cx, cy, 0.07 * scale, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
const tcp = executionToolPosition(state, []); const tcp = executionToolPosition(state, []);
@@ -637,12 +693,12 @@ function drawFallbackMachineReference(ctx, cx, cy, scale, state) {
ctx.strokeStyle = "#f1f5f9"; ctx.strokeStyle = "#f1f5f9";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(toolX, toolY - 0.38 * scale); ctx.moveTo(toolX, toolY - 0.038 * scale);
ctx.lineTo(toolX, toolY - 0.08 * scale); ctx.lineTo(toolX, toolY - 0.008 * scale);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = "#1ffff4"; ctx.fillStyle = "#1ffff4";
ctx.beginPath(); ctx.beginPath();
ctx.arc(toolX, toolY, 0.07 * scale, 0, Math.PI * 2); ctx.arc(toolX, toolY, 0.007 * scale, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
} }
@@ -676,7 +732,7 @@ function createToolpathCameraControls(canvas, camera, renderFrame) {
canvas.addEventListener("wheel", (event) => { canvas.addEventListener("wheel", (event) => {
event.preventDefault(); event.preventDefault();
const scale = Math.exp(Math.sign(event.deltaY) * 0.12); const scale = Math.exp(Math.sign(event.deltaY) * 0.12);
controls.radius = clamp(controls.radius * scale, 1.2, 28); controls.radius = clamp(controls.radius * scale, 0.06, 4);
applyCameraControls(controls); applyCameraControls(controls);
controls.renderFrame(); controls.renderFrame();
}, { passive: false }); }, { passive: false });
@@ -700,7 +756,7 @@ function createToolpathCameraControls(canvas, camera, renderFrame) {
const distance = getPointerDistance(controls.pointers); const distance = getPointerDistance(controls.pointers);
const center = getPointerCenter(controls.pointers); const center = getPointerCenter(controls.pointers);
if (distance > 0 && controls.lastPinchDistance > 0) { if (distance > 0 && controls.lastPinchDistance > 0) {
controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 1.2, 28); controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 0.06, 4);
if (controls.lastPinchCenter) { if (controls.lastPinchCenter) {
panCamera( panCamera(
controls, controls,
@@ -810,9 +866,9 @@ function applyFitBounds(controls, selectedView, fitPoints) {
bounds.getCenter(center); bounds.getCenter(center);
bounds.getSize(size); bounds.getSize(size);
controls.target.copy(center); controls.target.copy(center);
const maxSpan = Math.max(size.x, size.y, size.z, 0.8); const maxSpan = Math.max(size.x, size.y, size.z, 0.08);
const fitRadius = clamp(maxSpan * 1.8, 2.2, 28); const fitRadius = clamp(maxSpan * 1.8, 0.22, 4);
controls.radius = selectedView === "z" ? Math.max(fitRadius, 4.2) : fitRadius; controls.radius = selectedView === "z" ? Math.max(fitRadius, 0.42) : fitRadius;
return true; return true;
} }

View File

@@ -0,0 +1,726 @@
# G-code F 进给速度驱动 RUN 执行修复文档
生成时间2026-06-22
## 1. 问题结论
当前 `RUN` 链路已经能完成:
```text
1. 打开 LinuxCNC G-code 程序。
2. 通过 task/HAL runtime 推进状态。
3. 更新当前行、高亮行、DRO、axisPose、执行轨迹和 task/HAL feedback。
```
但当前 `RUN` **还没有按 G-code 的真实进给速度执行**
当前关键问题:
```text
G-code interpreter 能解析 F。
execution-timing.js 能基于 F / 距离 / INI 限速做 feed-based timing estimate。
但是 task/HAL RUN 执行链路没有把 F 用作真实运行速度。
C++ wrapper 层固定写入 velocity=60。
activeLine 按 task cycle 推进一行,而不是按 段距离 / F / elapsed time 推进。
```
因此现有浏览器 RUN 证据只能证明:
```text
task/HAL feedback 正在产生;
UI 当前行与 task/HAL active line 同步;
DRO 与 runtime axisPose 同步;
执行轨迹可见;
```
不能证明:
```text
程序按 G-code F 进给速度、G93/G94 模态、rapid/feed 区分、override、段距离真实定时执行。
```
## 2. 当前源码证据
### 2.1 固定 velocity=60
文件:
```text
wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_task_hal_wasm.cpp
```
函数:
```text
enqueue_linear_move_from_line(TaskRuntime &state, const std::string &line)
```
当前逻辑:
```cpp
command << ",\"velocity\":60}";
```
影响:
```text
1. 每条 G-code 运动行传给 motion runtime 的速度都是 60 units/s。
2. JS 状态层把 currentVel * 60 转成 mm/min。
3. 在 mm 单位下 UI 看到的速度固定为 3600 mm/min。
4. G-code 行中的 F159 / F318 / F636 等不会影响 RUN 实际速度。
```
### 2.2 按 task cycle 推进一行
文件:
```text
wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_task_hal_wasm.cpp
```
函数:
```text
lctask_run_cycles(long task_period_ns, long servo_period_ns, int task_cycles)
```
当前逻辑:
```cpp
for (int i = 0; i < task_cycles; ++i) {
state.task_cycle += 1;
if (state.interp_state == "READING" && state.next_program_line < state.opened_line_count) {
enqueue_linear_move_from_line(state, state.program_lines[state.next_program_line]);
...
}
lcmot_step_servo(...);
}
```
影响:
```text
1. 每个 task cycle 至多 enqueue 一条程序行。
2. active line 由 cycle 数推进。
3. 长段、短段、F 快、F 慢不会改变行推进节奏。
4. 这不是真实 LinuxCNC planner/task/motion 的时间语义。
```
### 2.3 interpreter 已有 F 信息
文件:
```text
web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-runtime.js
```
已有能力:
```text
1. feedRatesBySourceLine(programText) 从 G-code 中提取 F。
2. parseLinuxCncCanonicalMotion(...) 把 activeFeedRate 写入 motion event 的 feedRate。
3. linearUnitsBySourceLine(...) 跟踪 G20/G21。
```
输出 motion event 已包含:
```js
{
type,
line,
axes,
feedRate,
linearUnits,
...
}
```
### 2.4 timing estimate 已有 F 计算
文件:
```text
web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js
```
已有能力:
```text
1. 按 motion event feedRate 计算 requestedLinearVelocity。
2. 按 profile/INI max velocity 限速。
3. 按 linear distance / angular distance 计算 segment durationSeconds。
4. 处理 feedOverride / rapidOverride。
```
但当前边界是:
```text
semanticBoundary = linuxcnc_canonical_motion_timing_estimate_not_planner_queue
```
说明它是估算,不是 task/HAL RUN 的权威执行节奏。
## 3. 修复目标
### 3.1 必须达成
修复后 `RUN` 必须满足:
```text
1. G-code 中 F 值进入 task/HAL RUN 执行链路。
2. G94 units/min 模式下feed move 的速度来自当前 modal F。
3. G0/STRAIGHT_TRAVERSE 使用 rapid velocity / rapid override / INI max velocity。
4. activeLine 按 elapsed execution time 与当前段完成度推进。
5. 长距离低 F 段明显运行更久。
6. 短距离高 F 段明显更快完成。
7. UI currentVelocity 不再固定 3600 mm/min。
8. DRO/axisPose 可以在段内插值,而不是只在行边界跳变。
9. 现有 RUN gate、task/HAL session、status loop、STOP/ABORT/STEP 不回退。
```
### 3.2 第一阶段不强求
以下可以作为第二阶段:
```text
1. 完整 LinuxCNC trajectory planner queue 动力学一致性。
2. 加速度/jerk/圆弧真实插补完全对齐 LinuxCNC。
3. G93 inverse-time feed 的完整五轴角度/线性混合真实语义。
4. 硬件 realtime HAL 驱动。
```
但第一阶段至少不能继续固定 `velocity=60`
## 4. 推荐实现方案
### 4.1 不要继续在 C++ wrapper 中逐行粗解析 G-code
当前 C++ wrapper 的 `enqueue_linear_move_from_line()` 是字符串扫描:
```text
查找 XYZABC 字母;
strtod 读取数值;
没有模态;
没有 F
没有 G90/G91
没有 G20/G21
没有 G93/G94
没有 G0/G1/G2/G3 区分;
```
这个方向继续扩展会很脆弱。
推荐改为:
```text
JS interpreter runtime 继续负责生成 canonical motion events。
JS 侧把 canonical motion + timing segments 传入 task/HAL runtime session。
C++ task/HAL wrapper 不再直接解析 G-code 行来生成 velocity。
C++ task/HAL wrapper 按已解析 motion segment 执行。
```
### 4.2 新增 task/HAL program motion plan
`wasm-port/runtime/sdk/src/linuxcnc-task-hal.js` 增加可选 API
```js
loadProgramMotionPlan({
programPath,
motion,
timing,
linearUnits,
})
```
在 C++ wrapper 增加导出:
```cpp
int lctask_load_program_motion_plan_json(const char *plan_json);
```
motion plan 中至少包含:
```json
{
"programPath": "...",
"segments": [
{
"line": 8,
"type": "STRAIGHT_FEED",
"motionClass": "feed",
"axes": { "x": 6.302, "y": -11.560, "z": 27.743, "a": -71.841, "c": -35.930 },
"startAxes": { "...": 0 },
"feedRate": 318,
"linearUnits": "mm",
"velocityMmPerMin": 318,
"durationSeconds": 0.42,
"startSeconds": 1.25,
"elapsedSeconds": 1.67
}
]
}
```
### 4.3 C++ task runtime 状态新增字段
`TaskRuntime` 增加:
```cpp
struct MotionSegment {
int line = 0;
std::string type;
std::string motion_class;
double start_seconds = 0.0;
double duration_seconds = 0.0;
double velocity_mm_per_min = 0.0;
std::map<std::string, double> start_axes;
std::map<std::string, double> end_axes;
};
std::vector<MotionSegment> motion_plan;
int active_segment_index = 0;
double run_elapsed_seconds = 0.0;
double run_start_seconds = 0.0;
```
### 4.4 RUN 周期推进改为按 elapsed time
当前:
```text
每 task cycle enqueue 一行。
```
修复后:
```text
每次 lctask_run_cycles 根据 task_period_ns * task_cycles 增加 run_elapsed_seconds。
根据 run_elapsed_seconds 找到 active segment。
按 segment progress 插值 axisPose。
把 currentVel/requestedVel 设置为 segment.velocityMmPerMin / 60。
activeLine = segment.line。
segment 完成后才进入下一 segment。
```
伪代码:
```cpp
int lctask_run_cycles(long task_period_ns, long servo_period_ns, int task_cycles)
{
const double delta_seconds = (task_period_ns / 1e9) * task_cycles;
state.run_elapsed_seconds += delta_seconds;
const MotionSegment *segment = find_segment_at_time(state.motion_plan, state.run_elapsed_seconds);
if (!segment) {
state.interp_state = "IDLE";
state.exec_state = "DONE";
return step_servo(...);
}
const double local = state.run_elapsed_seconds - segment->start_seconds;
const double progress = clamp(local / segment->duration_seconds, 0.0, 1.0);
AxisPose pose = interpolate(segment->start_axes, segment->end_axes, progress);
forward_motion_sample({
line: segment->line,
axes: pose,
currentVel: segment->velocity_mm_per_min / 60.0,
requestedVel: segment->velocity_mm_per_min / 60.0,
inPosition: progress >= 1.0
});
step_servo(...);
}
```
### 4.5 motion runtime command schema
当前 motion command 是:
```json
{
"type": "EMC_TRAJ_LINEAR_MOVE",
"line": 12,
"x": 1,
"velocity": 60
}
```
建议新增或扩展为:
```json
{
"type": "EMC_TRAJ_LINEAR_SAMPLE",
"line": 12,
"x": 1.2,
"y": 3.4,
"z": 5.6,
"a": -70,
"c": 20,
"currentVel": 5.3,
"requestedVel": 5.3,
"segmentProgress": 0.35,
"source": "feed_timed_motion_plan"
}
```
如果不想新增 command type也可以继续使用 `EMC_TRAJ_LINEAR_MOVE`,但必须:
```text
1. velocity 来自 segment.velocityMmPerMin / 60。
2. axes 是当前插值位置,而不是只用 segment end。
3. status 能保留 line/progress/currentVel。
```
## 5. G94 速度规则
### 5.1 G94 units per minute
`TRAJ.LINEAR_UNITS=mm` 时:
```text
F318 => 318 mm/min
```
`TRAJ.LINEAR_UNITS=inch` 或 G20 active 时:
```text
F10 => 10 inch/min => 254 mm/min
```
计算:
```text
velocity_mm_per_min = feedRate * linearUnitScaleToMm * feedOverride
duration_seconds = linearDistanceMm / (velocity_mm_per_min / 60)
```
### 5.2 Rapid
G0 / `STRAIGHT_TRAVERSE`
```text
velocity_mm_per_min = min(INI max velocity, profile max velocity) * rapidOverride
duration_seconds = distance / velocity
```
### 5.3 Angular axes
第一阶段可沿用 `execution-timing.js` 现有策略:
```text
linearSeconds = linearDistanceMm / linearVelocity
angularSeconds = angularDistanceDeg / angularVelocity
durationSeconds = max(linearSeconds, angularSeconds)
```
这至少能避免旋转轴运动被零时长吞掉。
### 5.4 G93 inverse time
LinuxCNC impeller 程序包含:
```text
G93
...
G1 ... F159
```
G93 的 F 不是 units/min而是 inverse time。第一阶段有两种策略
```text
方案 A先检测 G93明确标注 unsupportedRUN gate 阻止真实 feed mode 运行。
方案 B实现基础 inverse-timeduration_minutes = 1 / Fduration_seconds = 60 / F。
```
推荐:
```text
第一阶段实现方案 B。
```
原因:
```text
1. test_linuxcnc_source/impeller-7bl-xyzac.ngc 使用 G93。
2. 如果不支持 G93就无法验证用户当前指定测试文件的真实进给语义。
3. 对 inverse-timeF 直接定义该运动块完成时间,适合第一阶段验证。
```
需要在 interpreter motion event 中增加:
```js
feedMode: "inverse-time" | "units-per-minute"
```
或至少在 timing 阶段通过 source line 扫描维护 G93/G94 模态。
## 6. 代码修改落点
### 6.1 `linuxcnc-interpreter-runtime.js`
新增:
```text
feedModeBySourceLine(programText)
```
识别:
```text
G93 => inverse-time
G94 => units-per-minute
```
motion event 增加:
```js
feedMode: activeFeedMode
```
### 6.2 `execution-timing.js`
修改 `buildTimingSegment(...)`
```text
if event.feedMode === "inverse-time":
durationSeconds = 60 / feedRate
velocityMmPerMin = linearDistanceMm / durationSeconds * 60
else:
保持 G94 units/min 逻辑
```
注意:
```text
1. G93 中 F 必须大于 0。
2. G93 中如果缺 F应保留上一个 modal F 或报错,按 LinuxCNC 语义确认。
3. velocity 仍要可被 INI max velocity 限制还是忠实 inverse-time需要明确。第一阶段建议记录 requested 与 capped 两个值。
```
### 6.3 `store.js`
`initializeTaskHalSession` 后或 `RUN_INTERPRETER_PROGRAM` 完成后,把当前 `programExecution.motion``programExecutionTiming.segments` 传给 task/HAL runtime
```js
await state.taskHalRuntime.loadProgramMotionPlan({
programPath: session.programPath,
motion: state.programExecution.motion,
timing: state.programExecutionTiming,
linearUnits: state.profile.traj.linearUnits,
});
```
需要保证:
```text
1. LOAD_LINUXCNC_GCODE_SOURCE 后 interpreter 已完成。
2. task/HAL openProgram 的 programPath 与 motion plan programPath 一致。
3. 如果 motion plan 不存在RUN gate 应阻止“真实进给速度 RUN”不能悄悄回退 velocity=60。
```
### 6.4 `linuxcnc-task-hal.js`
新增 SDK 方法:
```js
loadProgramMotionPlan(plan) {
const rc = callWithJson(mod, "lctask_load_program_motion_plan_json", plan);
if (rc !== 0) throw new Error(...);
}
```
### 6.5 `linuxcnc_task_hal_wasm.hh`
新增声明:
```cpp
int lctask_load_program_motion_plan_json(const char *plan_json);
```
### 6.6 `linuxcnc_task_hal_wasm.cpp`
新增:
```text
1. MotionSegment 数据结构。
2. 简单 JSON plan parser或复用现有轻量 json_number_after 风格解析数组。
3. lctask_load_program_motion_plan_json。
4. lctask_run_cycles 按 elapsed time 找 segment。
5. 删除或隔离 fixed velocity=60 的 fallback。
```
要求:
```text
fixed velocity=60 只能作为 explicit fixture fallback。
真实 RUN 路径不允许使用它。
```
## 7. 测试计划
### 7.1 Node 单元测试F 值改变 duration
新增测试:
```text
web-rtcp-5axis-sim-plan/tests/node/verify_feed_rate_execution_timing.mjs
```
用例:
```gcode
G90 G94
G1 X10 F60
G1 X20 F600
M2
```
期望:
```text
第一段 10mm @ 60mm/min => 10s。
第二段 10mm @ 600mm/min => 1s。
duration ratio ≈ 10:1。
```
### 7.2 Node 单元测试G93 inverse time
用例:
```gcode
G90 G93
G1 X10 F2
G1 X20 F10
M2
```
期望:
```text
F2 => 60/2 = 30s。
F10 => 60/10 = 6s。
```
### 7.3 task/HAL runtime 测试:速度不固定
新增或扩展:
```text
web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
```
断言:
```text
1. RUN feedback currentVelocityMmPerMin 不等于固定 3600。
2. 不同 F 段采样到不同 requested/current velocity。
3. activeLine 不再每 task cycle 固定递增一行。
4. 慢速段停留采样数 > 快速段。
```
### 7.4 浏览器证据测试
扩展:
```text
qa/web-rtcp-5axis-site-test/capture-test-linuxcnc-source-run.mjs
```
新增报告字段:
```text
feedMode
feedRate
segmentDurationSeconds
segmentProgress
requestedVelocityMmPerMin
currentVelocityMmPerMin
```
报告验收:
```text
1. impeller G93 行显示 inverse-time。
2. 采样 velocity 随当前 segment 变化。
3. 当前行停留时间与 F 值/段时长一致。
4. Word 报告中列出 F、feedMode、duration、velocity 的采样表。
```
## 8. 验收标准
修复完成必须通过:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_feed_rate_execution_timing.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linear_unit_conversion.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
node qa/web-rtcp-5axis-site-test/capture-test-linuxcnc-source-run.mjs
```
并满足:
```text
1. 不再出现所有 RUN 样本 velocity=3600 的固定速度现象。
2. C++ task/HAL RUN 主路径不再写死 velocity=60。
3. G94 F60/F600 用例体现 10:1 段时长差异。
4. G93 F2/F10 用例体现 5:1 段时长差异。
5. test_linuxcnc_source/impeller-7bl-xyzac.ngc 的 RUN 报告包含 feedMode/feedRate/duration 证据。
6. STOP/ABORT/STEP 现有测试不回退。
```
## 9. 风险与注意事项
### 9.1 不要伪造 LinuxCNC 语义
如果还没有完整 planner就必须在状态中明确
```text
semanticBoundary = feed_timed_canonical_motion_runtime
```
不要标称为真实 LinuxCNC planner。
### 9.2 不要把 estimate 当作硬件实时
第一阶段可以做到:
```text
canonical motion + feed mode + timing driven browser simulation
```
不能声称:
```text
hardware realtime LinuxCNC execution
```
### 9.3 G93 是当前 impeller 文件的关键
`test_linuxcnc_source/impeller-7bl-xyzac.ngc` 开头有:
```gcode
M428 ;TCP:xyzac
G93
S600 M3
```
因此如果不处理 G93针对该文件的“真实 F 速度”验证仍然不完整。
## 10. 建议实施顺序
```text
1. 给 interpreter motion event 增加 feedMode。
2. 给 execution-timing.js 增加 G93 duration。
3. 写 verify_feed_rate_execution_timing.mjs先证明 timing 正确。
4. 给 task/HAL SDK/WASM 增加 loadProgramMotionPlan。
5. 改 lctask_run_cycles按 elapsed time 和 segment progress 推进。
6. 改 store把 motion plan 注入 task/HAL session。
7. 扩展 verify_run_feedback_loop证明 velocity 不固定、慢段停留更久。
8. 扩展浏览器 RUN 报告,输出 feedMode/feedRate/duration/progress。
9. 重跑 build、smoke、浏览器报告。
10. 更新 working_run 和 gptlog-process。
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createLinuxCncTaskHalSdk } from "../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js";
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
import { buildProgramExecutionTiming } from "../../app/src/runtime/execution-timing.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
import {
buildTaskHalProgramMotionPlan,
wrapTaskHalSdk,
} from "../../app/src/runtime/linuxcnc-task-hal-runtime.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = resolve(__dirname, "../../..");
const sourceDir = resolve(rootDir, "web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source");
const iniPath = resolve(sourceDir, "xyzac-trt.ini");
const gcodePath = resolve(sourceDir, "impeller-7bl-xyzac.ngc");
const wasmPath = resolve(rootDir, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm");
const iniText = readFileSync(iniPath, "utf8");
const gcodeText = readFileSync(gcodePath, "utf8");
const profile = {
...getFiveAxisProfile("xyzac-trt"),
linuxCncIniConfig: parseLinuxCncIni(iniText, {
path: "working_run/test_linuxcnc_source/xyzac-trt.ini",
profileId: "xyzac-trt",
}),
};
const interpreterRuntime = await createLinuxCncInterpreterRuntime();
const execution = interpreterRuntime.runProgram(gcodeText);
const timing = buildProgramExecutionTiming({
motion: execution.motion,
profile,
feedOverride: 100,
rapidOverride: 100,
defaultFeedRate: 100,
});
const feedSegments = timing.segments.filter((segment) => segment.motionClass === "feed");
const f159 = feedSegments.find((segment) => segment.feedRate === 159);
const f636 = feedSegments.find((segment) => segment.feedRate === 636);
assert.equal(execution.summary.ready, true);
assert.equal(execution.motion.length > 1000, true);
assert.equal(execution.motion.some((event) => event.feedMode === "inverse-time"), true);
assert.equal(feedSegments.length > 1000, true);
assert.equal(f159?.feedMode, "inverse-time");
assert.equal(f636?.feedMode, "inverse-time");
assertNear(f159.durationSeconds, 60 / 159, "F159 inverse-time duration");
assertNear(f636.durationSeconds, 60 / 636, "F636 inverse-time duration");
assert.equal(f636.durationSeconds < f159.durationSeconds, true);
assert.equal(f159.velocityMmPerMin > 0, true);
assert.equal(f636.velocityMmPerMin > 0, true);
const taskHal = wrapTaskHalSdk(await createLinuxCncTaskHalSdk({
wasmBinary: readFileSync(wasmPath),
print() {},
printErr() {},
}));
const programPath = "/work/sim/xyzac-trt/impeller-7bl-xyzac.ngc";
taskHal.initSession({
profileId: "xyzac-trt",
iniPath: "/work/sim/xyzac-trt/xyzac-trt.ini",
iniText,
programPath,
});
taskHal.stageFiles([{ path: programPath, text: gcodeText }]);
taskHal.openProgram(programPath);
taskHal.loadProgramMotionPlan(buildTaskHalProgramMotionPlan({
programPath,
motion: execution.motion,
timing,
linearUnits: timing.linearUnits,
}));
taskHal.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" });
taskHal.sendCommand({ type: "EMC_TASK_SET_MODE", mode: "AUTO" });
taskHal.sendCommand({ type: "EMC_TASK_PLAN_RUN", line: 0 });
const samples = [];
for (let index = 0; index < 200; index += 1) {
taskHal.runCycles({ taskPeriodNs: 100000000, servoPeriodNs: 1000000, taskCycles: 1 });
const status = taskHal.readStatus();
samples.push({
index,
activeLine: status.ui.activeLine,
axisPose: status.ui.axisPose,
currentVelocity: status.ui.currentVelocity,
taskCycle: status.ui.taskCycle,
});
}
const movedSamples = samples.filter((sample) => sample.currentVelocity > 0);
const distinctVelocities = [...new Set(movedSamples.map((sample) => Math.round(sample.currentVelocity * 1000) / 1000))];
assert.equal(movedSamples.length > 20, true);
assert.equal(distinctVelocities.length > 3, true);
assert.equal(distinctVelocities.includes(3600), false);
assert.equal(samples.at(0).activeLine >= 7, true);
assert.equal(samples.at(-1).activeLine > samples.at(0).activeLine, true);
assert.equal(samples.some((sample) => sample.activeLine === f159.line), true);
assert.equal(samples.some((sample) => sample.activeLine === f636.line), true);
assert.equal(samples.some((sample) => Math.abs(sample.currentVelocity - f159.velocityMmPerMin) < 0.001), true);
assert.equal(samples.some((sample) => Math.abs(sample.currentVelocity - f636.velocityMmPerMin) < 0.001), true);
console.log(`impeller_motion_count=${execution.motion.length}`);
console.log(`impeller_feed_segments=${feedSegments.length}`);
console.log(`impeller_f159_duration_seconds=${f159.durationSeconds}`);
console.log(`impeller_f636_duration_seconds=${f636.durationSeconds}`);
console.log(`impeller_task_hal_distinct_velocities=${distinctVelocities.slice(0, 10).join(",")}`);
console.log("impeller_feed_task_hal_run=ok");
function assertNear(actual, expected, label, tolerance = 1e-9) {
assert.equal(Math.abs(Number(actual) - Number(expected)) <= tolerance, true, `${label}: ${actual} != ${expected}`);
}

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import {
linearUnitsToMetersFactor,
linearUnitsToMillimetersFactor,
linearValueToMeters,
linearValueToMillimeters,
normalizeLinearUnits,
resolveStateLinearUnits,
} from "../../app/src/runtime/linear-units.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { buildProgramExecutionTiming } from "../../app/src/runtime/execution-timing.js";
import { axesToSceneMeters } from "../../app/src/visualization/five-axis-scene.js";
function assertNear(actual, expected, label) {
assert.equal(Math.abs(actual - expected) < 1e-9, true, `${label}: expected ${expected}, got ${actual}`);
}
assert.equal(normalizeLinearUnits("MM"), "mm");
assert.equal(normalizeLinearUnits("inch"), "inch");
assert.equal(normalizeLinearUnits("meters"), "m");
assertNear(linearUnitsToMetersFactor("mm"), 0.001, "mm to m factor");
assertNear(linearUnitsToMetersFactor("inch"), 0.0254, "inch to m factor");
assertNear(linearUnitsToMetersFactor("m"), 1, "m to m factor");
assertNear(linearUnitsToMillimetersFactor("inch"), 25.4, "inch to mm factor");
assertNear(linearValueToMeters(25.4, "mm"), 0.0254, "25.4 mm to meters");
assertNear(linearValueToMeters(1, "inch"), 0.0254, "1 inch to meters");
assertNear(linearValueToMillimeters(0.5, "m"), 500, "0.5 m to millimeters");
const mmState = { profile: { traj: { linearUnits: "mm" } } };
const inchState = { profile: { traj: { linearUnits: "inch" } } };
const meterState = { profile: { traj: { linearUnits: "m" } } };
const iniState = {
linuxCncIniConfig: { traj: { linearUnits: "inch" } },
profile: { traj: { linearUnits: "mm" } },
};
assert.equal(resolveStateLinearUnits(iniState), "inch");
assert.deepEqual(axesToSceneMeters({ x: 25.4, y: -10, z: 2000 }, mmState), {
x: 0.0254,
y: -0.01,
z: 2,
});
assert.deepEqual(axesToSceneMeters({ x: 1, y: -0.5, z: 2 }, inchState), {
x: 0.0254,
y: -0.0127,
z: 0.0508,
});
assert.deepEqual(axesToSceneMeters({ x: 0.1, y: -0.2, z: 0.3 }, meterState), {
x: 0.1,
y: -0.2,
z: 0.3,
});
const inchTiming = buildProgramExecutionTiming({
profile: {
traj: {
linearUnits: "inch",
maxLinearVelocity: 2,
defaultLinearVelocity: 1,
},
},
defaultFeedRate: 1,
motion: [
{ type: "STRAIGHT_FEED", line: 1, axes: { x: 0, y: 0, z: 0 } },
{ type: "STRAIGHT_FEED", line: 2, axes: { x: 1, y: 0, z: 0 } },
],
});
assert.equal(inchTiming.linearUnits, "inch");
assertNear(inchTiming.segments[1].linearDistanceMm, 25.4, "inch segment distance in mm");
assertNear(inchTiming.segments[1].velocityMmPerMin, 25.4, "inch feed velocity in mm/min");
const mixedUnitsTiming = buildProgramExecutionTiming({
profile: {
traj: {
linearUnits: "mm",
maxLinearVelocity: 100,
defaultLinearVelocity: 10,
},
},
motion: [
{ type: "STRAIGHT_FEED", line: 1, axes: { x: 0, y: 0, z: 0 }, linearUnits: "inch", feedRate: 10 },
{ type: "STRAIGHT_FEED", line: 2, axes: { x: 1, y: 0, z: 0 }, linearUnits: "inch", feedRate: 10 },
{ type: "STRAIGHT_FEED", line: 3, axes: { x: 25.4, y: 0, z: 0 }, linearUnits: "mm", feedRate: 100 },
],
});
assertNear(mixedUnitsTiming.segments[1].linearDistanceMm, 25.4, "mixed units inch segment distance");
assertNear(mixedUnitsTiming.segments[2].linearDistanceMm, 0, "mixed units unchanged position distance");
const inverseTimeTiming = buildProgramExecutionTiming({
profile: {
traj: {
linearUnits: "mm",
maxLinearVelocity: 100,
defaultLinearVelocity: 10,
},
},
motion: [
{ type: "STRAIGHT_FEED", line: 1, axes: { x: 0, y: 0, z: 0 }, linearUnits: "mm", feedMode: "inverse-time", feedRate: 120 },
{ type: "STRAIGHT_FEED", line: 2, axes: { x: 10, y: 0, z: 0 }, linearUnits: "mm", feedMode: "inverse-time", feedRate: 120 },
],
});
assertNear(inverseTimeTiming.segments[1].durationSeconds, 0.5, "G93 inverse-time F120 duration");
assertNear(inverseTimeTiming.segments[1].velocityMmPerMin, 1200, "G93 inverse-time velocity");
const interpreterRuntime = await createLinuxCncInterpreterRuntime();
const inchExecution = interpreterRuntime.runProgram("G20 G90\nG1 X1 F10\nM2");
const metricExecution = interpreterRuntime.runProgram("G21 G90\nG1 X25.4 F100\nM2");
const inverseExecution = interpreterRuntime.runProgram("G21 G90 G93\nG1 X1 F120\nM2");
assert.equal(inchExecution.motion[0].linearUnits, "inch");
assert.equal(metricExecution.motion[0].linearUnits, "mm");
assert.equal(inverseExecution.motion[0].feedMode, "inverse-time");
assertNear(
axesToSceneMeters(inchExecution.motion[0].axes, mmState, inchExecution.motion[0].linearUnits).x,
0.0254,
"G20 interpreter motion uses inch scene conversion",
);
assertNear(
axesToSceneMeters(metricExecution.motion[0].axes, mmState, metricExecution.motion[0].linearUnits).x,
0.0254,
"G21 interpreter motion uses millimeter scene conversion",
);
console.log("linear_unit_conversion_smoke=ok");

View File

@@ -28,6 +28,16 @@ assert.equal(xyzacIni.kinematics.name, "xyzac-trt-kins");
assert.equal(xyzacIni.kinematicsParameters.sparm, "identityfirst"); assert.equal(xyzacIni.kinematicsParameters.sparm, "identityfirst");
assert.equal(xyzacIni.kinematicsParameters.joints, 5); assert.equal(xyzacIni.kinematicsParameters.joints, 5);
assert.equal(xyzacIni.traj.coordinates, "XYZAC"); assert.equal(xyzacIni.traj.coordinates, "XYZAC");
assert.equal(xyzacIni.rs274ngc.halPinVars, true);
assert.equal(xyzacIni.rs274ngc.parameterFile, "xyzac.var");
assert.equal(xyzacIni.hal.halFiles.includes("LIB:basic_sim.tcl"), true);
assert.equal(xyzacIni.hal.postguiHalFiles.includes("switchkins_postgui.hal"), true);
assert.equal(xyzacIni.hal.halcmd.some((line) => line.includes("motion.analog-out-03") && line.includes("motion.switchkins-type")), true);
assert.equal(xyzacIni.emcmot.module, "motmod");
assert.equal(xyzacIni.emcmot.servoPeriodNs, 1000000);
assert.equal(xyzacIni.task.module, "milltask");
assert.equal(xyzacIni.task.cycleTimeSeconds, 0.01);
assert.equal(xyzacIni.emcio.toolTable, "xyzac-trt.tbl");
assert.equal(xyzacIni.axisLimits.A.max, 50); assert.equal(xyzacIni.axisLimits.A.max, 50);
assert.equal(xyzacIni.jointConfig[3].axis, "A"); assert.equal(xyzacIni.jointConfig[3].axis, "A");
assert.equal(xyzacIni.jointConfig[4].max, 36000); assert.equal(xyzacIni.jointConfig[4].max, 36000);
@@ -50,8 +60,25 @@ assert.equal(xyzbcIni.validation.ready, true);
assert.equal(xyzbcIni.kinematics.name, "xyzbc-trt-kins"); assert.equal(xyzbcIni.kinematics.name, "xyzbc-trt-kins");
assert.equal(xyzbcIni.kinematicsModuleId, "xyzbc-trt"); assert.equal(xyzbcIni.kinematicsModuleId, "xyzbc-trt");
assert.equal(xyzbcIni.traj.coordinates, "XYZBC"); assert.equal(xyzbcIni.traj.coordinates, "XYZBC");
assert.equal(xyzbcIni.rs274ngc.parameterFile, "xyzbc.var");
assert.equal(xyzbcIni.emcio.toolTable, "xyzbc-trt.tbl");
assert.equal(xyzbcIni.axisLimits.B.max, 36000); assert.equal(xyzbcIni.axisLimits.B.max, 36000);
assert.equal(xyzbcIni.jointConfig[3].axis, "B"); assert.equal(xyzbcIni.jointConfig[3].axis, "B");
assert.equal(xyzbcIni.kinematicsParameters.switchkinsTypes[1].webKinsType, "tcp-xyzbc"); assert.equal(xyzbcIni.kinematicsParameters.switchkinsTypes[1].webKinsType, "tcp-xyzbc");
const missingHalPinVarsIni = parseLinuxCncIni(xyzacIniText.replace(/^\s*HAL_PIN_VARS\s*=\s*1$/m, ""), {
path: xyzacTrtProfile.iniPath,
profileId: xyzacTrtProfile.id,
});
assert.equal(missingHalPinVarsIni.validation.ready, false);
assert.equal(missingHalPinVarsIni.validation.missing.includes("RS274NGC.HAL_PIN_VARS=1"), true);
const missingTaskIni = parseLinuxCncIni(xyzacIniText.replace(/\n\[TASK\][\s\S]*?(?=\n\[EMCIO\])/m, "\n"), {
path: xyzacTrtProfile.iniPath,
profileId: xyzacTrtProfile.id,
});
assert.equal(missingTaskIni.validation.ready, false);
assert.equal(missingTaskIni.validation.missing.includes("[TASK]"), true);
assert.equal(missingTaskIni.validation.missing.includes("TASK.CYCLE_TIME"), true);
console.log("linuxcnc_ini_runtime_smoke=ok"); console.log("linuxcnc_ini_runtime_smoke=ok");

View File

@@ -71,8 +71,10 @@ assert.equal(state.fullExecutionBoundary.nativeTaskReady, true);
assert.equal(state.fullExecutionBoundary.nativeHalSyncReady, true); assert.equal(state.fullExecutionBoundary.nativeHalSyncReady, true);
await store.initializeTaskHalSession({ openProgram: true }); await store.initializeTaskHalSession({ openProgram: true });
store.dispatch({ type: "TOGGLE_POWER" }); if (!store.getState().machine.powerOn) {
await waitForTaskHal(store); store.dispatch({ type: "TOGGLE_POWER" });
await waitForTaskHal(store);
}
store.dispatch({ type: "HOME" }); store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "mdi" }); store.dispatch({ type: "SET_MODE", mode: "mdi" });
await waitForTaskHal(store); await waitForTaskHal(store);

View File

@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createLinuxCncTaskHalSdk } from "../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js";
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js";
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
import { wrapTaskHalSdk } from "../../app/src/runtime/linuxcnc-task-hal-runtime.js";
import { createSimulationStore } from "../../app/src/state/store.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = resolve(__dirname, "../../..");
const wasmPath = resolve(rootDir, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm");
await verifyRunFeedbackLoop({
profileId: "xyzac-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
stopAction: "STOP",
});
await verifyRunFeedbackLoop({
profileId: "xyzac-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
stopAction: "ABORT",
});
await verifyRunFeedbackLoop({
profileId: "xyzbc-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
stopAction: "STOP",
});
console.log("run_feedback_status_loop_smoke=ok");
async function verifyRunFeedbackLoop({ profileId, sourceRel, stopAction }) {
const sdk = await createLinuxCncTaskHalSdk({
wasmBinary: readFileSync(wasmPath),
print() {},
printErr(message) {
console.error(message);
},
});
const profile = getFiveAxisProfile(profileId);
const iniText = readFileSync(resolve(rootDir, "wasm-port/vendor/linuxcnc", profile.iniPath), "utf8");
const iniConfig = parseLinuxCncIni(iniText, {
path: profile.iniPath,
profileId: profile.id,
});
const kinematicsModuleId = iniConfig.kinematicsModuleId;
const store = createSimulationStore();
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
store.dispatch({
type: "ATTACH_KINEMATICS_RUNTIME",
runtime: await createLinuxCncKinematicsRuntime({ moduleId: kinematicsModuleId }),
});
store.dispatch({
type: "ATTACH_INTERPRETER_RUNTIME",
runtime: await createLinuxCncInterpreterRuntime(),
});
store.dispatch({ type: "ATTACH_TASK_HAL_RUNTIME", runtime: wrapTaskHalSdk(sdk) });
await store.stageMachineFiles();
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
await waitForState(store, (state) => (
state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)) &&
state.rtcpFrame?.sourceMode === "source-derived-kinematics-wasm"
));
store.dispatch({ type: "TOGGLE_POWER" });
await waitForTaskHalCommand(store);
store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "auto" });
await waitForTaskHalCommand(store);
store.dispatch({ type: "RUN" });
await waitForTaskHalCommand(store);
await waitForState(store, (state) => distinctActiveLines(state.programRuntimeFeedbackHistory).length >= 3);
let state = store.getState();
const history = state.programRuntimeFeedbackHistory;
if (state.taskHalStatusLoop.profileId) {
assert.equal(state.taskHalStatusLoop.profileId, profileId);
}
if (state.taskHalStatusLoop.kinematicsModuleId) {
assert.equal(state.taskHalStatusLoop.kinematicsModuleId, kinematicsModuleId);
}
assert.equal(state.taskHalStatusLoop.taskPeriodNs, 10000000);
assert.equal(state.taskHalStatusLoop.servoPeriodNs, 1000000);
assert.equal(history.length >= 3, true);
assert.equal(history.every((entry) => entry.sourceMode === "linuxcnc-task-motion-hal-wasm"), true);
assert.equal(history.every((entry) => entry.semanticBoundary === "linuxcnc_task_motion_hal_wasm_simulation_runtime"), true);
assert.equal(history.some((entry) => entry.sourceMode === "fixture-line-playback"), false);
assert.equal(history.every((entry) => entry.activeLineSource === "motion-status"), true);
assert.equal(history.every((entry) => entry.activeLineHalSynced === true), true);
assert.equal(history.every((entry) => entry.line === entry.motionProgramLine), true);
assert.equal(history.every((entry) => entry.line === entry.halProgramLine), true);
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin > 0), true);
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin !== 3600), true);
assert.equal(isMonotonic(history.map((entry) => entry.taskCycle).reverse()), true);
assert.equal(isMonotonic(history.map((entry) => entry.cycle).reverse()), true);
const activeLines = distinctActiveLines(history);
assert.equal(activeLines.length >= 3, true, `${sourceRel} activeLines=${activeLines.join(",")}`);
assert.equal(isMonotonic(activeLines), true, `${sourceRel} activeLines=${activeLines.join(",")}`);
assert.equal(
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.join(",") !== "1,10" : true,
true,
`${sourceRel} activeLines=${activeLines.join(",")}`,
);
assert.equal(
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.some((line) => line > activeLines.indexOf(line) + 1) : true,
true,
`${sourceRel} activeLines=${activeLines.join(",")}`,
);
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
assert.equal(state.taskHalStatus.summary.taskRuntimeReady, true);
assert.equal(state.taskHalStatus.summary.halSyncReady, true);
const cycleAfterRun = state.taskHalStatus.ui.taskCycle;
store.dispatch({ type: "STEP" });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.programRuntimeFeedback.sourceMode, "linuxcnc-task-motion-hal-wasm");
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
store.dispatch({ type: stopAction });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.taskHalStatusLoop.active, false);
assert.equal(["stopped", "complete", "idle"].includes(state.runState), true);
assert.equal(
stopAction !== "ABORT" || ["aborted", "stopped", "complete", "idle"].includes(state.taskHalStatusLoop.stopReason),
true,
);
assert.equal(state.taskHalStatus.ui.taskCycle >= cycleAfterRun, true);
}
async function waitForTaskHalCommand(store) {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (!store.getState().taskHalExecutionPending) {
await new Promise((resolve) => setTimeout(resolve, 0));
if (!store.getState().taskHalExecutionPending) return store.getState();
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("task/HAL command did not settle");
}
async function waitForState(store, predicate) {
for (let attempt = 0; attempt < 160; attempt += 1) {
const state = store.getState();
if (predicate(state)) return state;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("store state condition did not settle");
}
function isMonotonic(values) {
for (let index = 1; index < values.length; index += 1) {
if (Number(values[index]) < Number(values[index - 1])) return false;
}
return true;
}
function distinctActiveLines(history = []) {
return [...new Set(history.map((entry) => Number(entry.line)).reverse())]
.filter((line) => Number.isFinite(line));
}

View File

@@ -13,6 +13,7 @@ import {
createSimulationStore, createSimulationStore,
validateRunPreconditions, validateRunPreconditions,
} from "../../app/src/state/store.js"; } from "../../app/src/state/store.js";
import { gateLinuxCncTaskAction } from "../../app/src/state/linuxcnc-task-policy.js";
const cases = [ const cases = [
{ {
@@ -124,6 +125,35 @@ check = validateRunPreconditions(store.getState(), {
assert.equal(check.ok, false); assert.equal(check.ok, false);
assert.equal(check.operatorMessage, "run blocked: task/HAL runtime not ready"); assert.equal(check.operatorMessage, "run blocked: task/HAL runtime not ready");
let gate = gateLinuxCncTaskAction(store.getState(), { type: "RUN" });
assert.equal(gate.allowed, false);
assert.equal(gate.operatorMessage, "run blocked: machine must be on");
store.dispatch({ type: "TOGGLE_POWER" });
gate = gateLinuxCncTaskAction(store.getState(), { type: "RUN" });
assert.equal(gate.allowed, false);
assert.equal(gate.operatorMessage, "run blocked: home machine first");
store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "manual" });
gate = gateLinuxCncTaskAction(store.getState(), { type: "RUN" });
assert.equal(gate.allowed, false);
assert.equal(gate.operatorMessage, "run blocked: switch to auto mode first");
const unopenedStore = createSimulationStore();
unopenedStore.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
unopenedStore.dispatch({
type: "ATTACH_KINEMATICS_RUNTIME",
runtime: await createLinuxCncKinematicsRuntime({ moduleId: "xyzac-trt" }),
});
await unopenedStore.stageMachineFiles();
check = validateRunPreconditions(unopenedStore.getState(), {
requireTaskHalRuntime: false,
requireTaskHalSession: false,
});
assert.equal(check.ok, false);
assert.equal(check.operatorMessage, "run blocked: no machine-file G-code opened for task/HAL session");
console.log("run_preconditions_ini_profile_smoke=ok"); console.log("run_preconditions_ini_profile_smoke=ok");
console.log("run_preconditions_kinematics_smoke=ok"); console.log("run_preconditions_kinematics_smoke=ok");
console.log("run_preconditions_machine_file_smoke=ok"); console.log("run_preconditions_machine_file_smoke=ok");

View File

@@ -93,7 +93,385 @@ M430 -> switchkins type 2 -> userk
HAL link: motion.analog-out-03 => motion.switchkins-type HAL link: motion.analog-out-03 => motion.switchkins-type
``` ```
## 3. RUN 前置状态检查 ## 3. INI definition contract
`RUN` 只能基于一个完整、可验证的 LinuxCNC INI 上下文启动。INI 不是展示信息,而是机床契约来源。`parseLinuxCncIni()``applyIniConfigToProfile()` 必须把下面字段转成结构化状态RUN gate 只读取结构化状态,不在 RUN action 中重新猜测字符串。
### 3.1 必须存在的 INI sections
当前五轴 `xyzac-trt` / `xyzbc-trt` profile 至少要求这些 section
| section | 是否必填 | RUN gate 用途 |
| --- | --- | --- |
| `[EMC]` | 必填 | 识别 machine name确认是 switchkins 五轴 profile |
| `[DISPLAY]` | 必填 | 提供默认打开程序、程序目录、UI 坐标提示;不能作为运动学真值 |
| `[RS274NGC]` | 必填 | 提供 remap、subroutine path、HAL pin vars、parameter file |
| `[KINS]` | 必填 | 提供 kinematics module 和 joint 数,是 kinematics/runtime 匹配依据 |
| `[HAL]` | 必填 | 提供 HAL 文件、HALUI、POSTGUI_HALFILE、switchkins HALCMD |
| `[HALUI]` | 必填 | 提供 M428/M429/M430 operator command 顺序和语义提示 |
| `[TRAJ]` | 必填 | 提供 COORDINATES、单位、速度/加速度上限 |
| `[EMCMOT]` | 必填 | 提供 motmod、SERVO_PERIOD、COMM_TIMEOUT |
| `[TASK]` | 必填 | 提供 task module 和 CYCLE_TIME |
| `[EMCIO]` | 必填 | 提供 tool table |
| `[AXIS_*]` | 必填 | 提供各坐标轴 limit/velocity/acceleration |
| `[JOINT_*]` | 必填 | 提供 joint 类型、home、limit、home sequence |
不满足这些 section 时INI readiness 必须是 not readyRUN 必须阻断。
### 3.2 必填字段与 gate 用途
`[EMC]`
```text
VERSION
MACHINE
```
要求:
```text
xyzac-trt: MACHINE = sim-xyzac-trt-kins (switchkins)
xyzbc-trt: MACHINE = sim-xyzbc-trt-kins (switchkins)
```
用途:
```text
1. 推导 profileId。
2. 确认当前 profile 是 switchkins 五轴仿真机床。
3. 作为 task/HAL session metadata写入 feedback 证据。
```
`[DISPLAY]`
```text
GEOMETRY
OPEN_FILE
JOG_AXES
DISPLAY
PROGRAM_PREFIX
POSITION_OFFSET
POSITION_FEEDBACK
MAX_LINEAR_VELOCITY
MAX_ANGULAR_VELOCITY
```
用途:
```text
1. OPEN_FILE 只能作为默认程序候选RUN 实际程序必须来自 staged selected G-code。
2. PROGRAM_PREFIX 用于解析 machine-file staging 的相对路径。
3. GEOMETRY/JOG_AXES 用于 UI 呈现,不得覆盖 [TRAJ] COORDINATES。
```
`[RS274NGC]`
```text
SUBROUTINE_PATH
HAL_PIN_VARS = 1
REMAP = M428 modalgroup=10 ngc=428remap
REMAP = M429 modalgroup=10 ngc=429remap
REMAP = M430 modalgroup=10 ngc=430remap
PARAMETER_FILE
```
用途:
```text
1. HAL_PIN_VARS=1 是 remap 子程序读取 motion.switchkins-type 的前提。
2. M428/M429/M430 remap 文件必须被 stage 到同一 machine context。
3. PARAMETER_FILE 必须随 profile 区分,不能 xyzac/xyzbc 混用。
```
`[KINS]`
```text
KINEMATICS
JOINTS
```
要求:
```text
xyzac-trt: KINEMATICS = xyzac-trt-kins sparm=identityfirst
xyzbc-trt: KINEMATICS = xyzbc-trt-kins sparm=identityfirst
JOINTS = 5
```
用途:
```text
1. 从 KINEMATICS 推导 kinematicsModuleId。
2. 校验 kinematics WASM moduleId。
3. 校验 joint count 与 [JOINT_0]...[JOINT_4] 完整性。
4. sparm=identityfirst 决定 switchkins type 0 是 identity不能按默认 kins 猜测。
```
`[HAL]`
```text
HALUI = halui
HALFILE = LIB:basic_sim.tcl
POSTGUI_HALFILE = switchkins_postgui.hal
HALCMD = net :kinstype-select <= motion.analog-out-03 => motion.switchkins-type
```
用途:
```text
1. HALUI 存在时UI command 与 LinuxCNC halui 行为对齐。
2. HALFILE/POSTGUI_HALFILE 必须 stagetask/HAL runtime 才能构造同一机床上下文。
3. switchkins HALCMD 是 M428/M429/M430 影响 motion.switchkins-type 的证据。
```
`[HALUI]`
```text
MDI_COMMAND = M429
MDI_COMMAND = M428
MDI_COMMAND = M430
```
用途:
```text
1. 记录 operator 命令入口。
2. 与 [RS274NGC] REMAP 和 [HAL] switchkins HALCMD 交叉校验。
3. 不直接作为 RUN 自动插入命令RUN 程序内的 M428/M429/M430 由 interpreter/remap 处理。
```
`[TRAJ]`
```text
COORDINATES
LINEAR_UNITS
ANGULAR_UNITS
DEFAULT_LINEAR_VELOCITY
MAX_LINEAR_VELOCITY
MAX_LINEAR_ACCELERATION
DEFAULT_LINEAR_ACCELERATION
```
要求:
```text
xyzac-trt: COORDINATES = XYZAC
xyzbc-trt: COORDINATES = XYZBC
LINEAR_UNITS = mm
ANGULAR_UNITS = deg
```
用途:
```text
1. COORDINATES 是 UI 轴、G-code 轴、runtime axisPose 的主契约。
2. 单位决定 DRO 和 velocity 显示,不能由前端默认值覆盖。
3. velocity/acceleration 是 planner/runtime limit 的来源。
```
`[EMCMOT]``[TASK]`
```text
[EMCMOT]
EMCMOT = motmod
SERVO_PERIOD = 1000000
COMM_TIMEOUT = 1
[TASK]
TASK = milltask
CYCLE_TIME = 0.010
```
用途:
```text
1. SERVO_PERIOD 用于 taskHalStatusLoop 的 servo tick 语义。
2. CYCLE_TIME 用于 task cycle 语义和测试断言。
3. runtime readiness 必须能报告 task/motion/HAL 三者已按这些参数初始化。
```
`[EMCIO]`
```text
TOOL_TABLE
```
用途:
```text
1. tool table 必须 stage 到 machine files。
2. G43/tool offset 相关状态不能用空表静默替代。
```
`[AXIS_*]`
```text
MIN_LIMIT
MAX_LIMIT
MAX_VELOCITY
MAX_ACCELERATION
```
要求:
```text
xyzac-trt: [AXIS_X] [AXIS_Y] [AXIS_Z] [AXIS_A] [AXIS_C]
xyzbc-trt: [AXIS_X] [AXIS_Y] [AXIS_Z] [AXIS_B] [AXIS_C]
```
用途:
```text
1. 校验 profile coordinates 对应的 axis section 完整。
2. 约束 UI limit、DRO 范围、preview bounds。
3. 防止 xyzac/xyzbc 轴表混用。
```
`[JOINT_*]`
```text
TYPE
HOME
MIN_LIMIT
MAX_LIMIT
MAX_VELOCITY
MAX_ACCELERATION
HOME_SEARCH_VEL
HOME_SEQUENCE
```
要求:
```text
JOINT_0..JOINT_4 必须完整。
JOINT_0..JOINT_2 TYPE = LINEAR。
xyzac-trt: JOINT_3/JOINT_4 分别对应 A/C rotary。
xyzbc-trt: JOINT_3/JOINT_4 分别对应 B/C rotary。
```
用途:
```text
1. 校验 [KINS] JOINTS=5 与 joint section 数量一致。
2. home/allHomed gate 的配置来源。
3. task/HAL runtime 初始化 joint status 的来源。
```
### 3.3 INI readiness 输出
`parseLinuxCncIni()` 应输出或间接形成这些结构化字段:
```js
{
profileId: "xyzac-trt",
iniPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
machineName: "sim-xyzac-trt-kins (switchkins)",
traj: {
coordinates: "XYZAC",
linearUnits: "mm",
angularUnits: "deg"
},
kinematics: {
name: "xyzac-trt-kins",
moduleId: "xyzac-trt",
switchkins: true,
identityFirst: true,
joints: 5
},
rs274ngc: {
halPinVars: true,
remaps: ["M428", "M429", "M430"],
subroutinePath: "./remap_subs",
parameterFile: "xyzac.var"
},
hal: {
halui: "halui",
halFiles: ["LIB:basic_sim.tcl"],
postguiHalFiles: ["switchkins_postgui.hal"],
switchkinsSignal: "motion.analog-out-03 => motion.switchkins-type"
},
emcmot: {
module: "motmod",
servoPeriodNs: 1000000
},
task: {
module: "milltask",
cycleTimeSeconds: 0.010
},
emcio: {
toolTable: "xyzac-trt.tbl"
},
axes: {
X: { minLimit: -200, maxLimit: 200 },
Y: { minLimit: -100, maxLimit: 100 },
Z: { minLimit: -120, maxLimit: 120 },
A: { minLimit: -100, maxLimit: 50 },
C: { minLimit: -36000, maxLimit: 36000 }
},
joints: [
{ index: 0, type: "LINEAR", axis: "X" },
{ index: 1, type: "LINEAR", axis: "Y" },
{ index: 2, type: "LINEAR", axis: "Z" },
{ index: 3, type: "ANGULAR", axis: "A" },
{ index: 4, type: "ANGULAR", axis: "C" }
],
validation: {
ready: true,
errors: []
}
}
```
`xyzbc-trt` 的输出必须只在 profileId、machineName、coordinates、kinematics module、parameter/tool table、rotary axis B/C 等字段上变化,不能沿用 xyzac 的 A/C axis contract。
### 3.4 INI contract 失败时的阻断
这些情况必须让 `state.iniConfigReadiness.ready=false`,并阻断 RUN
```text
缺少 [EMC] MACHINE。
缺少 [TRAJ] COORDINATES。
COORDINATES 不是 XYZAC 或 XYZBC。
[KINS] KINEMATICS 不是 xyzac-trt-kins 或 xyzbc-trt-kins。
[KINS] JOINTS 不是 5。
缺少 M428/M429/M430 remap。
HAL_PIN_VARS 不是 1。
缺少 motion.analog-out-03 => motion.switchkins-type HALCMD。
缺少 [EMCMOT] SERVO_PERIOD。
缺少 [TASK] CYCLE_TIME。
缺少 TOOL_TABLE。
axis section 与 COORDINATES 不一致。
joint section 数量与 [KINS] JOINTS 不一致。
xyzac INI 指向 xyzbc tool table/parameter file或反向混用。
```
阻断消息建议:
```text
run blocked: LinuxCNC INI contract invalid
run blocked: INI missing required section [KINS]
run blocked: INI coordinates do not match selected profile
run blocked: INI switchkins remap incomplete
run blocked: INI HAL switchkins signal missing
run blocked: INI joint/axis contract mismatch
```
### 3.5 与 RUN gate 的关系
INI contract 通过只是 RUN 的第一层 gate。RUN 仍必须继续检查:
```text
1. kinematics WASM 已按 INI [KINS] moduleId 加载。
2. machine files 已按同一个 INI stage。
3. task/HAL runtime 已按同一个 INI/session 初始化。
4. selected G-code 已通过 task plan open 打开。
5. task status 的 opened file 与 selected staged G-code 一致。
6. machine ON、AUTO、homed/no_force_homing 满足。
```
所以不能把 `parseLinuxCncIni().validation.ready=true` 等同于 `RUN` ready。
## 4. RUN 前置状态检查
`RUN` action 进入真正执行前,应要求这些状态全部明确: `RUN` action 进入真正执行前,应要求这些状态全部明确:
@@ -112,6 +490,11 @@ state.taskHalRuntimeReadiness.taskRuntimeReady === true
state.taskHalRuntimeReadiness.motionRuntimeReady === true state.taskHalRuntimeReadiness.motionRuntimeReady === true
state.taskHalRuntimeReadiness.halRuntimeReady === true state.taskHalRuntimeReadiness.halRuntimeReady === true
state.taskHalSession.programPath 指向当前选中的 G-code state.taskHalSession.programPath 指向当前选中的 G-code
state.taskHalSession.openProgram === true
state.taskHalStatus.task.file 或 openedProgram.path 指向同一个 staged G-code
state.taskHalStatus.task.state 是 ON
state.taskHalStatus.task.mode 是 AUTO或 RUN 前可切换到 AUTO
state.taskHalStatus.motion.allHomed === true或当前仿真 profile 明确 no_force_homing
``` ```
如果任一项不满足,`RUN` 应返回明确 operatorMessage不应进入 fixture line playback 后伪装成真实运行。 如果任一项不满足,`RUN` 应返回明确 operatorMessage不应进入 fixture line playback 后伪装成真实运行。
@@ -124,11 +507,98 @@ run blocked: machine profile and INI coordinates mismatch
run blocked: LinuxCNC kinematics runtime not ready run blocked: LinuxCNC kinematics runtime not ready
run blocked: task/HAL runtime not ready run blocked: task/HAL runtime not ready
run blocked: no machine-file G-code opened for task/HAL session run blocked: no machine-file G-code opened for task/HAL session
run blocked: machine is not on
run blocked: machine is not homed
run blocked: task mode is not AUTO
``` ```
## 4. 详细测试计划 ## 5. LinuxCNC 源码对照结论
### 4.1 INI/profile 解析测试 `RUN` 命令修改方向总体合适,但必须严格贴近 LinuxCNC 的 task 语义:
```text
RUN 不是前端 sample playback。
RUN 不是直接解析 G-code 后自增 activeLine。
RUN 是在已打开程序、机床可运行、AUTO 模式下发送 EMC_TASK_PLAN_RUN
随后由 task 主循环持续 read/execute interpreter并从 motion/HAL 状态读取反馈。
```
源码证据:
```text
/home/meswork/cnc_wams/linuxcnc/src/emc/usr_intf/halui.cc:1096
sendProgramRun(int line)
- updateStatus()
- 如果 emcStatus->task.file 为空,直接返回 -1
- 保存 programStartLine
- 设置 EMC_TASK_PLAN_RUN.line
- sendAuto()
- emcCommandSend(EMC_TASK_PLAN_RUN)
/home/meswork/cnc_wams/linuxcnc/src/emc/usr_intf/shcom.cc:750
sendProgramOpen(program)
- 发送 EMC_TASK_PLAN_OPEN
- 本地进程发送文件名
- remote process 通过 remote_buffer 分块发送文件内容
/home/meswork/cnc_wams/linuxcnc/src/emc/usr_intf/shcom.cc:814
sendProgramRun(int line)
- status 为 AUTO update 时先 updateStatus()
- task.file 为空时尝试重新打开 lastProgramFile
- 再发送 EMC_TASK_PLAN_RUN
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctaskmain.cc:2164
EMC_TASK_PLAN_OPEN
- 接收 remote 文件或打开本地文件
- 调用 emcTaskPlanOpen(open_msg->file)
- 成功后写入 emcStatus->task.file
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctaskmain.cc:2318
EMC_TASK_PLAN_RUN
- 未 homed 且 no_force_homing=false 时拒绝运行
- 清 single stepping
- 必要时重新 emcTaskPlanOpen(emcStatus->task.file)
- 保存 programStartLine
- 设置 interpState=READING
- 清 task_paused
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctaskmain.cc:2337
EMC_TASK_PLAN_PAUSE
- emcTrajPause()
- interpState=PAUSED
- task_paused=1
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctaskmain.cc:2369
EMC_TASK_PLAN_RESUME
- emcTrajResume()
- 恢复 interpState
- 清 task_paused
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctask.cc:543
emcTaskPlanOpen(file)
- 清 motionLine/currentLine/readLine
- interp.open(file)
- taskplanopen=1
/home/meswork/cnc_wams/linuxcnc/src/emc/task/emctask.cc:566
emcTaskPlanRead()
- interp.read()
- 文件未打开时用 emcStatus->task.file 重新 open/read
```
对当前 web runtime 的约束:
```text
1. initializeTaskHalSession({ openProgram: true }) 必须等价于先完成 EMC_TASK_PLAN_OPEN。
2. RUN gate 必须确认 opened program 与当前 selected G-code 是同一个 staged machine-file。
3. RUN 发送 EMC_TASK_PLAN_RUN 后UI 不能用 canonical timing 或 fixture sample 冒充 task 反馈。
4. 连续反馈必须来自 taskHalRuntime.runCycles/readStatus且 status 的 task/motion/HAL 字段必须同源。
5. PAUSE/RESUME/ABORT 要按 LinuxCNC task command 修改状态循环,而不是只改前端 runState。
```
## 6. 详细测试计划
### 6.1 INI/profile 解析测试
目标:证明 `RUN` 使用的不是手写机床参数,而是明确 LinuxCNC INI。 目标:证明 `RUN` 使用的不是手写机床参数,而是明确 LinuxCNC INI。
@@ -175,7 +645,7 @@ node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
run_preconditions_ini_profile_smoke=ok run_preconditions_ini_profile_smoke=ok
``` ```
### 4.2 运动学模块确认测试 ### 6.2 运动学模块确认测试
目标:证明当前机床的五轴运动学算法已经按 INI/profile 加载。 目标:证明当前机床的五轴运动学算法已经按 INI/profile 加载。
@@ -199,7 +669,7 @@ node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_kinematics_runtime.mjs
新增 RUN 前置测试应把这个结果纳入 `RUN` gate而不是只作为独立 smoke。 新增 RUN 前置测试应把这个结果纳入 `RUN` gate而不是只作为独立 smoke。
### 4.3 machine-file staging 测试 ### 6.3 machine-file staging 测试
目标:证明 `RUN` 打开的程序、INI、remap、tool table 是同一个 LinuxCNC 机床目录上下文。 目标:证明 `RUN` 打开的程序、INI、remap、tool table 是同一个 LinuxCNC 机床目录上下文。
@@ -226,7 +696,7 @@ node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_kinematics_runtime.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_machine_file_staging.mjs node web-rtcp-5axis-sim-plan/tests/node/verify_machine_file_staging.mjs
``` ```
### 4.4 RUN 连续 feedback 测试 ### 6.4 RUN 连续 feedback 测试
目标:证明点击 `RUN` 后,不是只推进一次 `taskCycles=5`,而是持续从 task/HAL/motion 获取反馈。 目标:证明点击 `RUN` 后,不是只推进一次 `taskCycles=5`,而是持续从 task/HAL/motion 获取反馈。
@@ -282,7 +752,7 @@ programRuntimeFeedback.sourceMode 不是 linuxcnc-task-motion-hal-wasm。
RTCP 状态与当前 G-code 的 M428/M429 不一致。 RTCP 状态与当前 G-code 的 M428/M429 不一致。
``` ```
### 4.5 浏览器端测试 ### 6.5 浏览器端测试
建议扩展: 建议扩展:
@@ -318,7 +788,7 @@ qa/web-rtcp-5axis-site-test/output/run-preconditions-feedback.json
qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/*.png qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/*.png
``` ```
## 5. 编写 RUN 程序的实现步骤 ## 7. 编写 RUN 程序的实现步骤
### Step 1: 增加 `validateRunPreconditions(state)` ### Step 1: 增加 `validateRunPreconditions(state)`
@@ -338,6 +808,8 @@ app/src/state/store.js
5. 检查 task/HAL runtime ready。 5. 检查 task/HAL runtime ready。
6. 检查 machine file staging ready。 6. 检查 machine file staging ready。
7. 检查 taskHalSession.programPath 指向当前 G-code。 7. 检查 taskHalSession.programPath 指向当前 G-code。
8. 检查 task status 的 opened file 与当前 staged G-code 一致。
9. 检查 machine ON、AUTO mode、homed/no_force_homing。
``` ```
返回结构: 返回结构:
@@ -376,21 +848,32 @@ runTaskHalCommandSequence([...], { taskCycles: 5 })
2. validateRunPreconditions(state) 2. validateRunPreconditions(state)
3. initializeTaskHalSession({ openProgram: true }) 如需要 3. initializeTaskHalSession({ openProgram: true }) 如需要
4. 确认 readStatus() 的 openProgram/programPath 与 state.activeProgram 对齐 4. 确认 readStatus() 的 openProgram/programPath 与 state.activeProgram 对齐
5. 如果 task mode 不是 AUTO先发送 EMC_TASK_SET_MODE AUTO 并等待 status 确认
6. 如果 task state 不是 ON先发送 EMC_TASK_SET_STATE ON 并等待 status 确认
``` ```
未满足前置条件时,只设置 `operatorMessage`,不推进任何 line/sample。 未满足前置条件时,只设置 `operatorMessage`,不推进任何 line/sample。
### Step 3: 拆分 task command 与执行 pump ### Step 3: 拆分 task command 与 taskHalStatusLoop
`RUN` 应只发送 LinuxCNC task 命令: `RUN` 应只发送 LinuxCNC task 命令:
```text ```text
EMC_TASK_SET_STATE ON EMC_TASK_SET_STATE ON
EMC_TASK_SET_MODE AUTO EMC_TASK_SET_MODE AUTO
EMC_TASK_PLAN_RUN line = activeLine - programStartLine EMC_TASK_PLAN_RUN line = linuxcncStartLine
``` ```
然后启动 feedback pump 说明
```text
linuxcncStartLine 应按 LinuxCNC 的 program run line 语义保存。
普通从头运行传 0。
run-from-line 才传明确 line并需要单独处理 start line、previous modal state 和安全性。
不要用 UI activeLine 自行推导 programStartLine。
```
然后启动 `taskHalStatusLoop`
```text ```text
while running: while running:
@@ -399,22 +882,22 @@ while running:
dispatch TASK_HAL_STATUS_APPLIED(status) dispatch TASK_HAL_STATUS_APPLIED(status)
append feedback history append feedback history
if complete/paused/aborted/stopped: if complete/paused/aborted/stopped:
stop pump stop taskHalStatusLoop
``` ```
说明: 说明:
```text ```text
这里的 pump 可以在 store 主线程调度,也可以放到 worker 内部 postMessage 推送。 这里的 taskHalStatusLoop 可以在 store 主线程调度,也可以放到 worker 内部 postMessage 推送。
关键不是 timer 本身,而是每个 tick 都必须由 task/HAL/motion runtime 的 runCycles/readStatus 产生反馈。 关键不是 timer 本身,而是每个 tick 都必须由 task/HAL/motion runtime 的 runCycles/readStatus 产生反馈。
``` ```
### Step 4: 增加 RUN pump 状态 ### Step 4: 增加 RUN status loop 状态
建议 state 增加: 建议 state 增加:
```js ```js
taskHalRunPump: { taskHalStatusLoop: {
active: false, active: false,
sequence: 0, sequence: 0,
profileId: null, profileId: null,
@@ -457,24 +940,24 @@ programRuntimeFeedback
4. 运行态 axisPose、velocity、DTG、activeLine 必须来自同一个 status snapshot。 4. 运行态 axisPose、velocity、DTG、activeLine 必须来自同一个 status snapshot。
``` ```
### Step 6: STOP/ABORT/PAUSE/RESUME 控制 pump ### Step 6: STOP/ABORT/PAUSE/RESUME 控制 taskHalStatusLoop
行为要求: 行为要求:
```text ```text
STOP/ABORT: STOP/ABORT:
send EMC_TASK_ABORT send EMC_TASK_ABORT
stop pump stop taskHalStatusLoop
read final status read final status
PAUSE: PAUSE:
send EMC_TASK_PLAN_PAUSE send EMC_TASK_PLAN_PAUSE
pump 可停止或降频读取 paused status taskHalStatusLoop 可停止或降频读取 paused status
runState=paused runState=paused
RESUME: RESUME:
send EMC_TASK_PLAN_RESUME send EMC_TASK_PLAN_RESUME
restart pump restart taskHalStatusLoop
STEP: STEP:
不走 fixture sample playback 不走 fixture sample playback
@@ -509,7 +992,7 @@ fullLinuxCncProgramExecutionReady=false
在真实 `taskHalRuntime.loaded === true` 且前置条件失败时,不应退回 fixture playback。 在真实 `taskHalRuntime.loaded === true` 且前置条件失败时,不应退回 fixture playback。
## 6. 开发验收命令 ## 8. 开发验收命令
基础检查: 基础检查:
@@ -518,6 +1001,7 @@ node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_kinematics_runtime.mjs node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_kinematics_runtime.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_machine_file_staging.mjs node web-rtcp-5axis-sim-plan/tests/node/verify_machine_file_staging.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_task_hal_runtime.mjs node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_task_hal_runtime.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build npm --prefix web-rtcp-5axis-sim-plan/app run build
``` ```
@@ -540,7 +1024,7 @@ QA 证据采集:
node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
``` ```
## 7. 完成标准 ## 9. 完成标准
`RUN` 可以判定为完善,必须同时满足: `RUN` 可以判定为完善,必须同时满足:
@@ -553,6 +1037,7 @@ node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
6. RUN 后 taskCycle/servoCycle 随时间持续推进,直到 paused/stopped/complete。 6. RUN 后 taskCycle/servoCycle 随时间持续推进,直到 paused/stopped/complete。
7. UI 高亮行和执行历史能复现逐行执行过程。 7. UI 高亮行和执行历史能复现逐行执行过程。
8. fallback playback 不能冒充 LinuxCNC task/HAL runtime。 8. fallback playback 不能冒充 LinuxCNC task/HAL runtime。
9. RUN 失败时能明确区分 INI/profile、open program、machine ON、AUTO、homed、runtime readiness 哪一项未满足。
``` ```
未满足以上条件时,状态应继续标记为: 未满足以上条件时,状态应继续标记为:

View File

@@ -0,0 +1,210 @@
# 04 RUN status loop 完成记录与下一步建议
生成时间2026-06-22
## 1. 本轮完成范围
本轮完成 `03-run-step1-preconditions-complete-next-steps.md` 建议中的连续 feedback 工作,但命名从 `pump` 调整为更明确的:
```text
taskHalStatusLoop
```
这里的 loop 不生成 CNC 状态,不解析 G-code也不自增行号。它只负责在 `RUN` 之后持续调度:
```text
taskHalRuntime.runCycles(...)
taskHalRuntime.readStatus()
dispatch TASK_HAL_STATUS_APPLIED
```
`activeLine``axisPose``currentVelocity``switchkinsType`、DRO 和 runtime feedback 仍来自同一个 task/HAL/motion status snapshot。
## 2. 已完成改动
### 2.1 store 增加 taskHalStatusLoop
更新:
```text
app/src/state/store.js
```
新增状态:
```text
taskHalStatusLoop:
active
sequence
profileId
iniPath
kinematicsModuleId
tickCount
batchSize
intervalMs
taskPeriodNs
servoPeriodNs
lastStatusAt
lastError
stopReason
```
并新增:
```text
programRuntimeFeedbackHistory
```
最多保留 100 条 task/HAL/motion runtime feedback。
### 2.2 RUN 改为连续读取 task/HAL status
`RUN` 当前流程:
```text
1. validateRunPreconditions(..., requireTaskHalSession=false)
2. 初始化或复用当前 machine-file task/HAL session
3. validateRunPreconditions(..., requireTaskHalSession=true)
4. 发送:
EMC_TASK_SET_STATE ON
EMC_TASK_SET_MODE AUTO
EMC_TASK_PLAN_RUN
5. runCycles/readStatus 一次,应用首帧 status
6. 如果 status 仍处于 READING则启动 taskHalStatusLoop
7. loop 持续 runCycles/readStatus并通过 TASK_HAL_STATUS_APPLIED 更新 UI 状态
```
停止条件:
```text
interpState=IDLE 且 nextProgramLine >= openedLineCount -> complete
interpState=PAUSED 或 motion.paused=true -> paused
motion.aborted=true -> stopped
```
### 2.3 STOP / ABORT / PAUSE / RESUME / STEP
更新行为:
```text
STOP/ABORT:
先停止 taskHalStatusLoop再发送 EMC_TASK_ABORT并读取一次 status。
PAUSE:
先停止 taskHalStatusLoop再发送 EMC_TASK_PLAN_PAUSE并读取一次 paused status。
RESUME:
发送 EMC_TASK_PLAN_RESUME若 status 回到 READING则重新启动 taskHalStatusLoop。
STEP:
task/HAL runtime loaded 时不走 fixture playback。
改为明确 runCycles({ taskCycles: 1 }) 后 readStatus 一次。
```
### 2.4 新增验证
新增:
```text
tests/node/verify_run_feedback_loop.mjs
```
覆盖:
```text
1. 加载 xyzac-trt INI。
2. 加载 xyzac-trt kinematics WASM。
3. 加载 task/HAL runtime。
4. stage machine files。
5. 打开 xyzac_switchkins_test_1.ngc。
6. POWER/HOME/AUTO/RUN。
7. 等待 programRuntimeFeedbackHistory 至少 3 条。
8. 断言每条 feedback sourceMode 都是 linuxcnc-task-motion-hal-wasm。
9. 断言 semanticBoundary 是 linuxcnc_task_motion_hal_wasm_simulation_runtime。
10. 断言 taskCycle 和 servoCycle 单调递增。
11. 断言 STOP 后 taskHalStatusLoop.active=false。
```
`app/package.json``smoke:node` 已加入:
```text
node ../tests/node/verify_run_feedback_loop.mjs
```
## 3. 已运行验证
通过:
```bash
node tests/node/verify_run_feedback_loop.mjs
node tests/node/verify_linuxcnc_task_hal_runtime.mjs
node tests/node/verify_run_preconditions.mjs
node tests/node/verify_rtcp_store.mjs
npm --prefix app run build
npm --prefix app run smoke:node
```
关键输出:
```text
run_feedback_status_loop_smoke=ok
linuxcnc_task_hal_runtime_smoke=ok
run_preconditions_ini_profile_smoke=ok
rtcp_store_smoke=ok
gmoccapy_static_build=ok
```
本轮 `npm --prefix app run smoke:node` 已完整通过,包括 native task/HAL audit。
## 4. 当前 RUN 状态
当前 `RUN` 已经从:
```text
发送 task command -> runCycles({ taskCycles: 5 }) -> readStatus() 一次
```
推进为:
```text
发送 task command
-> runCycles/readStatus 首帧
-> taskHalStatusLoop 持续 runCycles/readStatus
-> TASK_HAL_STATUS_APPLIED 持续消费同一 task/HAL/motion status 链
```
重要边界仍保持:
```text
不引入 JS G-code parser。
不在 UI 自增 activeLine。
不在 UI 自造 axisPose。
不把 canonical timing estimate 当作真实 task/HAL runtime。
status 内容必须来自 task/HAL/motion runtime。
```
## 5. 下一步工作建议
下一步建议做浏览器端证据采集:
```text
1. 扩展 qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs。
2. 新增 07-run-preconditions-and-feedback case。
3. 浏览器中执行 POWER/HOME/AUTO/RUN。
4. 采集 t=0.2s / 0.5s / 1.0s / 2.0s state。
5. 断言:
- taskHalStatusLoop.tickCount 增长
- programRuntimeFeedbackHistory 增长
- activeLine 等于 UI 高亮行
- DRO 读取 state.programRuntimeFeedback.axisPose
- RTCP canvas 状态与 state.rtcpState 一致
6. 输出 JSON 和截图证据。
```
随后再考虑:
```text
1. 把 M428/M429 switchkins 状态变化纳入 RUN loop 浏览器断言。
2. 增加 xyzbc-trt 的 RUN status loop 对称测试。
3. 如果需要更接近真实 UI 实时性,再评估是否把 loop 下沉到 worker push status而不是 store setTimeout polling。
```

View File

@@ -0,0 +1,149 @@
# 06 RUN 实施完成情况矩阵与接续工作
生成时间2026-06-22
## 1. 本轮执行范围
`05-run-program-implementation-detailed-steps.md` 核对并补强当前实现,本轮完成:
```text
1. 建立 05 阶段完成情况矩阵。
2. 补强 INI definition contract validation。
3. 修正普通 RUN 的 EMC_TASK_PLAN_RUN 起始行为 line=0。
4. 补充 INI runtime 测试断言。
5. 执行 node smoke 验证。
6. 记录剩余接续工作。
```
## 2. 本轮代码改动
### 2.1 INI contract validation 补强
更新:
```text
app/src/runtime/linuxcnc-ini-runtime.js
tests/node/verify_linuxcnc_ini_runtime.mjs
```
补强内容:
```text
1. parseLinuxCncIni() 现在保留 sourceText。
2. 解析并输出 emcmot.module / emcmot.servoPeriodNs。
3. 解析并输出 task.module / task.cycleTimeSeconds。
4. emcio.toolTable 保持结构化输出。
5. applyIniConfigToProfile() 将 emcmot/task/emcio 写入 profile。
6. validateIniConfig() 增加 05 要求的关键 gate
- required sections: [EMC], [DISPLAY], [RS274NGC], [KINS], [HAL], [HALUI], [TRAJ], [EMCMOT], [TASK], [EMCIO]
- COORDINATES 必须是 XYZAC 或 XYZBC
- JOINTS 必须是 5
- JOINT_0..JOINT_4 必须存在
- KINEMATICS 必须包含 sparm=identityfirst
- M428/M429/M430 remap 必须完整
- HAL_PIN_VARS=1 必须存在
- HALCMD 必须连接 motion.analog-out-03 与 motion.switchkins-type
- SERVO_PERIOD / CYCLE_TIME / TOOL_TABLE 必须存在
```
### 2.2 RUN 起始行修正
更新:
```text
app/src/state/store.js
```
修正:
```text
普通 RUN 发送 EMC_TASK_PLAN_RUN line=0。
不再用 activeLine - programStartLine 推导普通 RUN 起始行。
run-from-line 后续应作为独立功能处理。
```
## 3. 完成情况矩阵
| 05 阶段 | 状态 | 证据 | 备注 |
| --- | --- | --- | --- |
| 阶段 0建立当前状态基线 | 完成 | `smoke:node` 完整通过 | 当前基线可复现 |
| 阶段 1INI definition contract | 完成 | `verify_linuxcnc_ini_runtime.mjs` | 本轮补强 section/字段 validation |
| 阶段 2绑定 kinematics runtime | 完成 | `verify_linuxcnc_kinematics_runtime.mjs`, `verify_run_preconditions.mjs` | RUN gate 检查 module/frame source |
| 阶段 3machine-file staging | 完成 | `verify_machine_file_staging.mjs` | selected G-code 与 wasmProgramPath 同步 |
| 阶段 4初始化 task/HAL session | 完成 | `verify_linuxcnc_task_hal_runtime.mjs` | openProgram/session/status 链路通过 |
| 阶段 5RUN preconditions gate | 完成 | `verify_run_preconditions.mjs` | 仍可继续扩展更细 ON/AUTO/homed 阻断文案 |
| 阶段 6RUN command sequence | 完成 | `verify_run_feedback_loop.mjs` | 本轮修正普通 RUN line=0 |
| 阶段 7taskHalStatusLoop | 完成 | `verify_run_feedback_loop.mjs` | tick/history/STOP 断言通过 |
| 阶段 8统一 TASK_HAL_STATUS_APPLIED | 完成 | `verify_run_feedback_loop.mjs`, `verify_rtcp_store.mjs` | feedback history 保留 task/HAL/motion source |
| 阶段 9STOP/ABORT/PAUSE/RESUME/STEP | 部分完成 | `verify_linuxcnc_task_hal_runtime.mjs` | PAUSE/RESUME 有测试STOP 有 feedback-loop 测试STEP/ABORT 仍需专门断言 |
| 阶段 10UI 显示与交互 | 部分完成 | 现有 browser smoke 未在本轮执行 | 仍需 07 浏览器证据 case |
| 阶段 11full execution boundary | 完成 | `verify_full_execution_boundary.mjs`, native audit | web simulation boundary 通过 |
| 阶段 12Node smoke 集成 | 完成 | `npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node` | 完整通过 |
| 阶段 13浏览器证据采集 | 未完成 | 无新 JSON/截图 | 下一步工作 |
## 4. 本轮验证结果
通过:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_ini_runtime.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
```
关键输出:
```text
linuxcnc_ini_runtime_smoke=ok
gmoccapy_static_build=ok
run_feedback_status_loop_smoke=ok
linuxcnc_task_hal_runtime_smoke=ok
full_execution_boundary_smoke=ok
machine_file_staging_smoke=ok
rtcp_store_smoke=ok
profile_boundary_smoke=ok
```
`smoke:node` 已完整通过。
## 5. 剩余接续工作
下一步建议优先做浏览器证据采集:
```text
1. 扩展 qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs。
2. 新增 07-run-preconditions-and-feedback case。
3. 浏览器执行 POWER/HOME/AUTO/RUN。
4. 采集 t=0.2s / 0.5s / 1.0s / 2.0s / 5.0s state。
5. 断言 taskHalStatusLoop.tickCount 和 programRuntimeFeedbackHistory 增长。
6. 断言 feedback sourceMode 不含 fixture-line-playback。
7. 输出 JSON 与截图:
qa/web-rtcp-5axis-site-test/output/run-preconditions-feedback.json
qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/*.png
```
随后补强控制类测试:
```text
1. ABORT 后 loop active=falserunState/stopped status 正确。
2. STEP 在 task/HAL loaded 时 sourceMode 仍是 linuxcnc-task-motion-hal-wasm。
3. RUN gate 对 machine ON / AUTO / homed / opened-file 增加更细粒度失败断言。
4. taskHalStatusLoop 的 taskPeriodNs/servoPeriodNs 从 INI CYCLE_TIME/SERVO_PERIOD 派生,而不是只使用默认值。
5. xyzbc-trt 增加对称 RUN status loop 测试。
```
## 6. 当前结论
当前 RUN 主链路已经满足 05 的 node 侧核心完成标准:
```text
INI contract ready
-> profile/INI/kinematics 一致
-> machine files staged
-> selected G-code opened by task/HAL session
-> RUN sends LinuxCNC task commands
-> taskHalStatusLoop continuously runCycles/readStatus
-> UI/store consumes TASK_HAL_STATUS_APPLIED snapshots
```
尚未完成的是浏览器端 JSON/截图证据,以及 ABORT/STEP/xyzbc 对称测试的补强。

View File

@@ -0,0 +1,135 @@
# 07 浏览器 RUN feedback 证据完成记录与下一步
生成时间2026-06-22
## 1. 本轮执行范围
`06-run-implementation-traceability-matrix-and-next-steps.md` 的优先级,完成浏览器端 `07-run-preconditions-and-feedback` 证据采集。
本轮没有继续扩展 ABORT/STEP/xyzbc 对称测试。
## 2. 已完成改动
更新:
```text
qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
```
新增:
```text
07-run-preconditions-and-feedback
```
浏览器流程:
```text
1. 等待 app、INI、kinematics runtime、machine files ready。
2. 选择 LinuxCNC vendored G-code:
configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc
3. 确认 taskHalSession.programPath 已 open 同一个 G-code。
4. POWER -> MANUAL -> HOME -> AUTO。
5. RUN。
6. 采集 baseline 和 0.2s / 0.5s / 1.0s / 2.0s / 5.0s state。
7. 截图并输出 JSON 证据。
```
输出:
```text
qa/web-rtcp-5axis-site-test/output/run-preconditions-feedback.json
qa/web-rtcp-5axis-site-test/screenshots/run-preconditions-feedback/
```
## 3. 断言结果
`run-preconditions-feedback.json` 当前状态:
```text
status=PASS
```
通过的关键断言:
```text
1. INI ready。
2. selected G-code 是 xyzac_switchkins_test_1.ngc。
3. task/HAL session opened selected G-code。
4. RUN 后 taskHalStatusLoop sequence 从 1 变为 2。
5. 本次 RUN tickCount=4。
6. 本次 RUN programRuntimeFeedbackHistory=5。
7. feedback sourceMode 全部是 linuxcnc-task-motion-hal-wasm。
8. 不含 fixture-line-playback feedback。
9. semanticBoundary 全部是 linuxcnc_task_motion_hal_wasm_simulation_runtime。
10. activeLine 等于 UI 高亮行。
11. DRO 等于 runtime feedback axisPose。
12. RTCP canvas 状态与 state.rtcpState 一致。
13. taskCycle/servoCycle 可见。
14. canvas 执行轨迹可见。
```
样本摘要:
```text
t=0.2s / 0.5s / 1.0s / 2.0s / 5.0s
runState=complete
taskHalStatusLoop.sequence=2
taskHalStatusLoop.tickCount=4
programRuntimeFeedbackHistory.length=5
activeLine=29
sourceMode=linuxcnc-task-motion-hal-wasm
```
说明:
```text
xyzac_switchkins_test_1.ngc 很短,浏览器首个 0.2s 样本时程序已 complete。
因此证据不要求 5 秒内持续 active=true而是验证本次 RUN 的新 loop sequence、tick/history、task/HAL feedback 和 UI snapshot 一致性。
```
## 4. 已运行验证
执行:
```bash
node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
```
结果:
```text
01-home-toolpath: PASS
02-operator-demo-toolpath: PASS
03-arc-demo-toolpath: PASS
04-clear-preview-reference: PASS
05-vendored-impeller-toolpath: PASS
06-running-rtcp-toolpath: PASS
07-run-preconditions-and-feedback: PASS
```
## 5. 接续工作
下一步建议补控制类 node 测试:
```text
1. ABORT 后 taskHalStatusLoop.active=falserunState/stopped status 正确。
2. STEP 在 task/HAL loaded 时 sourceMode 仍是 linuxcnc-task-motion-hal-wasm。
3. RUN gate 对 machine ON / AUTO / homed / opened-file 增加更细粒度失败断言。
4. taskHalStatusLoop 的 taskPeriodNs/servoPeriodNs 从 INI CYCLE_TIME/SERVO_PERIOD 派生。
5. xyzbc-trt 增加 RUN status loop 对称测试。
```
## 6. 当前完成矩阵变化
`06` 中的阶段 13 状态从:
```text
未完成:无新 JSON/截图
```
更新为:
```text
完成run-preconditions-feedback.json + run-preconditions-feedback/*.png07 case PASS
```

View File

@@ -0,0 +1,131 @@
# 08 LinuxCNC 源程序测试资产
生成时间2026-06-22
## 1. 选择结果
本轮按用户要求,从 `/home/meswork/cnc_wams/linuxcnc` LinuxCNC 源程序中选定一组更适合项目测试的真实 RTCP/五轴资产:
```text
INI:
/home/meswork/cnc_wams/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
G-code:
/home/meswork/cnc_wams/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc
```
已复制到 `working_run`
```text
web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source/xyzac-trt.ini
web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source/impeller-7bl-xyzac.ngc
```
已移除不合适的短程序副本:
```text
web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source/xyzac_switchkins_test_1.ngc
```
## 2. 选择原因
选择 `xyzac-trt.ini` + `impeller-7bl-xyzac.ngc`,原因:
```text
1. INI 是 LinuxCNC 官方 5axis table-rotary-tilting 仿真配置。
2. 坐标为 XYZAC与当前项目默认 profile `xyzac-trt` 一致。
3. KINEMATICS = xyzac-trt-kins sparm=identityfirst。
4. 包含完整 M428/M429/M430 remap contract。
5. 包含 HAL_PIN_VARS=1。
6. 包含 motion.analog-out-03 => motion.switchkins-type HALCMD。
7. G-code 以 M428 ;TCP:xyzac 进入 TCP/RTCP kinematics。
8. G-code 使用 G93 inverse-time feed。
9. G-code 包含大量 XYZAC 五轴联动段。
10. G-code 共 4510 行,比短 switchkins test 程序更适合测试项目功能完整性。
11. G-code 来自 /home/meswork/cnc_wams/linuxcnc 源程序,不是项目手写 fixture。
```
## 3. 源文件哈希
当前副本与 `/home/meswork/cnc_wams/linuxcnc` 源文件逐字节一致:
```text
xyzac-trt.ini
sha256=4b7fa6cb8d3e7031e769b3e0c779b586b67cfd45d715985f40d4ec23c662dc97
impeller-7bl-xyzac.ngc
sha256=e90f0b4b6c43809da94a8170ee1029b5afc3e1bbe9bf2ae66298a8baefad013c
```
验证结果:
```text
ini_cmp=0
ngc_cmp=0
```
## 4. 测试覆盖点
这组资产覆盖:
```text
1. LinuxCNC INI definition contract。
2. profile/INI 坐标一致性XYZAC。
3. kinematics module 一致性xyzac-trt。
4. RTCP/TCP kinematics
M428 -> xyzac TCP
5. inverse-time feed
G93
6. 长路径五轴联动:
XYZAC motion lines with A/C rotary axes
7. machine-file staging。
8. taskHalRuntime.openProgram(programPath)。
9. RUN command sequence。
10. taskHalStatusLoop feedback。
11. UI activeLine/DRO/RTCP/runtime feedback same-snapshot 证据。
12. 长路径 preview、执行轨迹、rapid/feed 与五轴姿态变化。
```
## 5. 已运行验证
执行:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_real_linuxcnc_5axis_program_cases.mjs
```
结果:
```text
linuxcnc_source_program_case_count=8
all_cases_program_preview_points=ok
all_cases_executed_path_points=ok_after_run_or_step
all_cases_toolpath_preview_source=linuxcnc_interpreter_canonical_motion
all_cases_tool_execution_trace_source=linuxcnc_tp_samples_or_task_motion_hal_feedback
all_switchkins_cases_rtcp_state_changes_verified=1
all_cases_source_guard=linuxcnc_vendored_5axis_gcode_source_file
fixture_toolpath_fallback_not_promoted=1
real_linuxcnc_5axis_program_cases_smoke=ok
```
## 6. 后续使用方式
后续新增测试时优先引用这组资产:
```text
working_run/test_linuxcnc_source/xyzac-trt.ini
working_run/test_linuxcnc_source/impeller-7bl-xyzac.ngc
```
如果要测试短 switchkins identity/TCP 循环,再补充:
```text
/home/meswork/cnc_wams/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc
```
如果要测试 xyzbc 对称链路,再补充:
```text
/home/meswork/cnc_wams/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
/home/meswork/cnc_wams/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc
```

View File

@@ -0,0 +1,58 @@
# 09 单位换算与 Three.js 米单位场景完成记录
生成时间2026-06-22
## 本轮目标
解决英寸、毫米、米之间的单位换算问题,重点覆盖:
- 实时轴值显示;
- 刀具路径预览;
- 刀具执行轨迹;
- Three.js 绘图坐标统一使用米。
## 完成情况矩阵
| 项目 | 完成状态 | 代码落点 | 验证 |
| --- | --- | --- | --- |
| 公共线性单位换算 | 已完成 | `app/src/runtime/linear-units.js` | `verify_linear_unit_conversion.mjs` |
| mm/inch/m 到米转换 | 已完成 | `linearValueToMeters()``axesToSceneMeters()` | `25.4mm == 1inch == 0.0254m` |
| 执行计时距离归一到毫米 | 已完成 | `app/src/runtime/execution-timing.js` | inch 段长 `1 inch -> 25.4 mm` |
| 执行计时进给归一到 mm/min | 已完成 | `app/src/runtime/execution-timing.js` | inch feed `1 inch/min -> 25.4 mm/min` |
| G20/G21 程序单位解析 | 已完成 | `app/src/runtime/linuxcnc-interpreter-runtime.js` | G20 motion 标记 `inch`G21 motion 标记 `mm` |
| 刀具路径预览使用米 | 已完成 | `app/src/visualization/five-axis-scene.js` | motion axes 经 `axesToSceneMeters()` |
| 刀具执行轨迹使用米 | 已完成 | `app/src/visualization/five-axis-scene.js` | timing samples 携带/继承 motion 单位 |
| 当前刀位 marker 使用米 | 已完成 | `app/src/visualization/five-axis-scene.js` | runtime feedback linearUnits 参与转换 |
| Three.js 机床参考模型使用米尺度 | 已完成 | `app/src/visualization/five-axis-scene.js` | 参考模型尺寸从任意倍率改为米尺度 |
| 2D fallback 使用米尺度 | 已完成 | `app/src/visualization/five-axis-scene.js` | fallback scale 改为米坐标范围 |
| 轴值实时显示 | 已确认边界 | `store.axisPose` / DRO | 保留机床/程序单位,不改成米 |
## 关键设计结论
实时轴值显示不应强制转换为米。DRO/axisPose 表示当前机床或程序坐标单位,应保留 LinuxCNC/INI/G-code 的语义Three.js 预览层才统一转换为米。
Three.js 场景现在通过 dataset 暴露:
```text
data-three-scene-units="m"
data-three-linear-units="<mm|inch|m>"
data-three-linear-unit-scale-to-meters="<factor>"
```
## 已执行验证
```text
node web-rtcp-5axis-sim-plan/tests/node/verify_linear_unit_conversion.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_rtcp_store.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_real_linuxcnc_5axis_program_cases.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_ini_runtime.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
```
结果均通过。
## 后续工作
1. 增加浏览器截图证据,确认米尺度 Three.js 画面在 desktop/mobile 下不空白、不偏移。
2. 如果后续接入真实 task/motion HAL feedback需要确认 feedback 坐标是否已经按 INI 单位输出,并继续填充 `programRuntimeFeedback.linearUnits`
3. 可继续补 G-code 中途 G20/G21 切换的多段浏览器端预览用例。

View File

@@ -0,0 +1,210 @@
# 10 RUN 控制类测试与 INI 周期派生详细步骤
生成时间2026-06-22
## 1. 本轮目标
`05``09` 已完成 RUN 主链路、浏览器 RUN 证据和米单位场景的基础上,继续补齐 `07``09` 中列出的剩余可自动验证项。
本轮执行范围:
```text
1. ABORT 后 taskHalStatusLoop.active=falserunState/status 正确。
2. STEP 在 task/HAL runtime loaded 时仍消费 linuxcnc-task-motion-hal-wasm feedback。
3. RUN gate 对 machine ON / AUTO / homed / opened-file 保持细粒度失败断言。
4. taskHalStatusLoop 的 taskPeriodNs/servoPeriodNs 从 INI TASK.CYCLE_TIME / EMCMOT.SERVO_PERIOD 派生。
5. xyzbc-trt 增加 RUN status loop 对称 node 测试。
```
不在本轮执行:
```text
1. 新增浏览器 desktop/mobile 米尺度截图证据。
2. G20/G21 中途切换浏览器端预览用例。
3. 新增真实 LinuxCNC native 对照资产。
```
## 2. 执行顺序
### 步骤 1建立当前基线
运行:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_task_hal_runtime.mjs
```
通过标准:
```text
现有 xyzac RUN feedback loop、RUN preconditions、task/HAL runtime smoke 均可复现。
```
### 步骤 2实现 INI 周期派生
代码落点:
```text
app/src/state/store.js
```
实现要求:
```text
1. 从 state.linuxCncIniConfig.task.cycleTimeSeconds 派生 taskPeriodNs。
2. 从 state.linuxCncIniConfig.emcmot.servoPeriodNs 派生 servoPeriodNs。
3. runTaskHalCommandSequence 默认使用派生周期。
4. startTaskHalStatusLoop 默认使用派生周期。
5. 如果 INI 字段缺失,才回退到原默认值 task=10000000ns / servo=1000000ns。
```
验收断言:
```text
xyzac-trt.ini: TASK.CYCLE_TIME=0.010 -> taskPeriodNs=10000000。
xyzac-trt.ini: EMCMOT.SERVO_PERIOD=1000000 -> servoPeriodNs=1000000。
```
### 步骤 3补 ABORT/STEP node 测试
测试落点:
```text
tests/node/verify_run_feedback_loop.mjs
```
新增断言:
```text
1. RUN 后 loop sequence/tick/history 由 task/HAL feedback 产生。
2. ABORT 后 taskHalStatusLoop.active=false。
3. ABORT 后 stopReason 为 aborted 或 runState 被 task/HAL status 收敛到 stopped/complete。
4. STEP 后 programRuntimeFeedback.sourceMode 是 linuxcnc-task-motion-hal-wasm。
5. STEP 后不出现 fixture-line-playback。
```
### 步骤 4补 RUN gate 细粒度断言
测试落点:
```text
tests/node/verify_run_preconditions.mjs
```
新增断言:
```text
1. INI/profile/kinematics/machine-file ready 但 machine off 时RUN gate 返回 machine must be on。
2. machine on 但未 home 时RUN gate 返回 home machine first。
3. homed 但 manual mode 时RUN gate 返回 switch to auto mode first。
4. selected G-code/session 缺失时validateRunPreconditions 返回 no machine-file G-code opened。
```
### 步骤 5补 xyzbc RUN status loop 对称测试
测试落点:
```text
tests/node/verify_run_feedback_loop.mjs
```
流程:
```text
1. 加载 profile=xyzbc-trt。
2. 解析 xyzbc-trt.ini。
3. attach xyzbc-trt kinematics runtime。
4. stage machine files。
5. 选择 xyzbc_switchkins.ngc 或 boat-xyzbc.ngc。
6. POWER -> HOME -> AUTO -> RUN。
7. 断言 taskHalStatusLoop/profile/kinematics/sourceMode/history 与 xyzbc 一致。
```
### 步骤 6验证
运行:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_task_hal_runtime.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
```
通过标准:
```text
所有命令返回 0。
新增断言覆盖 ABORT、STEP、RUN gate、INI cycle period、xyzbc RUN symmetry。
```
## 3. 完成记录模板
执行完成后,追加记录:
```text
1. 修改文件。
2. 新增断言。
3. 验证命令与结果。
4. 未完成项和下一步。
```
## 4. 本轮完成记录
完成时间2026-06-22 17:45 EDT
修改文件:
```text
app/src/state/store.js
tests/node/verify_run_feedback_loop.mjs
tests/node/verify_run_preconditions.mjs
working_run/README.md
working_run/10-run-control-tests-and-ini-cycle-steps.md
```
完成内容:
```text
1. runTaskHalCommandSequence 默认周期改为从 INI 派生:
TASK.CYCLE_TIME -> taskPeriodNs。
EMCMOT.SERVO_PERIOD -> servoPeriodNs。
2. startTaskHalStatusLoop 默认周期改为从同一个 INI 派生。
3. verify_run_feedback_loop.mjs 增加 STOP、ABORT、STEP、xyzbc-trt 对称 RUN 覆盖。
4. verify_run_feedback_loop.mjs 断言 taskHalStatusLoop.taskPeriodNs=10000000、
servoPeriodNs=1000000且 feedback sourceMode 不回退 fixture-line-playback。
5. verify_run_preconditions.mjs 增加 RUN gate 细粒度断言:
machine off、not homed、manual mode、未打开 selected G-code。
6. working_run/README.md 增加 10 文档索引,并把下一步改为米尺度浏览器截图证据。
```
验证结果:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linuxcnc_task_hal_runtime.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
```
结果:
```text
全部通过。
run_preconditions_ini_profile_smoke=ok
run_feedback_status_loop_smoke=ok
linuxcnc_task_hal_runtime_smoke=ok
gmoccapy_static_build=ok
smoke:node 全部通过。
```
剩余项:
```text
1. 增加浏览器 desktop/mobile 米尺度截图证据。
2. 后续可补 G20/G21 中途切换浏览器端预览用例。
```

View File

@@ -0,0 +1,105 @@
# 11 浏览器米尺度场景与 G20/G21 混合单位证据完成记录
生成时间2026-06-22 17:55 EDT
## 1. 本轮目标
`09-linear-units-meter-scene-complete-next-steps.md` 的剩余项继续执行,补齐:
```text
1. 浏览器 desktop/mobile 米尺度 Three.js 截图证据。
2. G20/G21 混合单位浏览器端预览用例。
3. 对应 JSON 证据和截图归档。
```
## 2. 修改文件
```text
app/src/visualization/five-axis-scene.js
qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
working_run/README.md
working_run/11-browser-meter-scene-and-mixed-units-complete.md
gptlog-process/gpdlog.md
```
## 3. 完成内容
```text
1. Three.js canvas dataset 新增 threePathBoundsMeters。
2. browser 证据脚本新增 08-meter-scene-desktop-mobile。
3. desktop/mobile 分别采集 canvas screenshot、pixelStats、dataset。
4. 断言 sceneUnits=m、linearUnitScaleToMeters 有效、pathFitBounds=ok、bounds maxSpan 为米尺度且 canvas 非空。
5. browser 证据脚本新增 09-g20-g21-mixed-units-preview。
6. 加载本地 G-code
G90 G20
G1 X1.0 Y0 Z0 F10
G21
G1 X25.4 Y25.4 Z0 F254
M2
7. 断言 canonical motion 同时保留 inch/mm并且 1 inch 与 25.4 mm 都映射到 0.0254m 场景坐标。
8. RUN case 前恢复 vendored impeller 程序,避免本地混合单位程序污染 RUN 证据上下文。
```
## 4. 新增输出
```text
qa/web-rtcp-5axis-site-test/output/meter-scene-evidence.json
qa/web-rtcp-5axis-site-test/screenshots/meter-scene-evidence/08-meter-scene-desktop.png
qa/web-rtcp-5axis-site-test/screenshots/meter-scene-evidence/08-meter-scene-mobile.png
qa/web-rtcp-5axis-site-test/screenshots/toolpath-preview-cases/09-g20-g21-mixed-units-preview.png
```
`toolpath-preview-cases.json` 当前新增 case
```text
08-meter-scene-desktop-mobile: PASS
09-g20-g21-mixed-units-preview: PASS
```
## 5. 验证结果
执行:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_linear_unit_conversion.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
```
结果:
```text
linear_unit_conversion_smoke=ok
gmoccapy_static_build=ok
browser evidence: all recorded cases PASS
smoke:node: all checks PASS
```
浏览器 JSON 摘要:
```text
meter-scene-evidence.json status=PASS
run-preconditions-feedback.json status=PASS
toolpath-preview-cases:
01-home-toolpath PASS
02-operator-demo-toolpath PASS
03-arc-demo-toolpath PASS
04-clear-preview-reference PASS
05-vendored-impeller-toolpath PASS
08-meter-scene-desktop-mobile PASS
09-g20-g21-mixed-units-preview PASS
06-running-rtcp-toolpath PASS
07-run-preconditions-and-feedback PASS
```
## 6. 当前结论
`working_run` 中列出的 RUN 主链路、控制类测试、INI 周期派生、xyzbc 对称测试、浏览器 RUN feedback 证据、Three.js 米尺度场景证据、G20/G21 混合单位预览证据均已完成并可复现。
后续可选增强:
```text
1. 将浏览器证据脚本拆分为独立小脚本,降低单次运行时间。
2. 补更多真实 LinuxCNC native 对照资产。
```

View File

@@ -0,0 +1,111 @@
# 12 working_run 剩余工作最终完成记录
生成时间2026-06-22
## 1. 执行范围
`working_run` 当前执行情况复核 `01``11` 的接续项,并完成最终收尾确认。
本轮重点核对:
```text
1. RUN 主链路是否仍可复现。
2. RUN preconditions、status loop、ABORT/STEP、xyzbc 对称测试是否通过。
3. INI CYCLE_TIME / SERVO_PERIOD 派生周期是否仍通过测试。
4. Three.js 米单位场景、G20/G21 混合单位浏览器证据是否仍通过。
5. browser evidence JSON 是否全部 PASS。
```
## 2. 当前结论
`working_run` 中早期文档保留的“下一步/未完成”项已经由后续文档覆盖:
```text
06 中的浏览器 RUN feedback 证据 -> 07 已完成。
07 中的 ABORT/STEP/RUN gate/xyzbc 对称测试 -> 10 已完成。
09/10 中的 desktop/mobile 米尺度截图与 G20/G21 混合单位浏览器用例 -> 11 已完成。
```
因此,按 `working_run` 已列出的必做项,当前剩余工作已全部完成并复核通过。
只保留可选增强:
```text
1. 将浏览器证据脚本拆成多个独立小脚本,降低单次运行时间。
2. 后续补更多真实 LinuxCNC native 对照资产。
3. 如果接入真实 task/motion HAL feedback再重新确认 feedback 坐标单位语义。
```
## 3. 本轮复核命令
已执行:
```bash
node web-rtcp-5axis-sim-plan/tests/node/verify_run_preconditions.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_run_feedback_loop.mjs
node web-rtcp-5axis-sim-plan/tests/node/verify_linear_unit_conversion.mjs
npm --prefix web-rtcp-5axis-sim-plan/app run build
npm --prefix web-rtcp-5axis-sim-plan/app run smoke:node
node qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs
```
结果:
```text
run_preconditions_ini_profile_smoke=ok
run_preconditions_kinematics_smoke=ok
run_preconditions_machine_file_smoke=ok
run_feedback_status_loop_smoke=ok
linear_unit_conversion_smoke=ok
gmoccapy_static_build=ok
smoke:node 全部通过
browser evidence 脚本返回 0
```
## 4. 浏览器证据状态
`qa/web-rtcp-5axis-site-test/output/toolpath-preview-cases.json`
```text
01-home-toolpath: PASS
02-operator-demo-toolpath: PASS
03-arc-demo-toolpath: PASS
04-clear-preview-reference: PASS
05-vendored-impeller-toolpath: PASS
08-meter-scene-desktop-mobile: PASS
09-g20-g21-mixed-units-preview: PASS
06-running-rtcp-toolpath: PASS
07-run-preconditions-and-feedback: PASS
```
其他证据:
```text
qa/web-rtcp-5axis-site-test/output/run-preconditions-feedback.json: PASS
qa/web-rtcp-5axis-site-test/output/meter-scene-evidence.json: PASS
```
## 5. 最终状态
当前已完成:
```text
INI definition contract validation
profile/INI/kinematics 一致性验证
machine-file staging
task/HAL session openProgram
RUN gate
RUN command sequence
连续 taskHalStatusLoop
TASK_HAL_STATUS_APPLIED runtime feedback 消费
STOP/ABORT/STEP 控制类测试
INI 周期派生
xyzac/xyzbc RUN status loop 对称覆盖
LinuxCNC 源程序真实五轴资产覆盖
mm/inch/m 到 Three.js 米单位场景转换
desktop/mobile 米尺度浏览器截图证据
G20/G21 混合单位浏览器预览证据
完整 node smoke
浏览器 evidence JSON/截图
```

View File

@@ -9,8 +9,17 @@
| 文件 | 用途 | | 文件 | 用途 |
| --- | --- | | --- | --- |
| `01-run-gcode-execution-principle-and-test.md` | G-code 具体执行过程、`RUN` 执行原理、源码证据、测试观察和问题结论 | | `01-run-gcode-execution-principle-and-test.md` | G-code 具体执行过程、`RUN` 执行原理、源码证据、测试观察和问题结论 |
| `02-run-preconditions-test-and-implementation-steps.md` | `RUN` 前置条件、INI/机床/五轴运动学确认流程、详细测试用例和编写 `RUN` 程序步骤 | | `02-run-preconditions-test-and-implementation-steps.md` | `RUN` 前置条件、INI definition contract、LinuxCNC 源码对照、INI/机床/五轴运动学确认流程、详细测试用例和编写 `RUN` 程序步骤 |
| `03-run-step1-preconditions-complete-next-steps.md` | 本轮已完成的 `RUN` 前置条件 gate、验证结果和下一步接续建议 | | `03-run-step1-preconditions-complete-next-steps.md` | 本轮已完成的 `RUN` 前置条件 gate、验证结果和下一步接续建议 |
| `04-run-status-loop-complete-next-steps.md` | 本轮已完成的 `RUN` 连续 task/HAL status loop、验证结果和下一步接续建议 |
| `05-run-program-implementation-detailed-steps.md` | 根据 01-04 文档整理的程序实现详细实施步骤、代码落点、测试与验收顺序 |
| `06-run-implementation-traceability-matrix-and-next-steps.md` | 按 05 核对后的完成情况矩阵、本轮补强项、验证结果和后续接续工作 |
| `07-browser-run-feedback-evidence-complete-next-steps.md` | 浏览器端 RUN feedback JSON/截图证据完成记录和后续控制类测试建议 |
| `08-linuxcnc-source-test-assets.md` | 从 LinuxCNC 源程序选定并复制到 working_run 的真实 INI/G-code 测试资产说明 |
| `09-linear-units-meter-scene-complete-next-steps.md` | 英寸/毫米/米单位换算、Three.js 米单位场景、执行轨迹单位语义完成记录和后续建议 |
| `10-run-control-tests-and-ini-cycle-steps.md` | RUN 控制类测试、INI 周期派生、ABORT/STEP/RUN gate/xyzbc 对称测试实施步骤与完成记录 |
| `11-browser-meter-scene-and-mixed-units-complete.md` | 浏览器 desktop/mobile 米尺度截图证据、G20/G21 混合单位预览用例与完成记录 |
| `12-working-run-final-completion-record.md` | 按 `working_run` 接续项最终复核后的剩余工作完成记录 |
## 结论摘要 ## 结论摘要
@@ -18,10 +27,13 @@
```text ```text
1. 先确定明确的 LinuxCNC INI 文件。 1. 先确定明确的 LinuxCNC INI 文件。
2. INI/profile 确定机床类型、坐标轴、joint 配置、HAL/remap/tool-table 资产 2. 验证 INI definition contract[EMC]、[DISPLAY]、[RS274NGC]、[KINS]、[HAL]、[TRAJ]、[EMCMOT]、[TASK]、[EMCIO]、[AXIS_*]、[JOINT_*] 必须完整且互相一致
3. 由 INI 的 [KINS] KINEMATICS 确定五轴运动学算法和 switchkins 映射 3. 由 INI/profile 确定机床类型、坐标轴、joint 配置、HAL/remap/tool-table 资产
4. 验证对应 LinuxCNC kinematics WASM 已加载,并能产生 source-derived frame 4. 由 INI 的 [KINS] KINEMATICS 确定五轴运动学算法和 switchkins 映射
5. 之后才进入 RUN打开同一 INI 上下文下的 G-code发送 task command持续消费 task/HAL/motion feedback 5. 验证对应 LinuxCNC kinematics WASM 已加载,并能产生 source-derived frame
6. 验证同一 INI 上下文下的 G-code 已通过 task plan open 打开。
7. 确认 machine ON、AUTO mode、homed/no_force_homing 等 LinuxCNC task gate。
8. 之后才进入 RUN发送 task command持续消费 task/HAL/motion feedback。
``` ```
当前项目明确支持的五轴机床上下文是: 当前项目明确支持的五轴机床上下文是:
@@ -33,22 +45,28 @@
当前实现已经能显示 G-code 程序文本、当前行、高亮行、DRO、runtime feedback 和 task/HAL 状态。 当前实现已经能显示 G-code 程序文本、当前行、高亮行、DRO、runtime feedback 和 task/HAL 状态。
但按 `textbak` 接续文件的边界要求,当前 `RUN` 还不是连续的 task/HAL feedback 推送执行模型 `04` 完成后,当前 `RUN` 已从单次 task/HAL status 读取推进为连续 status loop
```text ```text
RUN 点击后: RUN 点击后:
1. UI 发送 EMC_TASK_SET_STATE / EMC_TASK_SET_MODE / EMC_TASK_PLAN_RUN 1. UI 确认 opened program、machine ON、AUTO、homed 后发送 EMC_TASK_SET_STATE / EMC_TASK_SET_MODE / EMC_TASK_PLAN_RUN
2. store 调用 taskHalRuntime.runCycles({ taskCycles: 5 }) 2. store 调用 taskHalRuntime.runCycles(...)
3. 随后 readStatus() 一次 3. store 调用 taskHalRuntime.readStatus()
4. TASK_HAL_STATUS_APPLIED 将这一次 status 映射到 activeLine / axisPose / velocity / feedback 4. TASK_HAL_STATUS_APPLIED 将 task/HAL/motion status 映射到 activeLine / axisPose / velocity / feedback
5. 没有看到 worker 主动持续 postMessage 推送 status 5. taskHalStatusLoop 持续重复 runCycles/readStatus
6. 如果没有再次调用 runCycles/readStatus状态不会继续随真实时间推进 6. programRuntimeFeedbackHistory 保留最近 100 条 task/HAL/motion feedback
``` ```
因此,当前问题重点不是前端是否需要定时器”,而是: 该顺序与 LinuxCNC 源码中的 `halui.cc::sendProgramRun()``shcom.cc::sendProgramOpen/sendProgramRun()``emctaskmain.cc::EMC_TASK_PLAN_OPEN/RUN/PAUSE/RESUME` 对齐RUN 不是前端播放,而是在已打开程序和可运行 task 状态下发送 `EMC_TASK_PLAN_RUN`,后续状态来自 task/motion/HAL feedback。
当前仍需保持的边界是:
```text ```text
task/HAL runtime 是否能在 G-code 执行期间持续产出状态 taskHalStatusLoop 只负责调度 runCycles/readStatus
UI/store 是否持续消费这些 LinuxCNC-owned 状态 status 内容必须来自 task/HAL/motion runtime
轴值、当前行、速度、DTG、程序时间是否都来自同一条 task/HAL/motion feedback 链。 UI/store 不自增 activeLine
UI/store 不自造 axisPose
浏览器端米尺度 desktop/mobile 截图证据已补齐;
G20/G21 混合单位浏览器预览证据已补齐。
working_run 已列出的必做接续项已全部复核通过,仅保留可选增强。
``` ```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
[APPLICATIONS]
# uncomment to enable:
#APP = halshow --fformat %.5f switchkins.halshow
[EMC]
VERSION = 1.1
MACHINE = sim-xyzac-trt-kins (switchkins)
[DISPLAY]
GEOMETRY = XYZ-A
OPEN_FILE = ./demos/xyzac_switchkins.ngc
PYVCP = ./xyzac-trt.xml
JOG_AXES = XYZC
DISPLAY = axis
MAX_ANGULAR_VELOCITY = 360
MAX_LINEAR_VELOCITY = 1000
POSITION_OFFSET = RELATIVE
POSITION_FEEDBACK = ACTUAL
MAX_FEED_OVERRIDE = 2
PROGRAM_PREFIX = ../../nc_files
INTRO_GRAPHIC = emc2.gif
INTRO_TIME = 1
#EDITOR = geany
TOOL_EDITOR = tooledit z diam
TKPKG = Ngcgui 1.0
NGCGUI_FONT = Helvetica -12 normal
NGCGUI_SUBFILE = xyzac_switchkins_sub.ngc
NGCGUI_SUBFILE = centering.ngc
[RS274NGC]
SUBROUTINE_PATH = ./remap_subs
HAL_PIN_VARS = 1
REMAP = M428 modalgroup=10 ngc=428remap
REMAP = M429 modalgroup=10 ngc=429remap
REMAP = M430 modalgroup=10 ngc=430remap
PARAMETER_FILE = xyzac.var
[KINS]
#NOTE: for backwrds compatibility !!!!!!!!!!!!!!!!!!!
# default switchkins-type == 0 is xyzac-trt-kins
# here switchkins-type == 0 is identity kins
KINEMATICS = xyzac-trt-kins sparm=identityfirst
JOINTS = 5
[HAL]
HALUI = halui
HALFILE = LIB:basic_sim.tcl
POSTGUI_HALFILE = switchkins_postgui.hal
# net for control of motion.switchkins-type
HALCMD = net :kinstype-select <= motion.analog-out-03 => motion.switchkins-type
# vismach xyzac-trt-gui items
HALCMD = loadusr -W xyzac-trt-gui
HALCMD = net :table-x joint.0.pos-fb xyzac-trt-gui.table-x
HALCMD = net :saddle-y joint.1.pos-fb xyzac-trt-gui.saddle-y
HALCMD = net :spindle-z joint.2.pos-fb xyzac-trt-gui.spindle-z
HALCMD = net :tilt-a joint.3.pos-fb xyzac-trt-gui.tilt-a
HALCMD = net :rotate-c joint.4.pos-fb xyzac-trt-gui.rotate-c
HALCMD = net :tool-offset motion.tooloffset.z
HALCMD = net :tool-offset xyzac-trt-kins.tool-offset xyzac-trt-gui.tool-offset
HALCMD = net :y-offset xyzac-trt-kins.y-offset xyzac-trt-gui.y-offset
HALCMD = net :z-offset xyzac-trt-kins.z-offset xyzac-trt-gui.z-offset
HALCMD = sets :y-offset 20
HALCMD = sets :z-offset 10
# not currently supported by xyzac-trt-gui:
HALCMD = setp xyzac-trt-kins.x-rot-point 0
HALCMD = setp xyzac-trt-kins.y-rot-point 0
HALCMD = setp xyzac-trt-kins.z-rot-point 0
HALCMD = setp xyzac-trt-kins.conventional-directions 0
[HALUI]
# NOTE: kinstype==0 is identity kins because sparm=identityfirst
# M429:identity kins (motion.switchkins-type==0 startupDEFAULT)
# M428:xyzac kins (motion.switchkins-type==1)
# M430:userk kins (motion.switchkins-type==2)
MDI_COMMAND = M429
MDI_COMMAND = M428
MDI_COMMAND = M430
[TRAJ]
COORDINATES = XYZAC
LINEAR_UNITS = mm
ANGULAR_UNITS = deg
DEFAULT_LINEAR_VELOCITY = 20
MAX_LINEAR_VELOCITY = 35
MAX_LINEAR_ACCELERATION = 400
DEFAULT_LINEAR_ACCELERATION = 300
[EMCMOT]
EMCMOT = motmod
SERVO_PERIOD = 1000000
COMM_TIMEOUT = 1
[TASK]
TASK = milltask
CYCLE_TIME = 0.010
[EMCIO]
TOOL_TABLE = xyzac-trt.tbl
[AXIS_X]
MIN_LIMIT = -200
MAX_LIMIT = 200
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
[AXIS_Y]
MIN_LIMIT = -100
MAX_LIMIT = 100
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
[AXIS_Z]
MIN_LIMIT = -120
MAX_LIMIT = 120
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
[AXIS_A]
MIN_LIMIT = -100
MAX_LIMIT = 50
MAX_VELOCITY = 30
MAX_ACCELERATION = 300
[AXIS_C]
MIN_LIMIT = -36000
MAX_LIMIT = 36000
MAX_VELOCITY = 30
MAX_ACCELERATION = 300
[JOINT_0]
TYPE = LINEAR
HOME = 0
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
MIN_LIMIT = -200
MAX_LIMIT = 200
HOME_SEARCH_VEL = 0
HOME_SEQUENCE = 0
[JOINT_1]
TYPE = LINEAR
HOME = 0
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
MIN_LIMIT = -100
MAX_LIMIT = 100
HOME_SEARCH_VEL = 0
HOME_SEQUENCE = 0
[JOINT_2]
TYPE = LINEAR
HOME = 0
MAX_VELOCITY = 20
MAX_ACCELERATION = 300
MIN_LIMIT = -120
MAX_LIMIT = 120
HOME_SEARCH_VEL = 0
HOME_SEQUENCE = 0
[JOINT_3]
TYPE = ANGULAR
HOME = 0
MAX_VELOCITY = 30
MAX_ACCELERATION = 300
MIN_LIMIT = -100
MAX_LIMIT = 50
HOME_SEARCH_VEL = 0
HOME_SEQUENCE = 0
[JOINT_4]
TYPE = ANGULAR
HOME = 0
MAX_VELOCITY = 30
MAX_ACCELERATION = 300
MIN_LIMIT = -36000
MAX_LIMIT = 36000
HOME_SEARCH_VEL = 0
HOME_SEQUENCE = 0