支持真实G代码程序执行

结论:AXIS 风格浏览器仿真页面新增 Open Program、runProgramText 和 loadProgramFile,支持用户提供的 G-code 文本/文件通过 LinuxCNC WASM 执行并参与回放。
This commit is contained in:
2026-06-16 22:14:34 +08:00
parent 163a68ff42
commit 69ccf931b1
8 changed files with 204 additions and 5 deletions

View File

@@ -40,7 +40,7 @@ The project is complete for the current scope when all of the following are true
| --- | --- | --- | | --- | --- | --- |
| LinuxCNC semantic boundary | Stable | JS/browser remains glue, staging, OPFS, WASM/browser boundary, docs, and tests only. | | LinuxCNC semantic boundary | Stable | JS/browser remains glue, staging, OPFS, WASM/browser boundary, docs, and tests only. |
| Real browser simulation priority | Active first priority | Future batches should prioritize the real UI/browser CNC simulation page, with visible machine/session loading, G-code program state, LinuxCNC-backed interpreter execution, machine readiness/status panels, toolpath/preview rendering from LinuxCNC-produced output or validated runtime events, and browser smoke coverage. First entry point: `wasm-port/runtime/ui/simulation/index.html`; gate: `wasm-port/tests/browser/verify_real_simulation_browser.sh`. See `wasm-port/docs/real-browser-simulation-priority.md`. | | Real browser simulation priority | Active first priority | Future batches should prioritize the real UI/browser CNC simulation page, with visible machine/session loading, G-code program state, LinuxCNC-backed interpreter execution, machine readiness/status panels, toolpath/preview rendering from LinuxCNC-produced output or validated runtime events, and browser smoke coverage. First entry point: `wasm-port/runtime/ui/simulation/index.html`; gate: `wasm-port/tests/browser/verify_real_simulation_browser.sh`. See `wasm-port/docs/real-browser-simulation-priority.md`. |
| Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs them through interpreter WASM, and now includes reset/step/play/finish playback over LinuxCNC canonical motion events. It highlights active G-code and motion rows, updates live axis readout, moves the toolhead, and grows the executed SVG toolpath. Current program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. | | Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs operator-provided G-code text/files through interpreter WASM, and includes reset/step/play/finish playback over LinuxCNC canonical motion events. It highlights active G-code and motion rows, updates live axis readout, moves the toolhead, and grows the executed SVG toolpath. Current built-in program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. |
| AXIS-style simulation shell | Phase 1 active | `runtime/ui/simulation/index.html` now uses an AXIS-inspired titlebar, menubar, toolbar, Manual/MDI tabs, Preview/DRO tabs, G-code pane, machine-state pane, and status bar while preserving current LinuxCNC-backed execution and playback APIs. Browser smoke verifies shell regions, tab switching, toolbar playback, and existing program execution. | | AXIS-style simulation shell | Phase 1 active | `runtime/ui/simulation/index.html` now uses an AXIS-inspired titlebar, menubar, toolbar, Manual/MDI tabs, Preview/DRO tabs, G-code pane, machine-state pane, and status bar while preserving current LinuxCNC-backed execution and playback APIs. Browser smoke verifies shell regions, tab switching, toolbar playback, and existing program execution. |
| Simulation test program library | Active | Built-in browser simulation programs live in `wasm-port/runtime/ui/simulation/programs/`, one module per program, exported through `programs/index.js`; `verify_real_simulation_programs.sh` checks directory-backed inventory, IDs, defaults, and playback contracts. | | Simulation test program library | Active | Built-in browser simulation programs live in `wasm-port/runtime/ui/simulation/programs/`, one module per program, exported through `programs/index.js`; `verify_real_simulation_programs.sh` checks directory-backed inventory, IDs, defaults, and playback contracts. |
| Vendor/source guard | Stable | `verify_vendor_sync.sh` and standalone semantic guard are required every batch. | | Vendor/source guard | Stable | `verify_vendor_sync.sh` and standalone semantic guard are required every batch. |

