按规划继续工作

结论:解释器 WASM、SDK 统一入口、浏览器解释器 smoke 与兼容性文档已闭环,native 和 host/WASM/browser 验证全部通过。
This commit is contained in:
2026-06-08 07:24:06 +08:00
parent c28b629ff6
commit 706fd1e775
20 changed files with 629 additions and 122 deletions

View File

@@ -70,7 +70,12 @@ expanded into a separate implementation of G-code behavior.
- GUI.
7. Frontend code must be implemented with web technology, not migrated from
native GUI code.
8. All build scripts must be incremental. Native and WASM builds must reuse
8. Frontend, browser tests, and Node WASM tests should call generated WASM
modules through `runtime/sdk/src/index.js`. SDK code is a host-boundary
layer only: it may load modules, manage strings, write Emscripten files,
and call exported C ABI functions, but it must not implement G-code,
canonical motion, tool, parameter, kinematics, or planner semantics.
9. All build scripts must be incremental. Native and WASM builds must reuse
object files, dependency files, and command fingerprints, and must not
unconditionally recompile unchanged source files.

View File

@@ -9,6 +9,9 @@ Rules:
3. Port-specific code, build scripts, adapters, tests, and documentation live here.
4. LinuxCNC source is consumed by reference, copy, generated snapshot, or scripted extraction.
5. The browser/WASM product is managed as a separate program.
6. Browser UI and tests should call WASM modules through `runtime/sdk/src/index.js`.
The SDK is a thin module-loading, string-allocation, Emscripten-FS, and C
ABI wrapper; it must not implement CNC semantics.
Suggested layout:
@@ -119,6 +122,9 @@ Directory intent:
- `vendor/linuxcnc/`: read-only copied LinuxCNC source selected for the port.
- `runtime/core/`: the standalone simulation engine built around vendored LinuxCNC code.
- `runtime/sdk/`: JavaScript/TypeScript API for calling the WASM engine.
Stable imports should come from `runtime/sdk/src/index.js`. SDK code may
adapt host/runtime edges, but G-code, tool, parameter, kinematics, and
planner behavior must remain in vendored LinuxCNC source.
- `runtime/ui/`: HTML + JavaScript CNC simulation frontend.
- `runtime/opfs/`: OPFS-backed persistence layer.
- `tests/fixtures/`: stable simulation inputs shared by native and browser tests.

View File

@@ -36,6 +36,12 @@ The current browser smoke validation command is:
wasm-port/tests/browser/verify_ini_panel_browser.sh
```
The current browser interpreter smoke validation command is:
```bash
wasm-port/tests/browser/verify_interp_browser.sh
```
The current aggregate host/WASM/browser smoke command is:
```bash
@@ -53,7 +59,8 @@ The native validation script runs these checks in order:
Confirms every manifest file is present in `vendor/linuxcnc/`, no extra
vendored file exists, and every vendored file is byte-identical to upstream.
3. `tools/verify_no_standalone_cnc_semantics.sh`
Confirms standalone code has not reintroduced `Interp::convert_g()`.
Confirms standalone code has not introduced project-owned `Interp::...`
member definitions outside the documented Python/remap runtime-edge stubs.
4. `tools/verify_native_linuxcnc_fixture_baseline.sh`
Runs a side-by-side fixture baseline through upstream
`../linuxcnc/bin/rs274` and compares normalized canonical events for
@@ -67,17 +74,18 @@ The native validation script runs these checks in order:
The WASM INI smoke script builds `runtime/ui/ini-panel/linuxcnc_ini.js` and
`linuxcnc_ini.wasm` from vendored LinuxCNC `inifile.cc`, then loads that
module through `runtime/sdk/src/linuxcnc-ini.js` in Node and verifies INI
module through `runtime/sdk/src/index.js` in Node and verifies INI
queries against a file written to the Emscripten filesystem.
The WASM interpreter-core smoke script builds
`build/wasm/core/linuxcnc_interp.js` and `linuxcnc_interp.wasm` from the same
vendored LinuxCNC interpreter source set used by the native minimal
interpreter harness. It loads the module in Node, runs the first WASM
interpreter fixture group through `Interp::execute()`, and compares emitted
canonical events plus required LinuxCNC `_setup` state readback with the
matching files in `tests/fixtures/canon/`. It also writes selected G-code
fixtures into the Emscripten filesystem and runs them through LinuxCNC
interpreter harness. It loads the module in Node through
`runtime/sdk/src/index.js`, runs the first WASM interpreter fixture
group through `Interp::execute()`, and compares emitted canonical events plus
required LinuxCNC `_setup` state readback with the matching files in
`tests/fixtures/canon/`. It also writes selected G-code fixtures into the
Emscripten filesystem through the SDK and runs them through LinuxCNC
`Interp::open()`, `Interp::read()`, and `Interp::execute()` to validate the
file execution path.
@@ -90,15 +98,24 @@ targets. It also validates the host-side session snapshot JSON envelope and
round-trip store plus pure-text machine file and G-code stores without
defining CNC machine-state or file-format semantics.
The browser smoke script serves `wasm-port/` over localhost and runs Chromium
headless against a test page that imports the JS SDK, loads the INI WASM
module, queries vendored LinuxCNC INI parsing through the SDK, and performs an
OPFS text-file, generic session snapshot, machine file, and G-code text
round trip.
The browser INI/OPFS smoke script serves `wasm-port/` over localhost and runs
Chromium headless against a test page that imports the JS SDK, loads the INI
WASM module, queries vendored LinuxCNC INI parsing through the SDK, and
performs an OPFS text-file, generic session snapshot, machine file, and G-code
text round trip.
The browser interpreter smoke script serves `wasm-port/` over localhost and
runs Chromium headless against a test page that loads the interpreter-core
WASM module through `runtime/sdk/src/index.js`, writes no CNC
behavior in JavaScript, and verifies existing canonical fixtures through the
exported C ABI backed by vendored LinuxCNC `Interp::execute()` and
`Interp::open()`/`read()`/`execute()` paths, including the INI-aware
named-parameter file path and negative interpreter fixtures with expected
error text plus absent canonical motion output.
The aggregate host smoke script builds the INI and interpreter-core WASM
artifacts once, then runs the Node WASM smokes, the Node OPFS mock smoke, and
the Chromium browser smoke.
the Chromium browser smokes.
## Source Coverage
@@ -147,9 +164,10 @@ The validation fails if:
| Harness | Purpose |
| --- | --- |
| `tests/wasm/node/verify_ini_wasm.sh` | Validates the browser-facing INI WASM module can be built from vendored LinuxCNC `inifile.cc`, loaded through the JS SDK in Node, and queried through the exported C ABI. |
| `tests/wasm/node/verify_interp_wasm.sh` | Validates the initial interpreter-core WASM module can be built from vendored LinuxCNC interpreter source, run the first fixture group through `Interp::execute()` and selected file fixtures through `Interp::open()`/`read()`/`execute()`, and match the native canonical event plus required state readback fixtures. |
| `tests/wasm/node/verify_interp_wasm.sh` | Validates the initial interpreter-core WASM module can be built from vendored LinuxCNC interpreter source, loaded through the interpreter JS SDK, run the first fixture group through `Interp::execute()` and selected file fixtures through `Interp::open()`/`read()`/`execute()`, and match the native canonical event plus required state readback fixtures. |
| `tests/opfs/node/verify_file_service.sh` | Validates the host-owned OPFS text-file adapter, path model, session snapshot store, machine file store, and G-code text store used by the browser INI panel without moving file persistence into the WASM core. |
| `tests/browser/verify_ini_panel_browser.sh` | Validates the INI SDK, WASM module loading, OPFS text-file round trip, generic session snapshot round trip, machine file text round trip, and G-code text round trip in a real browser runtime. |
| `tests/browser/verify_interp_browser.sh` | Validates the interpreter-core WASM module loads through the interpreter JS SDK in a real browser runtime and runs selected positive and negative canonical fixtures through vendored LinuxCNC `Interp::execute()` plus `Interp::open()`/`read()`/`execute()` via the exported C ABI. |
| `tests/host/verify_host_smokes.sh` | Runs the current host-side Node, WASM interpreter-core, OPFS, and browser smoke validation with shared WASM builds. |
## Fixture Coverage
@@ -206,12 +224,127 @@ plus the `g1_zero_feed`, `arc_radius_mismatch`, `arc_zero_radius`,
`cutter_comp_plane_change`, `g53_incremental`, `namedparam_readonly`,
`numbered_param_readonly`, `tool_not_found`, and
`tool_length_offset_not_found` negative fixtures. The WASM interpreter file
path additionally covers the same canonical-event fixture group except
`namedparam_semantics` and `position_params`, plus `file_open_reset`,
`percent_file_finish`, and `oword_subroutine`. OPFS validation is limited to
the JavaScript host-boundary adapter plus the INI browser smoke harness. Full
browser coverage, full SDK coverage, and full machine-session validation remain
future work.
path additionally covers the same canonical-event fixture group, plus
`namedparam_semantics` through the INI-aware file execution ABI,
`file_open_reset`, `percent_file_finish`, and `oword_subroutine`.
`position_params` uses a dedicated file-path expectation under
`tests/fixtures/canon_file/` because LinuxCNC file execution advances the
post-execute position parameters differently than the line-by-line MDI smoke.
OPFS validation is limited to the JavaScript host-boundary adapter plus the INI
browser smoke harness. Full browser coverage, full SDK coverage, and full
machine-session validation remain future work.
## WASM/Browser Fixture Matrix
Node WASM `Interp::execute()` coverage currently includes:
- `minimal_linear`
- `arc_semantics`
- `length_units`
- `modal_incremental`
- `plane_selection`
- `coordinate_offsets`
- `g53_machine_coordinates`
- `feed_control_modes`
- `position_params`
- `probe_semantics`
- `spindle_orient`
- `comment_logging`
- `numbered_params`
- `tool_semantics`
- `tool_table_setup`
- `tool_reload`
- `canned_cycles`
- `cutter_comp_motion`
- `threading_sync`
- `nurbs_g5_semantics`
- `nurbs_g6_semantics`
- `state_tag_motion`
- `canon_runtime_edges`
- `program_end_modal_reset`
- `namedparam_semantics` through the INI-aware program ABI
Node WASM file-path coverage currently includes:
- `minimal_linear`
- `arc_semantics`
- `length_units`
- `modal_incremental`
- `plane_selection`
- `coordinate_offsets`
- `g53_machine_coordinates`
- `feed_control_modes`
- `probe_semantics`
- `spindle_orient`
- `comment_logging`
- `numbered_params`
- `tool_semantics`
- `tool_table_setup`
- `tool_reload`
- `canned_cycles`
- `cutter_comp_motion`
- `threading_sync`
- `nurbs_g5_semantics`
- `nurbs_g6_semantics`
- `state_tag_motion`
- `canon_runtime_edges`
- `program_end_modal_reset`
- `file_open_reset`
- `percent_file_finish`
- `oword_subroutine`
- `position_params` through the dedicated `canon_file/` expectation
- `namedparam_semantics` through the INI-aware file ABI
Browser interpreter `Interp::execute()` coverage currently includes:
- `minimal_linear`
- `arc_semantics`
- `length_units`
- `modal_incremental`
- `plane_selection`
- `coordinate_offsets`
- `g53_machine_coordinates`
- `feed_control_modes`
- `canned_cycles`
- `numbered_params`
- `comment_logging`
- `tool_semantics`
- `tool_table_setup`
- `probe_semantics`
- `spindle_orient`
- `cutter_comp_motion`
- `threading_sync`
- `nurbs_g5_semantics`
- `nurbs_g6_semantics`
- `state_tag_motion`
- `canon_runtime_edges`
- `tool_reload`
- `program_end_modal_reset`
Browser interpreter negative coverage currently includes every fixture under
`tests/fixtures/gcode_errors/`:
- `g1_zero_feed`
- `arc_radius_mismatch`
- `arc_zero_radius`
- `g53_incremental`
- `cutter_comp_plane_change`
- `namedparam_readonly`
- `numbered_param_readonly`
- `tool_length_offset_not_found`
- `tool_not_found`
Browser interpreter file-path coverage currently includes:
- `position_params` through the dedicated `canon_file/` expectation
- `file_open_reset`
- `percent_file_finish`
- `oword_subroutine`
- `namedparam_semantics` through the INI-aware file ABI
All current positive G-code fixtures have browser interpreter smoke coverage
through either `Interp::execute()`, the file-path ABI, or the INI-aware
file-path ABI.
The current fixture expectations validate standalone behavior against both the
vendored LinuxCNC source path and an upstream `rs274` side-by-side baseline for