View File

@@ -1887,3 +1887,53 @@ real_simulation_programs_node_smoke=ok
- 程序目录只保存 G-code fixture text 和 expected LinuxCNC canonical motion type 断言; - 程序目录只保存 G-code fixture text 和 expected LinuxCNC canonical motion type 断言;
- 执行仍通过 LinuxCNC-backed WASM SDK - 执行仍通过 LinuxCNC-backed WASM SDK
- JS 没有新增 G-code 解释、planner 或 machine semantics。 - JS 没有新增 G-code 解释、planner 或 machine semantics。
八十四、2026-06-16 继续执行记录:真实 G-code 文本/文件执行
用户要求程序实现跑真实的 G-code 程序。本批在保留内置测试程序库的基础上,新增 operator-provided
G-code text 和 browser File 的执行入口。
本批新增实质能力:
- 页面工具栏新增 Open Program 文件入口:
- `data-open-program`
- `data-open-program-file`
- `window.linuxCncRealSimulationApi` 新增:
- `runProgramText(programText, metadata)`
- `loadProgramFile(file)`
- `runProgramText()` 会直接调用 `runRealBrowserSimulation({ programText })`,因此仍通过
LinuxCNC-backed WASM interpreter 执行;
- `loadProgramFile(file)` 读取浏览器 File 文本,再走同一个 LinuxCNC WASM 执行路径;
- simulation state 新增 program source metadata
- `program.source`
- `program.sourceLabel`
- `program.filename`
- `document.body.dataset.simulationProgramSource`
- UI 显示程序来源:
- G-code pane header
- bottom status bar
- browser smoke 新增真实 G-code 验证:
- `runProgramText()` 执行非内置 G-code验证 canonical output 中出现对应坐标;
- `loadProgramFile()` 使用 browser `File` 对象加载 `.ngc` 文本,验证修改后的 G-code 坐标
通过 LinuxCNC WASM 输出;
- Node smoke 新增 custom `programText` 直接执行验证。
本批新增/更新可执行验证:
```bash
wasm-port/tests/ui/node/verify_real_simulation_programs.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_browser.sh
```
关键通过输出:
```text
real_simulation_programs_node_smoke=ok
browser_real_simulation_page_smoke=ok
```
本批仍保持 LinuxCNC 语义边界:
- JS 只读取用户提供的 G-code 文本/文件;
- G-code 执行仍交给 LinuxCNC-backed WASM
- JS 不解释 G-code、不规划刀路、不推导 CNC 语义。

View File

@@ -29,6 +29,10 @@ The current UI direction is the AXIS-style simulation shell in
Built-in simulation test programs live under Built-in simulation test programs live under
`runtime/ui/simulation/programs/` and are exported by `runtime/ui/simulation/programs/` and are exported by
`runtime/ui/simulation/programs/index.js`. `runtime/ui/simulation/programs/index.js`.
The page can also execute operator-provided G-code text or loaded browser files
through the LinuxCNC-backed WASM interpreter via
`linuxCncRealSimulationApi.runProgramText()` and
`linuxCncRealSimulationApi.loadProgramFile()`.
The project release gate for the current scope is: The project release gate for the current scope is:

View File