View File

@@ -49,7 +49,8 @@ semantic rewrites:
- Do not edit `../linuxcnc/`.
- Do not patch vendored files without adding a patch under `patches/` and
documenting the reason.
- Do not add standalone `Interp::convert_g()`.
- Do not add standalone `Interp::...` member definitions outside the
documented Python/remap runtime-edge stubs.
- Do not add `.c` or `.cc` manifest files without a source compile probe.
- Do not sync from a different upstream commit without updating
`tools/upstream-baseline.txt` and `docs/scope-and-baseline.md`.
@@ -58,8 +59,10 @@ semantic rewrites:
- No browser/full-core WASM parity tests yet. The INI parser now has Node and
Chromium smoke harnesses against vendored LinuxCNC `inifile.cc`.
- JS SDK validation is currently limited to the INI WASM wrapper around
vendored LinuxCNC `inifile.cc`.
- JS SDK validation now covers the INI WASM wrapper around vendored LinuxCNC
`inifile.cc` and the interpreter-core SDK wrapper around the existing
exported C ABI. The interpreter SDK only manages strings, Emscripten file
writes, and calls into vendored LinuxCNC execution paths.
- OPFS validation covers a Node mock of the file-service adapter, the
host-side path model, generic session snapshot storage, pure-text machine
file and G-code storage, and a Chromium localhost round trip for those

View File

@@ -888,14 +888,20 @@ Keep these documents under `wasm-port/docs/`:
## Immediate Next Step
Continue expanding the standalone interpreter core from the verified minimal
traverse path:
Continue from the current verified extracted-core baseline without adding
project-authored CNC semantics:
1. keep `convert_g()` execution on vendored LinuxCNC interpreter conversion
code and reject any new standalone `Interp::convert_g()` implementation.
2. identify and shim the native runtime symbols blocking direct compilation of
`interp_convert.cc`, `interp_execute.cc`, and related interpreter files.
3. keep fixture coverage as regression protection while deleting temporary
hand-written semantics.
4. keep all source changes inside `wasm-port/` and leave `../linuxcnc/`
read-only.
1. Keep all `Interp::...` interpreter member behavior on vendored LinuxCNC
source. Standalone code may only provide documented runtime-edge stubs such
as the current Python/remap boundary.
2. Expand WASM interpreter coverage by routing more existing native fixture
paths through `runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp`, using
the same vendored interpreter source set as the native harness.
3. Promote remaining standalone-only fixture expectations to native LinuxCNC
baselines where possible, especially adapter-heavy paths such as INI/HAL
named parameters, tool-change host state, and richer machine session state.
4. Move browser-facing work through SDK and OPFS adapters only after the core
behavior is validated against native LinuxCNC or vendored-source harnesses.
5. Before adding any CNC feature, update `tools/source-manifest.txt`, extract
the LinuxCNC source file, add a source probe or harness, and document the
reuse boundary in `docs/source-reuse-map.md`.