@@ -56,6 +56,7 @@ The current simulation page already has:
- LinuxCNC interpreter WASM execution through `createLinuxCncInterpSdk()`; - LinuxCNC interpreter WASM execution through `createLinuxCncInterpSdk()`;
- selectable test programs under `runtime/ui/simulation/programs/`; - selectable test programs under `runtime/ui/simulation/programs/`;
- custom G-code text and browser File execution through LinuxCNC WASM;
- canonical output rendering; - canonical output rendering;
- program-line rendering; - program-line rendering;
- motion table; - motion table;
@@ -125,6 +126,8 @@ Current implemented regions:
### Phase 2: Program Execution Workflow ### Phase 2: Program Execution Workflow
Status: active; custom text/file execution is implemented.
Upgrade the G-code pane and execution controls: Upgrade the G-code pane and execution controls:
- load a selected built-in test program; - load a selected built-in test program;
@@ -133,6 +136,8 @@ Upgrade the G-code pane and execution controls:
buttons; buttons;
- highlight active G-code line and current motion row; - highlight active G-code line and current motion row;
- show run status, source program, motion count, and current frame. - show run status, source program, motion count, and current frame.
- load operator-provided G-code text through `runProgramText()`;
- load browser `File` objects through `loadProgramFile()`;
Built-in test programs must live in: Built-in test programs must live in:
@@ -224,6 +229,8 @@ Required methods:
- `play()`; - `play()`;
- `pause()`; - `pause()`;
- `finishPlayback()`; - `finishPlayback()`;
- `runProgramText(programText, metadata)`;
- `loadProgramFile(file)`;
- future: `loadProgramText(text, metadata)`; - future: `loadProgramText(text, metadata)`;
- future: `loadMachineSessionFromOpfs(options)`; - future: `loadMachineSessionFromOpfs(options)`;
- future: `getMachineReadiness()`; - future: `getMachineReadiness()`;

View File

@@ -501,6 +501,14 @@
.axis-statusbar { grid-template-columns: 1fr; } .axis-statusbar { grid-template-columns: 1fr; }
dl { grid-template-columns: 1fr; } dl { grid-template-columns: 1fr; }
} }
.hidden-file-input {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
</style> </style>
</head> </head>
<body data-simulation-ready="false"> <body data-simulation-ready="false">
@@ -510,6 +518,7 @@
<h1>axis.ngc - AXIS 2.9.8 on LinuxCNC-BROWSER-SIM</h1> <h1>axis.ngc - AXIS 2.9.8 on LinuxCNC-BROWSER-SIM</h1>
<div class="window-controls" aria-hidden="true"><span></span><span></span><span></span><span>×</span></div> <div class="window-controls" aria-hidden="true"><span></span><span></span><span></span><span>×</span></div>
</header> </header>
<input class="hidden-file-input" type="file" accept=".ngc,.nc,.tap,.gcode,.txt" data-open-program-file>
<nav class="axis-menubar" data-axis-shell="menubar" aria-label="AXIS menu"> <nav class="axis-menubar" data-axis-shell="menubar" aria-label="AXIS menu">
<span>File</span> <span>File</span>
<span>Machine</span> <span>Machine</span>
@@ -518,7 +527,7 @@
</nav> </nav>
<div class="axis-toolbar" data-axis-shell="toolbar" aria-label="AXIS toolbar"> <div class="axis-toolbar" data-axis-shell="toolbar" aria-label="AXIS toolbar">
<button type="button" class="toolbar-button" aria-label="Emergency stop"></button> <button type="button" class="toolbar-button" aria-label="Emergency stop"></button>
<button type="button" class="toolbar-button" aria-label="Open program"></button> <button type="button" class="toolbar-button" aria-label="Open program" data-open-program>Open</button>
<button type="button" class="toolbar-button" aria-label="Reload program"></button> <button type="button" class="toolbar-button" aria-label="Reload program"></button>
<div class="program-controls"> <div class="program-controls">
<select data-program-selector aria-label="Simulation test program"></select> <select data-program-selector aria-label="Simulation test program"></select>
@@ -643,7 +652,7 @@
</div> </div>
<section class="axis-gcode-pane" data-axis-shell="gcode-pane" aria-label="G-code program"> <section class="axis-gcode-pane" data-axis-shell="gcode-pane" aria-label="G-code program">
<div class="axis-gcode-head"> <div class="axis-gcode-head">
<span>Program: <span data-selected-program>Executed through the LinuxCNC-backed WASM interpreter.</span></span> <span>Program: <span data-selected-program>Executed through the LinuxCNC-backed WASM interpreter.</span> | Source: <span data-program-source>loading</span></span>
<span>Active line <span data-active-line>-</span></span> <span>Active line <span data-active-line>-</span></span>
</div> </div>
<div class="program-lines" data-program-lines></div> <div class="program-lines" data-program-lines></div>
@@ -678,7 +687,7 @@
</section> </section>
<footer class="axis-statusbar" data-axis-shell="statusbar"> <footer class="axis-statusbar" data-axis-shell="statusbar">
<span>ESTOP</span> <span>ESTOP</span>
<span>No tool</span> <span>No tool | <span data-program-source>loading</span></span>
<span>Position: Relative Actual</span> <span>Position: Relative Actual</span>
</footer> </footer>
</main> </main>
@@ -692,6 +701,8 @@
const runButton = document.querySelector("[data-run-program]"); const runButton = document.querySelector("[data-run-program]");
const programSelector = document.querySelector("[data-program-selector]"); const programSelector = document.querySelector("[data-program-selector]");
const openProgramButton = document.querySelector("[data-open-program]");
const openProgramFile = document.querySelector("[data-open-program-file]");
const resetButton = document.querySelector("[data-playback-reset]"); const resetButton = document.querySelector("[data-playback-reset]");
const prevButton = document.querySelector("[data-playback-prev]"); const prevButton = document.querySelector("[data-playback-prev]");
const playButton = document.querySelector("[data-playback-play]"); const playButton = document.querySelector("[data-playback-play]");
@@ -807,6 +818,48 @@
} }
} }
async function runProgramText(programText, metadata = {}) {
stopPlayback();
document.body.dataset.simulationReady = "false";
setAllText("[data-simulation-status]", "running");
if (runButton) {
runButton.disabled = true;
}
try {
const state = await runRealBrowserSimulation({ documentRef: document, programText });
const label = metadata.label ?? metadata.filename ?? "Custom G-code program";
const sourceLabel = metadata.filename ? `Loaded file: ${metadata.filename}` : (metadata.sourceLabel ?? "Custom G-code text");
state.program = {
id: "custom",
label,
category: "Custom",
source: metadata.source ?? "custom",
sourceLabel,
filename: metadata.filename ?? null,
};
window.linuxCncRealSimulationState = state;
document.body.dataset.simulationProgramId = "custom";
document.body.dataset.simulationProgramSource = state.program.source;
setAllText("[data-selected-program]", state.program.label);
setAllText("[data-program-source]", state.program.sourceLabel);
renderPlayback(0);
return state;
} finally {
if (runButton) {
runButton.disabled = false;
}
}
}
async function loadProgramFile(file) {
const text = await file.text();
return runProgramText(text, {
filename: file.name,
label: file.name,
source: "file",
});
}
try { try {
const state = await runSelectedProgram(DEFAULT_SIMULATION_PROGRAM_ID); const state = await runSelectedProgram(DEFAULT_SIMULATION_PROGRAM_ID);
window.linuxCncRealSimulationState = state; window.linuxCncRealSimulationState = state;
@@ -819,6 +872,8 @@
play: startPlayback, play: startPlayback,
pause: stopPlayback, pause: stopPlayback,
finishPlayback: () => renderPlayback(currentState()?.motion.length - 1 ?? 0), finishPlayback: () => renderPlayback(currentState()?.motion.length - 1 ?? 0),
runProgramText,
loadProgramFile,
runProgramById: async (programId) => { runProgramById: async (programId) => {
const nextState = await runSelectedProgram(programId); const nextState = await runSelectedProgram(programId);
window.linuxCncRealSimulationState = nextState; window.linuxCncRealSimulationState = nextState;
@@ -832,6 +887,18 @@
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error); document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
}); });
}); });
openProgramButton?.addEventListener("click", () => openProgramFile?.click());
openProgramFile?.addEventListener("change", () => {
const file = openProgramFile.files?.[0];
if (!file) {
return;
}
window.linuxCncRealSimulationApi.loadProgramFile(file).catch((error) => {
document.body.dataset.simulationReady = "false";
setAllText("[data-simulation-status]", "failed");
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
});
});
resetButton?.addEventListener("click", () => renderPlayback(0)); resetButton?.addEventListener("click", () => renderPlayback(0));
prevButton?.addEventListener("click", () => stepPlayback(-1)); prevButton?.addEventListener("click", () => stepPlayback(-1));
playButton?.addEventListener("click", togglePlayback); playButton?.addEventListener("click", togglePlayback);