View File

@@ -25,13 +25,15 @@ Current validation is intentionally mechanical:
- `tests/native/verify_native_probes.sh` checks that every manifest `.c` and
`.cc` file has a matching source probe in `build/native/source-probes.tsv`.
- `tools/verify_no_standalone_cnc_semantics.sh` rejects standalone
`Interp::convert_g()` definitions outside `vendor/linuxcnc/`.
`Interp::...` member definitions outside `vendor/linuxcnc/`, except for the
documented Python/remap runtime-edge stubs in
`runtime/core/linuxcnc_wrap/linuxcnc_interp_edge_stubs.cpp`.
## Reuse Matrix
| Capability | LinuxCNC source files | Port classification | Standalone boundary | Current validation |
| --- | --- | --- | --- | --- |
| INI parsing | `src/emc/ini/inifile.cc`, `inifile.h`, `inifile.hh` | Copy unchanged | Native file IO remains LinuxCNC-style in the vendored parser; browser OPFS integration remains outside this layer; `runtime/sdk/src/linuxcnc-ini.js` only wraps the exported WASM C ABI | Vendor byte sync, `linuxcnc_ini_probe`, `linuxcnc_inifile_source_probe`, `tests/wasm/node/verify_ini_wasm.sh`, `tests/browser/verify_ini_panel_browser.sh`, `tests/host/verify_host_smokes.sh` |
| INI parsing | `src/emc/ini/inifile.cc`, `inifile.h`, `inifile.hh` | Copy unchanged | Native file IO remains LinuxCNC-style in the vendored parser; browser OPFS integration remains outside this layer; `runtime/sdk/src/index.js` exports the INI SDK wrapper around the generated WASM C ABI | Vendor byte sync, `linuxcnc_ini_probe`, `linuxcnc_inifile_source_probe`, `tests/wasm/node/verify_ini_wasm.sh`, `tests/browser/verify_ini_panel_browser.sh`, `tests/host/verify_host_smokes.sh` |
| RTAPI compatibility headers | `src/rtapi/rtapi_*.h` in the manifest | Copy unchanged plus standalone shim include path | `runtime/core/shims/rtapi.h` supplies the minimal standalone RTAPI surface needed by vendored code | Vendor byte sync, compile coverage through dependent source probes |
| Canon/NML-facing interpreter types | `src/emc/nml_intf/canon*.hh`, `emctool.h`, `interp_return.hh`, `motion_types.h`, `emcpose.*`, `emcpos.h`, `debugflags.h`, `src/emc/linuxcnc.h` | Copy unchanged plus narrow standalone status shim | NML transport is not ported; `runtime/core/shims/nml_intf/emc.hh` exposes only the `emcStatus` machine-units status edge currently needed by vendored interpreter conversion and initialization code | Vendor byte sync, dependent source probes, `linuxcnc_emc_status_probe`, `linuxcnc_tp_api_probe`, interpreter harnesses |
| Motion state headers | `src/emc/motion/state_tag.h`, `emcmotcfg.h`, `simple_tp.h`, `motion.h`, `mot_priv.h`, `axis.h` | Copy unchanged | Realtime motion process is not ported; standalone probes seed the small motion status/config state required by TP calls | Vendor byte sync, `linuxcnc_tp_api_probe` |
@@ -68,9 +70,11 @@ Current validation is intentionally mechanical:
userspace genser flows are not yet established.
- Cutter compensation positive motion and negative interpreter paths are
fixture-covered through vendored `interp_convert.cc` and `interp_queue.cc`.
- Browser/WASM C ABI and JS SDK layers are not yet built for the full
interpreter/planner core. The INI parser has a minimal JS SDK wrapper and
Node/browser WASM smoke harnesses.
- Browser/WASM C ABI and JS SDK layers are now present for the INI parser and
the current interpreter-core smoke scope. The interpreter SDK is a thin
allocation, filesystem, and C ABI wrapper over vendored LinuxCNC execution
paths; it does not define G-code semantics. Full planner/session SDK
coverage remains future work.
- OPFS persistence is connected to the INI panel through the host-side
`runtime/opfs/file-service.js` adapter. `runtime/opfs/path-model.js` now
defines paths for INI, tool table, parameter file, G-code program,

View File