View File

@@ -306,9 +306,11 @@ export function renderSimulationPlaybackFrame(documentRef, state, index = state.
export function renderSimulationState(documentRef, state) { export function renderSimulationState(documentRef, state) {
documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false"; documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false";
documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom"; documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom";
documentRef.body.dataset.simulationProgramSource = state.program?.source ?? "custom";
setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked"); setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked");
setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value); setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value);
setText(documentRef, "[data-selected-program]", state.program?.label ?? "Custom program"); setText(documentRef, "[data-selected-program]", state.program?.label ?? "Custom program");
setText(documentRef, "[data-program-source]", state.program?.sourceLabel ?? "Custom G-code text");
setText(documentRef, "[data-canonical-output]", state.resultText); setText(documentRef, "[data-canonical-output]", state.resultText);
renderProgramSelector(documentRef, state.program?.id ?? "custom"); renderProgramSelector(documentRef, state.program?.id ?? "custom");
renderRows(documentRef, state.summary.rows); renderRows(documentRef, state.summary.rows);
@@ -330,7 +332,15 @@ export async function runRealBrowserSimulation({
const state = { const state = {
apiName: "real-browser-simulation-state", apiName: "real-browser-simulation-state",
stateVersion: 1, stateVersion: 1,
program: program ? { ...program, text: undefined } : null, program: program
? { ...program, source: "builtin", sourceLabel: "Built-in test program", text: undefined }
: {
id: "custom",
label: "Custom G-code program",
category: "Custom",
source: "custom",
sourceLabel: "Custom G-code text",
},
programText: resolvedProgramText, programText: resolvedProgramText,
resultText, resultText,
motion, motion,

View File

@@ -130,6 +130,9 @@
if (!api?.getPrograms || !api?.runProgramById) { if (!api?.getPrograms || !api?.runProgramById) {
throw new Error("simulation API missing program controls"); throw new Error("simulation API missing program controls");
} }
if (!api?.runProgramText || !api?.loadProgramFile) {
throw new Error("simulation API missing real G-code loading controls");
}
if (!api?.resetPlayback || !api?.stepPlayback || !api?.finishPlayback) { if (!api?.resetPlayback || !api?.stepPlayback || !api?.finishPlayback) {
throw new Error("simulation API missing playback controls"); throw new Error("simulation API missing playback controls");
} }
@@ -137,6 +140,9 @@
if (programs.length < 5) { if (programs.length < 5) {
throw new Error("simulation test program inventory too small"); throw new Error("simulation test program inventory too small");
} }
if (!doc.querySelector("[data-open-program]") || !doc.querySelector("[data-open-program-file]")) {
throw new Error("simulation page missing open program controls");
}
doc.querySelector('[data-axis-tab="mdi"]')?.click(); doc.querySelector('[data-axis-tab="mdi"]')?.click();
if (!doc.querySelector('[data-axis-panel="manual"]')?.hidden || doc.querySelector('[data-axis-panel="mdi"]')?.hidden) { if (!doc.querySelector('[data-axis-panel="manual"]')?.hidden || doc.querySelector('[data-axis-panel="mdi"]')?.hidden) {
@@ -230,6 +236,46 @@
throw new Error("drill simulation did not produce expected feed plunges"); throw new Error("drill simulation did not produce expected feed plunges");
} }
const customProgramText = [
"G90 G17 G0 X0 Y0 Z0",
"G1 X2.5 Y0.5 Z0 F75",
"G1 X2.5 Y1.5 Z0",
"M2",
"",
].join("\n");
const customState = await api.runProgramText(customProgramText, {
label: "operator-real-part.ngc",
source: "text",
sourceLabel: "Operator pasted G-code",
});
assertRenderedState(doc, customState);
if (customState.program?.id !== "custom" || customState.program?.source !== "text") {
throw new Error("custom text program state metadata drift");
}
if (!customState.resultText.includes("canon_event=STRAIGHT_FEED line=2 x=2.5 y=0.5 z=0")) {
throw new Error("custom text program did not execute through LinuxCNC WASM");
}
if (doc.body.dataset.simulationProgramId !== "custom" || doc.body.dataset.simulationProgramSource !== "text") {
throw new Error("custom text program body dataset drift");
}
if (!doc.querySelector('[data-axis-shell="statusbar"]')?.textContent.includes("Operator pasted G-code")) {
throw new Error("custom text program source did not render in statusbar");
}
const fileProgram = new frame.contentWindow.File(
[customProgramText.replace("X2.5", "X3.25")],
"real-loaded-file.ngc",
{ type: "text/plain" },
);
const fileState = await api.loadProgramFile(fileProgram);
assertRenderedState(doc, fileState);
if (fileState.program?.source !== "file" || fileState.program?.filename !== "real-loaded-file.ngc") {
throw new Error("loaded file program metadata drift");
}
if (!fileState.resultText.includes("canon_event=STRAIGHT_FEED line=2 x=3.25 y=0.5 z=0")) {
throw new Error("loaded file program did not execute changed G-code through LinuxCNC WASM");
}
status.textContent = "browser_real_simulation_page_smoke=ok"; status.textContent = "browser_real_simulation_page_smoke=ok";
} catch (error) { } catch (error) {
status.textContent = `browser_real_simulation_page_smoke=fail ${error.stack || error.message}`; status.textContent = `browser_real_simulation_page_smoke=fail ${error.stack || error.message}`;

View File

@@ -12,6 +12,7 @@ import {
getSimulationTestProgram, getSimulationTestProgram,
getSimulationTestPrograms, getSimulationTestPrograms,
parseLinuxCncCanonicalMotion, parseLinuxCncCanonicalMotion,
runRealBrowserSimulation,
} from "../../../runtime/ui/simulation/simulation-app.js"; } from "../../../runtime/ui/simulation/simulation-app.js";
import { import {
SIMULATION_TEST_PROGRAMS as directoryPrograms, SIMULATION_TEST_PROGRAMS as directoryPrograms,
@@ -119,4 +120,18 @@ const clampedFrame = createPlaybackFrame(playbackState, 99);
assert.equal(clampedFrame.index, 2); assert.equal(clampedFrame.index, 2);
assert.equal(clampedFrame.progress, 100); assert.equal(clampedFrame.progress, 100);
const customRunDocument = {
body: { dataset: {} },
querySelector: () => null,
querySelectorAll: () => [],
};
const customRun = await runRealBrowserSimulation({
documentRef: customRunDocument,
programText: "G90 G17 G0 X0 Y0 Z0\nG1 X4.25 Y2.5 F90\nM2\n",
});
assert.equal(customRun.program.id, "custom");
assert.equal(customRun.program.source, "custom");
assert.ok(customRun.resultText.includes("canon_event=STRAIGHT_FEED line=2 x=4.25 y=2.5"));
assert.ok(customRun.summary.ready);
console.log("real_simulation_programs_node_smoke=ok"); console.log("real_simulation_programs_node_smoke=ok");