@@ -183,9 +183,13 @@ char *lcinterp_run_program(const char *program_text)
}
EMSCRIPTEN_KEEPALIVE
char *lcinterp_run_file(const char *path)
char *lcinterp_run_file_with_ini(const char *path, const char *ini_path)
{
unsetenv("INI_FILE_NAME");
if (ini_path && ini_path[0] != '\0') {
setenv("INI_FILE_NAME", ini_path, 1);
} else {
unsetenv("INI_FILE_NAME");
}
Interp interp;
standalone::reset_canon_events();
@@ -248,6 +252,12 @@ char *lcinterp_run_file(const char *path)
return copy_result(output.str());
}
EMSCRIPTEN_KEEPALIVE
char *lcinterp_run_file(const char *path)
{
return lcinterp_run_file_with_ini(path, nullptr);
}
EMSCRIPTEN_KEEPALIVE
void lcinterp_free_string(char *value)
{

View File

@@ -0,0 +1,41 @@
# LinuxCNC WASM SDK
This SDK exposes browser/Node JavaScript wrappers for standalone WASM modules
built from vendored LinuxCNC source.
## Boundary
The SDK is a host-boundary layer only. It may:
- load generated Emscripten modules;
- allocate and free C strings;
- write text files into the Emscripten filesystem;
- call exported C ABI functions;
- return LinuxCNC-produced text output to JavaScript callers.
The SDK must not implement G-code interpretation, canonical motion behavior,
tool semantics, parameter semantics, kinematics, or planner behavior. Those
behaviors must continue to come from vendored LinuxCNC source through the C ABI.
## Entrypoints
Use `src/index.js` for stable imports:
```js
import {
createLinuxCncIniSdk,
createLinuxCncInterpSdk,
} from "./src/index.js";
```
`createLinuxCncIniSdk()` wraps the INI parser module built from vendored
LinuxCNC `inifile.cc`.
`createLinuxCncInterpSdk()` wraps the interpreter-core module built from
vendored LinuxCNC RS274NGC sources and exposes:
- `runProgram(programText)`
- `runProgramWithIni(programText, iniPath)`
- `runFile(path)`
- `runFileWithIni(path, iniPath)`
- `writeTextFile(path, text)`

View File

@@ -0,0 +1,2 @@
export { createLinuxCncIniSdk } from "./linuxcnc-ini.js";
export { createLinuxCncInterpSdk } from "./linuxcnc-interp.js";

View File

@@ -0,0 +1,73 @@
import createLinuxCncInterpModule from "../../../build/wasm/core/linuxcnc_interp.js";
function allocCString(mod, value) {
const bytes = mod.lengthBytesUTF8(value) + 1;
const ptr = mod._malloc(bytes);
mod.stringToUTF8(value, ptr, bytes);
return ptr;
}
function ensureParentPath(mod, path) {
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts.slice(0, -1)) {
current += `/${part}`;
try {
mod.FS.mkdir(current);
} catch {
// Directory already exists.
}
}
}
function copyResultString(mod, resultPtr, functionName) {
if (!resultPtr) {
throw new Error(`${functionName} returned null`);
}
return mod.UTF8ToString(resultPtr);
}
function callStringResult(mod, functionName, ...values) {
const ptrs = values.map((value) => allocCString(mod, value));
let resultPtr = 0;
try {
resultPtr = mod[`_${functionName}`](...ptrs);
return copyResultString(mod, resultPtr, functionName);
} finally {
if (resultPtr) {
mod._lcinterp_free_string(resultPtr);
}
for (const ptr of ptrs) {
mod._free(ptr);
}
}
}
export async function createLinuxCncInterpSdk(moduleOptions = {}) {
const mod = await createLinuxCncInterpModule(moduleOptions);
return {
module: mod,
writeTextFile(path, text) {
ensureParentPath(mod, path);
mod.FS.writeFile(path, text, { encoding: "utf8" });
},
runProgram(programText) {
return callStringResult(mod, "lcinterp_run_program", programText);
},
runProgramWithIni(programText, iniPath) {
return callStringResult(mod, "lcinterp_run_program_with_ini", programText, iniPath);
},
runFile(path) {
return callStringResult(mod, "lcinterp_run_file", path);
},
runFileWithIni(path, iniPath) {
return callStringResult(mod, "lcinterp_run_file_with_ini", path, iniPath);
},
};
}

View File

@@ -1,4 +1,4 @@
import { createLinuxCncIniSdk } from "../../sdk/src/linuxcnc-ini.js";
import { createLinuxCncIniSdk } from "../../sdk/src/index.js";
import {
getOpfsRoot,
loadTextFile,

View File

@@ -7,7 +7,7 @@
<body>
<pre id="status">running</pre>
<script type="module">
import { createLinuxCncIniSdk } from "../../runtime/sdk/src/linuxcnc-ini.js";
import { createLinuxCncIniSdk } from "../../runtime/sdk/src/index.js";
import { loadTextFile, saveTextFile } from "../../runtime/opfs/file-service.js";
import { machineIniPath } from "../../runtime/opfs/path-model.js";
import {

View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>LinuxCNC Interpreter Browser Smoke</title>
</head>
<body>
<pre id="status">running</pre>
<script type="module">
import { createLinuxCncInterpSdk } from "../../runtime/sdk/src/index.js";
const status = document.getElementById("status");
async function fetchText(path) {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`${path}: HTTP ${response.status}`);
}
return response.text();
}
function verifyExpectedOutput(fixtureName, output, expectedText) {
const expectedLines = expectedText.split("\n").filter(Boolean);
for (const expectedLine of expectedLines) {
if (expectedLine.startsWith("absent=")) {
const forbidden = expectedLine.slice("absent=".length);
if (output.includes(forbidden)) {
throw new Error(`${fixtureName}: unexpected ${forbidden}`);
}
continue;
}
if (!output.includes(expectedLine)) {
throw new Error(`${fixtureName}: missing ${expectedLine}`);
}
}
}
try {
const interp = await createLinuxCncInterpSdk({
locateFile(path) {
if (path === "linuxcnc_interp.wasm") {
return "../../build/wasm/core/linuxcnc_interp.wasm";
}
return path;
},
print() {},
printErr(message) {
console.error(message);
},
});
for (const fixtureName of [
"minimal_linear",
"arc_semantics",
"length_units",
"modal_incremental",
"plane_selection",
"coordinate_offsets",
"g53_machine_coordinates",
"feed_control_modes",
"canned_cycles",
"numbered_params",
"comment_logging",
"tool_semantics",
"tool_table_setup",
"probe_semantics",
"spindle_orient",
"cutter_comp_motion",
"threading_sync",
"nurbs_g5_semantics",
"nurbs_g6_semantics",
"state_tag_motion",
"canon_runtime_edges",
"tool_reload",
"program_end_modal_reset",
]) {
const programText = await fetchText(`../fixtures/gcode/${fixtureName}.ngc`);
const expectedText = await fetchText(`../fixtures/canon/${fixtureName}.events`);
verifyExpectedOutput(fixtureName, interp.runProgram(programText), expectedText.trimEnd());
}
for (const errorFixtureName of [
"g1_zero_feed",
"arc_radius_mismatch",
"arc_zero_radius",
"g53_incremental",
"cutter_comp_plane_change",
"namedparam_readonly",
"numbered_param_readonly",
"tool_length_offset_not_found",
"tool_not_found",
]) {
verifyExpectedOutput(
errorFixtureName,
interp.runProgram(await fetchText(`../fixtures/gcode_errors/${errorFixtureName}.ngc`)),
(await fetchText(`../fixtures/canon_errors/${errorFixtureName}.expected`)).trimEnd(),
);
}
const positionParamsText = await fetchText("../fixtures/gcode/position_params.ngc");
const positionParamsPath = "/work/position_params.ngc";
interp.writeTextFile(positionParamsPath, positionParamsText);
verifyExpectedOutput(
"position_params_file",
interp.runFile(positionParamsPath),
(await fetchText("../fixtures/canon_file/position_params.events")).trimEnd(),
);
for (const fileFixtureName of ["file_open_reset", "percent_file_finish", "oword_subroutine"]) {
const programPath = `/work/${fileFixtureName}.ngc`;
interp.writeTextFile(programPath, await fetchText(`../fixtures/gcode/${fileFixtureName}.ngc`));
verifyExpectedOutput(
`${fileFixtureName}_file`,
interp.runFile(programPath),
(await fetchText(`../fixtures/canon/${fileFixtureName}.events`)).trimEnd(),
);
}
const namedParamIniPath = "/work/namedparams.ini";
interp.writeTextFile(namedParamIniPath, await fetchText("../fixtures/ini/namedparams.ini"));
const namedParamPath = "/work/namedparam_semantics.ngc";
interp.writeTextFile(
namedParamPath,
await fetchText("../fixtures/gcode/namedparam_semantics.ngc"),
);
verifyExpectedOutput(
"namedparam_semantics_file",
interp.runFileWithIni(namedParamPath, namedParamIniPath),
(await fetchText("../fixtures/canon/namedparam_semantics.events")).trimEnd(),
);
status.textContent = "browser_interp_smoke=ok";
} catch (error) {
status.textContent = `browser_interp_smoke=fail ${error.stack || error.message}`;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
CHROMIUM="${CHROMIUM:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || command -v google-chrome-stable || true)}"
if [[ -z "$CHROMIUM" ]]; then
echo "missing Chromium-compatible browser; set CHROMIUM=/path/to/browser" >&2
exit 1
fi
if [[ "${SKIP_INTERP_BUILD:-0}" != "1" ]]; then
"$ROOT_DIR/tools/build_wasm_core.sh"
fi
TMP_DIR="$(mktemp -d)"
PORT_FILE="$TMP_DIR/port"
SERVER_LOG="$TMP_DIR/server.log"
CHROME_PROFILE="$TMP_DIR/chrome-profile"
mkdir -p "$CHROME_PROFILE"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
python3 - <<'PY' "$ROOT_DIR" "$PORT_FILE" >"$SERVER_LOG" 2>&1 &
import functools
import http.server
import pathlib
import socketserver
import sys
root = pathlib.Path(sys.argv[1])
port_file = pathlib.Path(sys.argv[2])
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root))
with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
port_file.write_text(str(httpd.server_address[1]), encoding="ascii")
httpd.serve_forever()
PY
SERVER_PID=$!
for _ in $(seq 1 100); do
[[ -s "$PORT_FILE" ]] && break
sleep 0.05
done
if [[ ! -s "$PORT_FILE" ]]; then
echo "browser interpreter smoke HTTP server did not start" >&2
cat "$SERVER_LOG" >&2 || true
exit 1
fi
PORT="$(cat "$PORT_FILE")"
URL="http://127.0.0.1:$PORT/tests/browser/interp_smoke.html"
OUT="$TMP_DIR/chromium.out"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--virtual-time-budget=10000 \
--dump-dom \
"$URL" >"$OUT" 2>&1
if ! grep -Fq "browser_interp_smoke=ok" "$OUT"; then
echo "browser interpreter smoke failed" >&2
sed -n '1,220p' "$OUT" >&2
exit 1
fi
echo "browser_interp_smoke=ok"

View File

@@ -0,0 +1,15 @@
file_open=0
file_read_count=3
file_execute_count=2
canon_event=STRAIGHT_TRAVERSE line=1 x=1 y=2 z=3 a=0 b=0 c=0 u=0 v=0 w=0
canon_event=SET_FEED_RATE rate=80
canon_event=COMMENT: interpreter: distance mode changed to incremental
canon_event=STRAIGHT_FEED line=2 x=1.5 y=1 z=5 a=0 b=0 c=0 u=0 v=0 w=0
setup.current_x=1.5
setup.current_y=1
setup.current_z=5
setup.parameter_5420=1.5
setup.parameter_5421=1
setup.parameter_5422=5
post_execute.distance_mode=1
post_execute.motion_mode=10

View File

@@ -9,5 +9,6 @@ SKIP_INI_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_ini_wasm.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_interp_wasm.sh"
"$ROOT_DIR/tests/opfs/node/verify_file_service.sh"
SKIP_INI_BUILD=1 "$ROOT_DIR/tests/browser/verify_ini_panel_browser.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_interp_browser.sh"
echo "host_wasm_opfs_browser_smokes=ok"

View File

@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import assert from "node:assert/strict";
import { createLinuxCncIniSdk } from "../../../runtime/sdk/src/linuxcnc-ini.js";
import { createLinuxCncIniSdk } from "../../../runtime/sdk/src/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

View File

@@ -3,69 +3,7 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import assert from "node:assert/strict";
import createLinuxCncInterpModule from "../../../build/wasm/core/linuxcnc_interp.js";
function allocCString(mod, value) {
const bytes = mod.lengthBytesUTF8(value) + 1;
const ptr = mod._malloc(bytes);
mod.stringToUTF8(value, ptr, bytes);
return ptr;
}
function runProgram(mod, programText) {
const programPtr = allocCString(mod, programText);
let resultPtr = 0;
try {
resultPtr = mod._lcinterp_run_program(programPtr);
assert.notEqual(resultPtr, 0);
return mod.UTF8ToString(resultPtr);
} finally {
if (resultPtr) {
mod._lcinterp_free_string(resultPtr);
}
mod._free(programPtr);
}
}
function runProgramWithIni(mod, programText, iniPath) {
const programPtr = allocCString(mod, programText);
const iniPathPtr = allocCString(mod, iniPath);
let resultPtr = 0;
try {
resultPtr = mod._lcinterp_run_program_with_ini(programPtr, iniPathPtr);
assert.notEqual(resultPtr, 0);
return mod.UTF8ToString(resultPtr);
} finally {
if (resultPtr) {
mod._lcinterp_free_string(resultPtr);
}
mod._free(programPtr);
mod._free(iniPathPtr);
}
}
function ensureDir(mod, path) {
try {
mod.FS.mkdir(path);
} catch {
// Directory already exists.
}
}
function runFile(mod, path) {
const pathPtr = allocCString(mod, path);
let resultPtr = 0;
try {
resultPtr = mod._lcinterp_run_file(pathPtr);
assert.notEqual(resultPtr, 0);
return mod.UTF8ToString(resultPtr);
} finally {
if (resultPtr) {
mod._lcinterp_free_string(resultPtr);
}
mod._free(pathPtr);
}
}
import { createLinuxCncInterpSdk } from "../../../runtime/sdk/src/index.js";
function verifyExpectedOutput(fixtureName, output, expectedText) {
const expectedLines = expectedText.split("\n").filter(Boolean);
@@ -94,7 +32,7 @@ const __dirname = dirname(__filename);
const rootDir = resolve(__dirname, "../../..");
const wasmPath = resolve(rootDir, "build/wasm/core/linuxcnc_interp.wasm");
const interp = await createLinuxCncInterpModule({
const interp = await createLinuxCncInterpSdk({
wasmBinary: readFileSync(wasmPath),
print() {},
printErr(message) {
@@ -139,15 +77,13 @@ for (const fixtureName of fixtureNames) {
"utf8",
).trimEnd();
verifyExpectedOutput(fixtureName, runProgram(interp, programText), expectedEvents);
verifyExpectedOutput(fixtureName, interp.runProgram(programText), expectedEvents);
}
ensureDir(interp, "/work");
const namedParamIniPath = "/work/namedparams.ini";
interp.FS.writeFile(
interp.writeTextFile(
namedParamIniPath,
readFileSync(resolve(rootDir, "tests/fixtures/ini/namedparams.ini"), "utf8"),
{ encoding: "utf8" },
);
const namedParamProgramText = readFileSync(
resolve(rootDir, "tests/fixtures/gcode/namedparam_semantics.ngc"),
@@ -159,7 +95,7 @@ const namedParamExpected = readFileSync(
).trimEnd();
verifyExpectedOutput(
"namedparam_semantics",
runProgramWithIni(interp, namedParamProgramText, namedParamIniPath),
interp.runProgramWithIni(namedParamProgramText, namedParamIniPath),
namedParamExpected,
);
@@ -185,7 +121,7 @@ for (const fixtureName of errorFixtureNames) {
"utf8",
).trimEnd();
verifyExpectedOutput(fixtureName, runProgram(interp, programText), expectedOutput);
verifyExpectedOutput(fixtureName, interp.runProgram(programText), expectedOutput);
}
const fileFixtureNames = [
@@ -228,7 +164,29 @@ for (const fixtureName of fileFixtureNames) {
"utf8",
).trimEnd();
interp.FS.writeFile(programPath, programText, { encoding: "utf8" });
verifyExpectedOutput(fixtureName, runFile(interp, programPath), expectedEvents);
interp.writeTextFile(programPath, programText);
verifyExpectedOutput(fixtureName, interp.runFile(programPath), expectedEvents);
}
const namedParamFilePath = "/work/namedparam_semantics.ngc";
interp.writeTextFile(namedParamFilePath, namedParamProgramText);
verifyExpectedOutput(
"namedparam_semantics_file",
interp.runFileWithIni(namedParamFilePath, namedParamIniPath),
namedParamExpected,
);
const positionParamsFilePath = "/work/position_params.ngc";
interp.writeTextFile(
positionParamsFilePath,
readFileSync(resolve(rootDir, "tests/fixtures/gcode/position_params.ngc"), "utf8"),
);
verifyExpectedOutput(
"position_params_file",
interp.runFile(positionParamsFilePath),
readFileSync(
resolve(rootDir, "tests/fixtures/canon_file/position_params.events"),
"utf8",
).trimEnd(),
);
console.log("interp_wasm_node_smoke=ok");

View File

@@ -95,5 +95,5 @@ link_wasm_module \
-s STACK_SIZE=2MB \
-s NO_EXIT_RUNTIME=1 \
-s FORCE_FILESYSTEM=1 \
-s EXPORTED_FUNCTIONS='["_malloc","_free","_lcinterp_run_program","_lcinterp_run_program_with_ini","_lcinterp_run_file","_lcinterp_free_string"]' \
-s EXPORTED_FUNCTIONS='["_malloc","_free","_lcinterp_run_program","_lcinterp_run_program_with_ini","_lcinterp_run_file","_lcinterp_run_file_with_ini","_lcinterp_free_string"]' \
-s EXPORTED_RUNTIME_METHODS='["FS","UTF8ToString","stringToUTF8","lengthBytesUTF8"]'

View File

@@ -4,7 +4,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
TMP_FILE="$(mktemp)"
trap 'rm -f "$TMP_FILE"' EXIT
VIOLATION_FILE="$(mktemp)"
trap 'rm -f "$TMP_FILE" "$VIOLATION_FILE"' EXIT
find \
"$ROOT_DIR/runtime" \
@@ -15,11 +16,44 @@ find \
! -path "$ROOT_DIR/vendor/*" \
! -path "$ROOT_DIR/runtime/ui/ini-panel/linuxcnc_ini.js" \
-print0 |
xargs -0 grep -nE '^[[:space:]]*int[[:space:]]+Interp::convert_g[[:space:]]*\(' > "$TMP_FILE" || true
xargs -0 grep -nE '^[[:space:]]*([A-Za-z_][A-Za-z0-9_:<>~*&[:space:]]+[[:space:]]+)?Interp::[A-Za-z_~][A-Za-z0-9_]*[[:space:]]*\(' > "$TMP_FILE" || true
if [[ -s "$TMP_FILE" ]]; then
echo "standalone Interp::convert_g definitions are not allowed:" >&2
cat "$TMP_FILE" >&2
ALLOWED_STUB_FILE="$ROOT_DIR/runtime/core/linuxcnc_wrap/linuxcnc_interp_edge_stubs.cpp"
is_allowed_interp_stub() {
local line="$1"
case "$line" in
"$ALLOWED_STUB_FILE":*Interp::is_pycallable\(* | \
"$ALLOWED_STUB_FILE":*Interp::is_user_defined_g_code\(* | \
"$ALLOWED_STUB_FILE":*Interp::is_any_m_code_remapped\(* | \
"$ALLOWED_STUB_FILE":*Interp::is_user_defined_m_code\(* | \
"$ALLOWED_STUB_FILE":*Interp::is_m_code_remappable\(* | \
"$ALLOWED_STUB_FILE":*Interp::is_g_code_remappable\(* | \
"$ALLOWED_STUB_FILE":*Interp::remap_in_progress\(* | \
"$ALLOWED_STUB_FILE":*Interp::remapping\(* | \
"$ALLOWED_STUB_FILE":*Interp::parse_remap\(* | \
"$ALLOWED_STUB_FILE":*Interp::convert_remapped_code\(* | \
"$ALLOWED_STUB_FILE":*Interp::add_parameters\(* | \
"$ALLOWED_STUB_FILE":*Interp::pycall\(* | \
"$ALLOWED_STUB_FILE":*Interp::py_execute\(* | \
"$ALLOWED_STUB_FILE":*Interp::py_reload\(*)
return 0
;;
esac
return 1
}
while IFS= read -r line; do
if ! is_allowed_interp_stub "$line"; then
printf '%s\n' "$line" >> "$VIOLATION_FILE"
fi
done < "$TMP_FILE"
if [[ -s "$VIOLATION_FILE" ]]; then
echo "standalone Interp member definitions are not allowed outside documented runtime-edge stubs:" >&2
cat "$VIOLATION_FILE" >&2
exit 1
fi