Advance AXIS-style Three.js simulation UI

This commit is contained in:
2026-06-17 09:59:22 +08:00
parent d6a623308b
commit 656cddf73a
24 changed files with 84658 additions and 75 deletions

View File

@@ -127,7 +127,9 @@ Current implemented regions:
### Phase 2: Program Execution Workflow
Status: active; custom text/file execution and editor-text execution are implemented.
Status: active; custom text/file/editor execution and OPFS program persistence
are implemented. MDI scratch text can now be loaded into the AXIS G-code pane
or executed through the same LinuxCNC-backed WASM path.
Upgrade the G-code pane and execution controls:
@@ -135,12 +137,26 @@ Upgrade the G-code pane and execution controls:
- run through LinuxCNC WASM;
- reset/step/play/pause/finish playback from toolbar and keyboard-accessible
buttons;
- reload the current built-in, editor, MDI, file, or OPFS-backed program
through the same LinuxCNC-backed execution path;
- highlight active G-code line and current motion row;
- show run status, source program, motion count, and current frame.
- load operator-provided G-code text through `runProgramText()`;
- stage operator-provided G-code text without executing through
`loadProgramText()`;
- load browser `File` objects through `loadProgramFile()`;
- edit visible G-code in the AXIS shell and run it through
`runEditorProgramText()`;
- load or run the MDI panel scratch program through LinuxCNC-backed text
execution;
- record successful LinuxCNC-backed MDI executions in an AXIS-style MDI
history list, with click-to-restore and clear controls;
- record successful LinuxCNC-backed built-in, text, editor, file, OPFS, MDI,
and session-backed executions in an AXIS-style recent program list, with
click-to-stage and clear controls;
- save the current editor G-code through OPFS with `saveProgramToOpfs()`;
- load an OPFS G-code program through `loadProgramFromOpfs()` and execute it
through LinuxCNC WASM;
Built-in test programs must live in:
@@ -158,9 +174,23 @@ Validation:
- browser smoke runs at least one linear program, one arc program, and one
canned-cycle program through toolbar actions;
- active-line, toolhead, executed path, and status bar update together.
- browser smoke saves an edited G-code program to OPFS, changes the editor
text, reloads the OPFS program, and verifies the reloaded text executes
through LinuxCNC WASM.
- browser smoke verifies `loadProgramText()` stages text without executing and
that MDI text execution produces LinuxCNC canonical output.
- browser smoke verifies successful MDI execution records history, restores
history text into the MDI pane, and clears history without changing
LinuxCNC-produced motion state.
- browser smoke verifies successful LinuxCNC-backed executions populate recent
programs, recent entries stage text back into the editor without implicit
execution, and the recent list can be cleared.
### Phase 3: DRO and Modal Display
Status: partially implemented; actual axis readout and LinuxCNC `UPDATE_TAG`
modal display are implemented.
Add a DRO panel modeled after AXIS:
- relative actual X/Y/Z/A/B/C;
@@ -174,6 +204,19 @@ Add a DRO panel modeled after AXIS:
Fallback values must be marked as unavailable rather than invented.
Current implemented behavior:
- relative actual X/Y/Z/A/B/C values are rendered from LinuxCNC canonical
motion frames;
- distance-to-go, G54, G92, tool-length offset, and velocity fields render as
explicit `n/a` until LinuxCNC-produced or validated runtime fields are
available;
- modal rows are derived from LinuxCNC `canon_event=UPDATE_TAG` output;
- spindle, coolant, tool, and override rows are derived from LinuxCNC canonical
events when present and otherwise remain explicit `n/a`;
- browser API exposes `getDroState()`, `getModalState()`, and
`getMachineStatusState()`.
Validation:
- Node and browser tests verify no unavailable field is silently fabricated;
@@ -182,6 +225,10 @@ Validation:
### Phase 4: Preview Upgrade
Status: implemented for the current browser simulation scope; AXIS-like
Three.js viewport controls are available with SVG fallback state for
compatibility tests.
Improve the preview from a simple SVG polyline into an AXIS-like viewport:
- dark viewport theme;
@@ -189,17 +236,85 @@ Improve the preview from a simple SVG polyline into an AXIS-like viewport:
- full path versus executed path;
- active tool marker;
- zoom fit / pan / reset view;
- optional orthographic 3D using Three.js only if it stays stable in browser
smoke and canvas-pixel checks.
- orthographic 3D using Three.js `^0.183.2`, kept stable in browser smoke.
Current implemented behavior:
- dark viewport theme;
- full path versus executed path;
- active tool marker;
- motion-derived extents rectangle, origin axes/marker, and preview legend;
- Three.js canvas renders the primary toolpath preview from LinuxCNC canonical
motion, while SVG remains a hidden fallback/state surface for compatibility;
- runtime imports the local `runtime/ui/simulation/vendor/three/three.module.js`
build, with `runtime/ui/simulation/package.json` documenting the requested
`three` dependency range;
- Three.js preview includes dynamic grid lines and X/Y/Z axis labels;
- Three.js preview renders coordinate axes, motion-type segment metadata,
active toolhead coordinates, renderer revision, and canvas size into the
preview state API for smoke validation;
- Three.js preview samples `ARC_FEED` display curves from LinuxCNC-produced
canonical center, rotation, active-plane, and endpoint fields, while keeping
SVG fallback state endpoint-compatible for existing browser tests;
- Three.js preview now renders AXIS-style tool geometry with a visible tool
body, optional LinuxCNC-produced tool-length-offset indicator, current tool
label when available, and a viewport scale bar surfaced through
`getPreviewState()`;
- Three.js preview renders a compact X/Y/Z orientation triad in the viewport
and surfaces its structured display state through `getPreviewState()`;
- AXIS-style preview HUD overlays the Three.js stage with view/zoom/pan,
toolhead XYZ, machine envelope, grid step, and renderer/path counts derived
from LinuxCNC canonical motion and Three.js render state;
- AXIS-style bottom status bar is structured into E-stop, tool, program
source, active line, position/run mode, and preview state fields, with
`getAxisStatusbarState()` exposing the same browser state for smoke tests;
- fit, zoom in, zoom out, pan, Top/Front/Side/Iso view modes, and reset view
controls;
- AXIS-style preview layer toggles can show/hide rapid traverse, feed, arc,
tool, envelope, and scale-bar display layers without changing the
LinuxCNC-produced motion state;
- Three.js canvas exposes an AXIS-style cursor crosshair and live X/Y preview
coordinate readout derived from the current rendered viewBox; this is display
state only and does not change machine or G-code state;
- Three.js canvas supports wheel zoom, pointer-drag pan, and double-click fit;
- browser resize refreshes the Three.js viewport while preserving playback,
zoom, pan, and view-mode state;
- dedicated desktop/mobile screenshot smoke validates the Three.js viewport,
preview legend, diagnostics, compact tool table, status history, and
G-code/Machine State layout in real browser viewports;
- screenshot smoke captures Preview, DRO, and MDI entry states through stable
URL view parameters;
- screenshot smoke can persist desktop/mobile PNGs and screenshot metadata JSON
when `AXIS_SCREENSHOT_ARTIFACT_DIR` is provided; full
`exportDiagnosticsArtifact()` JSON is available behind
`AXIS_SCREENSHOT_FULL_DIAGNOSTICS=1` for CI jobs that opt into CDP capture;
- release-readiness artifact generation summarizes persisted AXIS screenshot
diagnostics when `AXIS_SCREENSHOT_ARTIFACT_DIR` points at the screenshot
artifact directory, without changing the fixed release gate count;
- browser API exposes `fitPreview()`, `zoomPreview(factor)`,
`panPreview(dx, dy)`, `setPreviewViewMode(mode)`, `getPreviewState()`, and
`resetPreview()`;
- browser API exposes `getPreviewLayerState()` and
`setPreviewLayer(name, enabled)` for AXIS-style display layer controls.
- browser API exposes `getPreviewCursorState()` for the current preview
cursor/crosshair readout.
Validation:
- browser smoke verifies nonblank preview, executed path growth, toolhead
movement, and stable viewport controls;
- mobile and desktop screenshots must not show overlapping UI.
movement, Three.js canvas pixels, sampled canonical arc rendering, structured
tool geometry, scale bar state, AXIS preview layer toggles, and stable
viewport controls;
- browser smoke verifies pointer-driven preview cursor/crosshair coordinate
readout and pointer-leave clearing;
- mobile and desktop screenshot smoke verifies nonblank Three.js rendering,
visible compact panels, and non-overlapping AXIS shell sections.
### Phase 5: Machine/Session Integration
Status: partially implemented; OPFS machine/session readiness, session
staging, and loaded-session program execution are visible in the AXIS shell.
Connect the AXIS-style shell to the existing OPFS/session workflow:
- load machine session from OPFS;
@@ -208,6 +323,75 @@ Connect the AXIS-style shell to the existing OPFS/session workflow:
- stage files into WASM before execution;
- block run controls with explicit reasons when session readiness is missing.
Current implemented behavior:
- Manual Control panel includes machine id, session id, and G-code filename
inputs;
- Manual Control also renders explicit blocked state for real jog/spindle and
coolant controls that are not backed by the browser runtime;
- `Check Session` calls existing `readMachineSessionReadiness()`;
- readiness phase, missing checks, INI path, and G-code path render in the
AXIS shell;
- browser API exposes `getMachineReadiness()` and
`checkMachineSessionReadiness(options)`;
- AXIS panels show LinuxCNC canonical machine status for spindle, coolant,
tool, and override fields when available;
- `Load Session` calls existing LinuxCNC-backed `loadMachineSessionFromOpfs()`
after readiness passes;
- staged INI, parameter, and tool-table WASM paths render in the AXIS shell;
- browser API exposes `getMachineSessionLoadState()` and
`loadReadyMachineSession(options)`;
- staged LinuxCNC tool-table load results are summarized in Machine State with
OPFS/WASM paths, tool count, and the first loaded tool row;
- staged LinuxCNC tool-table rows also render in a compact AXIS-style table
with T/P/Z/D/comment columns;
- editor/custom/built-in program execution uses `runProgramWithIni()` with the
loaded session INI path when a session has been staged;
- toolbar and status bar expose the current run mode as standalone or
session-backed with the active INI path;
- Manual Control exposes a `Use Session` toggle so a staged machine session can
be enabled or bypassed for subsequent runs;
- Machine State includes a compact run summary for execution mode, session INI,
OPFS program path, readiness phase, and session load phase;
- browser API exposes `getRunMode()`, `setUseLoadedSession(value)`, and
`getRunSummary()`;
- toolbar and Machine State expose run-control ready/fallback state so operators
can see whether a requested session-backed run will actually use a loaded INI
or fall back to standalone LinuxCNC WASM;
- AXIS-style keyboard bindings route F3/F5/F6/F7, run, playback stepping,
finish/reset, and preview fit through the same browser API as toolbar
controls;
- Machine State includes a compact diagnostics panel for the last LinuxCNC
canonical event, last surfaced error, readiness/load phase, and most recent
shortcut action;
- Machine State includes a compact status-history ring for recent run,
readiness, session, OPFS, shortcut, and error events;
- Machine State includes a compact limits/home panel that explicitly reports
`n/a` until LinuxCNC-produced runtime fields are available;
- diagnostics can be exported as a structured browser artifact containing run
summary, preview state, status history, tool-table summary, and limits/home
state;
- browser API exposes `getDiagnosticsState()`, `getStatusHistory()`,
`getToolTableSummary()`, `getLimitsHomeState()`,
`exportDiagnosticsArtifact()`, and `handleAxisShortcut(event)`;
- browser smoke covers run and play/pause keyboard shortcuts after waiting for
their async UI/API side effects;
- browser smoke verifies blocked readiness before OPFS files exist, then writes
machine files, G-code, and a session snapshot through existing OPFS helpers
and verifies readiness becomes `ready`;
- browser smoke verifies a ready machine session stages INI, parameter, and
tool-table files into the WASM filesystem through LinuxCNC-backed SDKs.
- browser smoke verifies editor G-code execution uses the loaded session INI
path and still renders LinuxCNC canonical output/playback state.
- browser smoke verifies OPFS-loaded G-code also uses the loaded session INI
path and that run-mode UI/API state switches to session-backed.
- browser smoke verifies disabling `Use Session` returns editor execution to
standalone mode while leaving the staged session available.
- browser smoke verifies run summary API/DOM values for standalone,
session-backed, and OPFS program execution.
- browser smoke verifies AXIS URL view parameters can open Manual/MDI and
Preview/DRO panels without bypassing the normal tab controls.
Validation:
- OPFS/session browser smoke covers load -> run -> playback;
@@ -226,22 +410,56 @@ Required methods:
- `getState()`;
- `getPrograms()`;
- `runProgramById(programId)`;
- `reloadCurrentProgram()`;
- `getPlaybackFrame()`;
- `isPlaybackRunning()`;
- `resetPlayback()`;
- `stepPlayback(delta)`;
- `play()`;
- `pause()`;
- `finishPlayback()`;
- `runProgramText(programText, metadata)`;
- `loadProgramText(text, metadata)`;
- `loadProgramFile(file)`;
- `getProgramText()`;
- `setProgramText(text, metadata)`;
- `runEditorProgramText()`;
- future: `loadProgramText(text, metadata)`;
- future: `loadMachineSessionFromOpfs(options)`;
- future: `getMachineReadiness()`;
- future: `getDroState()`;
- future: `getModalState()`.
- `runMdiProgramText(text)`;
- `getMdiHistory()`;
- `clearMdiHistory()`;
- `getRecentPrograms()`;
- `clearRecentPrograms()`;
- `getOpfsProgramState()`;
- `saveProgramToOpfs(filename)`;
- `loadProgramFromOpfs(filename)`;
- `getDroState()`;
- `getModalState()`;
- `getMachineStatusState()`;
- `fitPreview()`;
- `zoomPreview(factor)`;
- `panPreview(dx, dy)`;
- `setPreviewViewMode(mode)`;
- `getPreviewState()`;
- `getPreviewLayerState()`;
- `setPreviewLayer(name, enabled)`;
- `getPreviewCursorState()`;
- `resetPreview()`;
- `getMachineReadiness()`;
- `checkMachineSessionReadiness(options)`;
- `getMachineSessionLoadState()`;
- `loadReadyMachineSession(options)`;
- `getRunMode()`;
- `getRunControlState()`;
- `getAxisStatusbarState()`;
- `setUseLoadedSession(value)`;
- `getRunSummary()`;
- `getDiagnosticsState()`;
- `getStatusHistory()`;
- `getToolTableSummary()`;
- `getLimitsHomeState()`;
- `exportDiagnosticsArtifact()`;
- `handleAxisShortcut(event)`;
- `applyAxisViewFromUrl(search)`;
API additions must be covered by Node or browser smoke before being relied on
by UI controls.
@@ -267,6 +485,7 @@ Every implementation batch must keep these passing:
```bash
wasm-port/tests/ui/node/verify_real_simulation_programs.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_browser.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_axis_screenshot_browser.sh
wasm-port/tests/docs/node/verify_real_browser_simulation_priority_docs.sh
wasm-port/tests/host/verify_project_release_gate.sh
```
@@ -285,16 +504,14 @@ When AXIS-style shell work starts, add or extend browser assertions for:
## Immediate Next Batch
Implement Phase 1 without removing current functionality:
Continue AXIS by deepening operator workflow coverage without adding
browser-side CNC semantics:
1. Restructure `runtime/ui/simulation/index.html` into the AXIS-style shell.
2. Preserve existing program selector and playback API.
3. Move current playback buttons into the toolbar.
4. Add Manual Control and MDI tabs with readiness/blocked state.
5. Add Preview and DRO tabs with current preview and readout.
6. Add bottom status bar.
7. Extend `real_simulation_page_smoke.html` to verify the new shell regions.
The first implementation batch should be UI structure plus smoke coverage; it
must not invent machine-control behavior that is not backed by LinuxCNC/WASM or
existing session/readiness APIs.
1. Add limits/home real data mapping only when LinuxCNC-produced runtime fields
are available.
2. Keep unavailable DRO and machine fields explicit `n/a` until
LinuxCNC-produced runtime data exists.
3. Promote AXIS screenshot artifact summaries into CI retention once the
release job has a stable artifact directory.
4. Harden the optional CDP diagnostics capture path before making full
`exportDiagnosticsArtifact()` retention mandatory in CI.

View File

@@ -120,6 +120,53 @@ function normalizeObservedOutput(value) {
return [];
}
function normalizeAxisScreenshotArtifacts(value) {
return arrayOrEmpty(value)
.map((artifact) => objectOrEmpty(artifact))
.filter(({ apiName }) => apiName === "real-browser-simulation-axis-screenshot-artifact")
.map((artifact) => ({
apiName: artifact.apiName,
artifactVersion: artifact.artifactVersion ?? 1,
viewportName: artifact.viewportName ?? "unknown",
windowSize: artifact.windowSize ?? "unknown",
query: artifact.query ?? "",
screenshotBytes: Number.isFinite(artifact.screenshotBytes) ? artifact.screenshotBytes : 0,
validatedBy: artifact.validatedBy ?? "unknown",
fullDiagnosticsApiName: artifact.fullDiagnosticsApiName ?? null,
fullDiagnosticsPath: artifact.fullDiagnosticsPath ?? null,
previewRenderer: artifact.previewRenderer ?? null,
previewViewMode: artifact.previewViewMode ?? null,
threeReady: artifact.threeReady === true,
threePathPoints: Number.isFinite(artifact.threePathPoints) ? artifact.threePathPoints : 0,
screenshotPath: artifact.screenshotPath ?? null,
diagnosticsPath: artifact.diagnosticsPath ?? null,
}));
}
function createAxisScreenshotArtifactSummary(axisScreenshotArtifacts = []) {
const artifacts = normalizeAxisScreenshotArtifacts(axisScreenshotArtifacts);
const expectedViewports = ["desktop-preview", "desktop-dro", "mobile-preview", "mobile-mdi"];
const viewportNames = artifacts.map(({ viewportName }) => viewportName);
const missingViewports = expectedViewports.filter((name) => !viewportNames.includes(name));
const undersizedViewports = artifacts
.filter(({ screenshotBytes }) => screenshotBytes < 10000)
.map(({ viewportName }) => viewportName);
const ready = artifacts.length === 0 || (missingViewports.length === 0 && undersizedViewports.length === 0);
return {
apiName: "project-release-axis-screenshot-artifact-summary",
summaryVersion: 1,
phase: ready ? "ready" : "blocked",
ready,
expectedViewports,
artifactCount: artifacts.length,
viewportNames,
missingViewports,
undersizedViewports,
totalScreenshotBytes: artifacts.reduce((sum, { screenshotBytes }) => sum + screenshotBytes, 0),
artifacts,
};
}
export function createProjectReleaseGateManifest() {
return {
apiName: "project-release-gate-manifest",
@@ -1206,6 +1253,7 @@ export function createProjectReleaseReadinessReport({
simConfigInventory = DEFAULT_SIM_CONFIG_INVENTORY_BASELINE,
blockedRuntimeFamilies = BLOCKED_RUNTIME_FAMILIES,
promotedRuntimeFamilies = [],
axisScreenshotArtifacts = [],
} = {}) {
const gateManifest = createProjectReleaseGateManifest();
const gateExecutionManifest = createProjectReleaseGateExecutionManifest({
@@ -1236,11 +1284,14 @@ export function createProjectReleaseReadinessReport({
const promotedBlockedFamilies = promotedFamilies.filter((family) => blockedFamilies.includes(family));
const inventoryReady = inventory.unexpectedFail === 0;
const blockedRuntimeReady = promotedBlockedFamilies.length === 0;
const ready = releaseGatePassed && inventoryReady && blockedRuntimeReady;
const axisScreenshotArtifactSummary = createAxisScreenshotArtifactSummary(axisScreenshotArtifacts);
const axisScreenshotArtifactsReady = axisScreenshotArtifactSummary.ready;
const ready = releaseGatePassed && inventoryReady && blockedRuntimeReady && axisScreenshotArtifactsReady;
const missing = [
...(releaseGatePassed ? [] : ["project-release-gate"]),
...(inventoryReady ? [] : ["sim-config-inventory"]),
...(blockedRuntimeReady ? [] : ["blocked-runtime-families"]),
...(axisScreenshotArtifactsReady ? [] : ["axis-screenshot-artifacts"]),
];
return {
@@ -1258,6 +1309,7 @@ export function createProjectReleaseReadinessReport({
blockedRuntimeFamilies: blockedFamilies,
promotedRuntimeFamilies: promotedFamilies,
promotedBlockedFamilies,
axisScreenshotArtifactSummary,
gateManifest,
gateExecutionManifest,
gateExecutionSummaryViewModel,
@@ -1280,6 +1332,13 @@ export function createProjectReleaseReadinessReport({
label: "Blocked runtime families",
value: blockedRuntimeReady ? blockedFamilies.join(", ") : `promoted: ${promotedBlockedFamilies.join(", ")}`,
},
{
id: "axis-screenshot-artifacts",
label: "AXIS screenshot artifacts",
value: axisScreenshotArtifactSummary.artifactCount > 0
? `${axisScreenshotArtifactSummary.artifactCount}/${axisScreenshotArtifactSummary.expectedViewports.length} captured`
: "not captured",
},
],
};
}
@@ -1290,6 +1349,7 @@ export function createProjectReleaseReadinessSummaryViewModel(
const missing = Array.isArray(report?.missing) ? report.missing : [];
const gateResultMatrix = objectOrEmpty(report?.gateResultMatrix);
const simConfigInventory = objectOrEmpty(report?.simConfigInventory);
const axisScreenshotArtifactSummary = objectOrEmpty(report?.axisScreenshotArtifactSummary);
const blockedRuntimeFamilies = arrayOrEmpty(report?.blockedRuntimeFamilies);
const promotedBlockedFamilies = arrayOrEmpty(report?.promotedBlockedFamilies);
const ready = report?.ready === true;
@@ -1327,6 +1387,13 @@ export function createProjectReleaseReadinessSummaryViewModel(
? `promoted: ${promotedBlockedFamilies.join(", ")}`
: (blockedRuntimeFamilies.join(", ") || "none"),
},
{
id: "axis-screenshot-artifacts",
label: "AXIS screenshot artifacts",
value: Number(axisScreenshotArtifactSummary.artifactCount ?? 0) > 0
? `${axisScreenshotArtifactSummary.artifactCount}/${arrayOrEmpty(axisScreenshotArtifactSummary.expectedViewports).length} captured`
: "not captured",
},
{
id: "missing",
label: "Missing readiness",
@@ -1840,6 +1907,7 @@ export function createProjectReleaseReadinessArtifactValidation(artifact = {}) {
const gateExecutionSummaryViewModel = objectOrEmpty(artifactObject.gateExecutionSummaryViewModel);
const gateResultMatrix = objectOrEmpty(artifactObject.gateResultMatrix);
const gateActionPlan = objectOrEmpty(artifactObject.gateActionPlan);
const axisScreenshotArtifactSummary = objectOrEmpty(artifactObject.axisScreenshotArtifactSummary);
const gates = arrayOrEmpty(artifactObject.gates);
const manifestGateIds = arrayOrEmpty(gateManifest.gateIds);
const executionRows = arrayOrEmpty(gateExecutionManifest.rows);
@@ -1879,6 +1947,30 @@ export function createProjectReleaseReadinessArtifactValidation(artifact = {}) {
&& gateActionPlan.pendingCount === 0
&& gateActionPlan.nextCommand === null
&& actionPlanCommands.length === 0;
const axisScreenshotArtifacts = arrayOrEmpty(axisScreenshotArtifactSummary.artifacts);
const axisScreenshotSummaryReady = !axisScreenshotArtifactSummary.apiName
|| axisScreenshotArtifacts.length === 0
|| (
axisScreenshotArtifactSummary.apiName === "project-release-axis-screenshot-artifact-summary"
&& axisScreenshotArtifactSummary.summaryVersion === 1
&& axisScreenshotArtifactSummary.ready === true
&& axisScreenshotArtifacts.length === 4
&& arrayOrEmpty(axisScreenshotArtifactSummary.missingViewports).length === 0
&& arrayOrEmpty(axisScreenshotArtifactSummary.undersizedViewports).length === 0
&& axisScreenshotArtifacts.every((artifact) =>
artifact.apiName === "real-browser-simulation-axis-screenshot-artifact"
&& artifact.screenshotBytes >= 10000
&& artifact.validatedBy === "browser_real_simulation_page_smoke"
&& (
artifact.fullDiagnosticsPath === null
|| (
artifact.fullDiagnosticsApiName === "real-browser-simulation-diagnostics-artifact"
&& artifact.previewRenderer === "threejs"
&& artifact.threePathPoints > 0
)
)
)
);
const missing = [
...(artifactObject.apiName === PROJECT_RELEASE_READINESS_ARTIFACT_API ? [] : ["apiName"]),
...(artifactObject.reportVersion === PROJECT_RELEASE_READINESS_ARTIFACT_VERSION ? [] : ["reportVersion"]),
@@ -1901,6 +1993,7 @@ export function createProjectReleaseReadinessArtifactValidation(artifact = {}) {
...(executionSummaryReady ? [] : ["gateExecutionSummaryViewModel"]),
...(matrixReady ? [] : ["gateResultMatrix"]),
...(actionPlanReady ? [] : ["gateActionPlan"]),
...(axisScreenshotSummaryReady ? [] : ["axisScreenshotArtifactSummary"]),
...(gateCountReady ? [] : ["gates.length"]),
...(gatesPassed ? [] : ["gates.passed"]),
...(rows.find(({ id, value }) => id === "project-release-gate" && value === "passed")
@@ -1924,6 +2017,8 @@ export function createProjectReleaseReadinessArtifactValidation(artifact = {}) {
gateExecutionSummaryReady: executionSummaryReady,
gateResultMatrixReady: matrixReady,
gateActionPlanReady: actionPlanReady,
axisScreenshotArtifactSummaryReady: axisScreenshotSummaryReady,
axisScreenshotArtifactCount: axisScreenshotArtifacts.length,
blockedRuntimeFamilies: arrayOrEmpty(artifactObject.blockedRuntimeFamilies),
rows: [
{
@@ -1961,6 +2056,13 @@ export function createProjectReleaseReadinessArtifactValidation(artifact = {}) {
label: "Gate action plan",
value: actionPlanReady ? "ready" : "missing",
},
{
id: "axis-screenshot-artifacts",
label: "AXIS screenshot artifacts",
value: axisScreenshotArtifacts.length > 0
? `${axisScreenshotArtifacts.length} captured`
: "not captured",
},
{
id: "validation",
label: "Artifact validation",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "simulation",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"three": "^0.183.2"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"dependencies": {
"three": "^0.183.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LinuxCNC AXIS Screenshot Smoke</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #bfc5c9;
}
iframe {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
border: 0;
}
#status,
#axis-diagnostics-artifact {
position: fixed;
left: -10000px;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
<pre id="status">running</pre>
<pre id="axis-diagnostics-artifact">{}</pre>
<script type="module">
const status = document.getElementById("status");
const artifactNode = document.getElementById("axis-diagnostics-artifact");
const okStatusPrefix = ["axis_screenshot_smoke", "ok"].join("=");
const failStatusPrefix = ["axis_screenshot_smoke", "fail"].join("=");
async function loadFrame() {
const frame = document.createElement("iframe");
frame.src = `../../runtime/ui/simulation/index.html${window.location.search}`;
frame.width = `${window.innerWidth}`;
frame.height = `${window.innerHeight}`;
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener("error", reject, { once: true });
});
return frame;
}
async function waitForReady(frame) {
for (let i = 0; i < 1000; i += 1) {
const state = frame.contentWindow?.linuxCncRealSimulationState;
const canvas = frame.contentDocument?.querySelector("[data-toolpath-three]");
const api = frame.contentWindow?.linuxCncRealSimulationApi;
if (state?.summary?.ready && api?.exportDiagnosticsArtifact && canvas) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("AXIS screenshot smoke did not reach ready simulation API state");
}
function rectOf(doc, selector) {
const node = doc.querySelector(selector);
if (!node) {
throw new Error(`missing screenshot target ${selector}`);
}
return node.getBoundingClientRect();
}
function assertVisibleRect(name, rect, viewport) {
if (rect.width <= 0 || rect.height <= 0) {
throw new Error(`${name} has empty rect ${JSON.stringify(rect.toJSON?.() ?? rect)}`);
}
if (rect.left < -2 || rect.right > viewport.width + 2) {
throw new Error(`${name} overflows viewport horizontally: ${rect.left}..${rect.right} of ${viewport.width}`);
}
if (rect.top < -2 || rect.top > viewport.height + 2) {
throw new Error(`${name} starts outside viewport vertically: ${rect.top} of ${viewport.height}`);
}
}
function intersectionArea(a, b) {
const x = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
const y = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
return x * y;
}
function assertNoOverlap(name, a, b) {
const area = intersectionArea(a, b);
if (area > 1) {
throw new Error(`${name} overlap area ${area}`);
}
}
function assertCanvasPixel(canvas) {
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) {
throw new Error("AXIS screenshot smoke missing WebGL context");
}
const pixel = new Uint8Array(4);
gl.readPixels(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
if (pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0 && pixel[3] === 0) {
throw new Error("AXIS screenshot smoke Three.js center pixel is blank");
}
}
try {
const frame = await loadFrame();
await waitForReady(frame);
frame.contentWindow.dispatchEvent(new Event("resize"));
await new Promise((resolve) => setTimeout(resolve, 100));
const doc = frame.contentDocument;
const viewport = {
width: frame.contentWindow.innerWidth,
height: frame.contentWindow.innerHeight,
};
const canvas = doc.querySelector("[data-toolpath-three]");
const canvasRect = canvas.getBoundingClientRect();
const hudRect = rectOf(doc, '[data-axis-shell="preview-hud"]');
const legendRect = rectOf(doc, '[data-axis-shell="preview-legend"]');
const diagnosticsRect = rectOf(doc, '[data-axis-shell="diagnostics"]');
const toolTableRect = rectOf(doc, '[data-axis-shell="tool-table"]');
const limitsHomeRect = rectOf(doc, '[data-axis-shell="limits-home"]');
const historyRect = rectOf(doc, '[data-axis-shell="status-history"]');
const gcodeRect = rectOf(doc, '[data-axis-shell="gcode-pane"]');
const machineRect = rectOf(doc, '[data-axis-shell="machine-state"]');
const statusbarRect = rectOf(doc, '[data-axis-shell="statusbar"]');
const activeCodesRect = rectOf(doc, '[data-axis-shell="active-codes"]');
for (const [name, rect] of [
["three canvas", canvasRect],
["preview HUD", hudRect],
["preview legend", legendRect],
["diagnostics", diagnosticsRect],
["tool table", toolTableRect],
["limits/home", limitsHomeRect],
["status history", historyRect],
["gcode pane", gcodeRect],
["machine state", machineRect],
["statusbar", statusbarRect],
["active modal codes", activeCodesRect],
]) {
assertVisibleRect(name, rect, viewport);
}
const modalText = doc.querySelector("[data-modal-rows]")?.textContent ?? "";
if (!modalText.includes("Plane:") || !modalText.includes("Origin:")) {
throw new Error(`AXIS screenshot smoke missing LinuxCNC modal code rows: ${modalText}`);
}
assertNoOverlap("gcode/machine state", gcodeRect, machineRect);
assertNoOverlap("diagnostics/status history", diagnosticsRect, historyRect);
if (hudRect.left < canvasRect.left || hudRect.top < canvasRect.top || hudRect.right > canvasRect.right + 1 || hudRect.bottom > canvasRect.bottom + 1) {
throw new Error("preview HUD should remain inside the Three.js canvas stage");
}
const statusbarText = doc.querySelector('[data-axis-shell="statusbar"]')?.textContent ?? "";
if (!statusbarText.includes("Tool") || !statusbarText.includes("Line") || !statusbarText.includes("Preview")) {
throw new Error(`AXIS screenshot smoke missing structured statusbar fields: ${statusbarText}`);
}
if (toolTableRect.top < diagnosticsRect.top || toolTableRect.bottom > diagnosticsRect.bottom + 1) {
throw new Error("tool table should remain inside diagnostics panel");
}
if (limitsHomeRect.top < diagnosticsRect.top || limitsHomeRect.bottom > diagnosticsRect.bottom + 1) {
throw new Error("limits/home panel should remain inside diagnostics panel");
}
if (legendRect.top < canvasRect.top || legendRect.bottom < canvasRect.bottom) {
throw new Error("preview legend should sit below the Three.js canvas");
}
assertCanvasPixel(canvas);
if (!doc.querySelector("[data-tool-table-rows]") || !doc.querySelector("[data-limits-home-rows]") || !doc.querySelector("[data-status-history]")) {
throw new Error("AXIS screenshot smoke missing compact panels");
}
const artifact = frame.contentWindow.linuxCncRealSimulationApi?.exportDiagnosticsArtifact?.();
if (artifact?.apiName !== "real-browser-simulation-diagnostics-artifact") {
throw new Error("AXIS screenshot smoke missing diagnostics artifact");
}
if (
artifact.preview?.renderer !== "threejs" ||
artifact.preview?.three?.pathPoints <= 0 ||
artifact.preview?.three?.toolMarkerObjects < 3 ||
artifact.preview?.three?.scaleBarObjects !== 4 ||
artifact.preview?.three?.orientationTriadObjects !== 6 ||
!artifact.preview?.three?.toolGeometry ||
!artifact.preview?.three?.scaleBar ||
!artifact.preview?.three?.orientationTriad
) {
throw new Error(`AXIS screenshot smoke diagnostics missing Three.js preview state: ${JSON.stringify(artifact.preview)}`);
}
artifact.viewport = viewport;
artifactNode.textContent = JSON.stringify(artifact);
status.textContent = `${okStatusPrefix} ${viewport.width}x${viewport.height}`;
} catch (error) {
status.textContent = `${failStatusPrefix} ${error.stack || error.message}`;
throw error;
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
#!/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"
ARTIFACT_DIR="${AXIS_SCREENSHOT_ARTIFACT_DIR:-}"
FULL_DIAGNOSTICS="${AXIS_SCREENSHOT_FULL_DIAGNOSTICS:-0}"
if [[ -n "$ARTIFACT_DIR" ]]; then
mkdir -p "$ARTIFACT_DIR"
fi
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 "AXIS screenshot browser smoke HTTP server did not start" >&2
cat "$SERVER_LOG" >&2 || true
exit 1
fi
PORT="$(cat "$PORT_FILE")"
SMOKE_URL="http://127.0.0.1:$PORT/tests/browser/real_simulation_page_smoke.html"
SCREENSHOT_URL="http://127.0.0.1:$PORT/runtime/ui/simulation/index.html"
run_case() {
local name="$1"
local size="$2"
local query="${3:-}"
local out="$TMP_DIR/axis-${name}.dom"
local shot="$TMP_DIR/axis-${name}.png"
local diagnostics_json="$TMP_DIR/axis-${name}-diagnostics.json"
local full_diagnostics_json="$TMP_DIR/axis-${name}-full-diagnostics.json"
local screenshot_url="$SCREENSHOT_URL$query"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--virtual-time-budget=10000 \
--dump-dom \
"$SMOKE_URL" >"$out" 2>&1
if ! grep -Fq "browser_real_simulation_page_smoke=ok" "$out"; then
echo "AXIS screenshot prerequisite smoke failed for $name" >&2
sed -n '1,260p' "$out" >&2
exit 1
fi
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--user-data-dir="$CHROME_PROFILE" \
--window-size="$size" \
--virtual-time-budget=60000 \
--screenshot="$shot" \
"$screenshot_url" \
>/dev/null 2>&1
if [[ ! -s "$shot" ]]; then
echo "AXIS screenshot output missing for $name" >&2
exit 1
fi
local bytes
bytes="$(wc -c <"$shot")"
if [[ "$bytes" -lt 10000 ]]; then
echo "AXIS screenshot output too small for $name: $bytes bytes" >&2
exit 1
fi
if [[ "$FULL_DIAGNOSTICS" == "1" ]]; then
local cdp_profile="$TMP_DIR/cdp-${name}"
mkdir -p "$cdp_profile"
"$CHROMIUM" \
--headless=new \
--disable-gpu \
--no-sandbox \
--remote-debugging-port=0 \
--user-data-dir="$cdp_profile" \
--window-size="$size" \
"about:blank" \
>/dev/null 2>&1 &
local cdp_pid=$!
node - "$cdp_profile" "$full_diagnostics_json" "$screenshot_url" <<'NODE'
const [profile, targetPath, targetUrl] = process.argv.slice(2);
const fs = await import("node:fs/promises");
const path = await import("node:path");
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function readDevToolsPort() {
const portFile = path.resolve(profile, "DevToolsActivePort");
for (let i = 0; i < 200; i += 1) {
try {
const [port] = (await fs.readFile(portFile, "utf8")).trim().split(/\r?\n/);
if (port) {
return port;
}
} catch {
// Wait for Chromium to publish the DevTools endpoint.
}
await sleep(50);
}
throw new Error("missing DevToolsActivePort");
}
function call(ws, id, method, params = {}) {
ws.send(JSON.stringify({ id, method, params }));
}
function sendCommand(ws, pending, id, method, params = {}, timeoutMs = 15000) {
call(ws, id, method, params);
return Promise.race([
new Promise((resolve) => pending.set(id, resolve)),
new Promise((_, reject) => setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs)),
]).finally(() => pending.delete(id));
}
async function evaluateArtifact(webSocketDebuggerUrl) {
const ws = new WebSocket(webSocketDebuggerUrl);
let nextId = 1;
const pending = new Map();
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const waiter = pending.get(message.id);
if (waiter) {
pending.delete(message.id);
waiter(message);
}
});
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, { once: true });
ws.addEventListener("error", reject, { once: true });
});
await sendCommand(ws, pending, nextId++, "Page.enable");
await sendCommand(ws, pending, nextId++, "Runtime.enable");
await sendCommand(ws, pending, nextId++, "Page.navigate", { url: targetUrl });
await sleep(500);
const expression = `(
async () => {
for (let i = 0; i < 600; i += 1) {
const api = window.linuxCncRealSimulationApi;
const state = window.linuxCncRealSimulationState;
if (api?.exportDiagnosticsArtifact && state?.summary?.ready) {
const artifact = api.exportDiagnosticsArtifact();
artifact.viewport = { width: window.innerWidth, height: window.innerHeight };
return JSON.stringify(artifact);
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("AXIS diagnostics API did not become ready");
}
)()`;
let lastError = null;
for (let attempt = 0; attempt < 30; attempt += 1) {
const id = nextId;
nextId += 1;
const response = await sendCommand(ws, pending, id, "Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
}, 35000);
if (!response.error && !response.result.exceptionDetails) {
ws.close();
return response.result.result.value;
}
lastError = response.error?.message ?? response.result.exceptionDetails?.text ?? "Runtime.evaluate failed";
if (!/context|destroyed|navigate|ready/i.test(lastError)) {
break;
}
await sleep(250);
}
ws.close();
throw new Error(lastError ?? "Runtime.evaluate failed");
}
const port = await readDevToolsPort();
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const page = pages.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl);
if (!page) {
throw new Error("missing Chromium page target");
}
const artifactText = await evaluateArtifact(page.webSocketDebuggerUrl);
const artifact = JSON.parse(artifactText);
if (artifact.apiName !== "real-browser-simulation-diagnostics-artifact") {
throw new Error(`unexpected diagnostics artifact ${artifact.apiName}`);
}
if (artifact.preview?.renderer !== "threejs" || Number(artifact.preview?.three?.pathPoints ?? 0) <= 0) {
throw new Error("diagnostics artifact missing Three.js preview state");
}
await fs.writeFile(targetPath, `${JSON.stringify(artifact, null, 2)}\n`);
NODE
local cdp_status=$?
kill "$cdp_pid" 2>/dev/null || true
wait "$cdp_pid" 2>/dev/null || true
if [[ "$cdp_status" -ne 0 || ! -s "$full_diagnostics_json" ]]; then
echo "AXIS full diagnostics artifact missing for $name" >&2
exit 1
fi
fi
python3 - "$diagnostics_json" "$full_diagnostics_json" "$name" "$size" "$bytes" "$query" <<'PY'
import json
import pathlib
import sys
target, full_diagnostics, name, size, bytes_count, query = sys.argv[1:7]
target_path = pathlib.Path(target)
screenshot_path = target_path.with_name(target_path.name.replace("-diagnostics.json", ".png"))
full_diagnostics_path = pathlib.Path(full_diagnostics)
full_artifact = json.loads(full_diagnostics_path.read_text(encoding="utf-8")) if full_diagnostics_path.exists() else {}
pathlib.Path(target).write_text(json.dumps({
"apiName": "real-browser-simulation-axis-screenshot-artifact",
"artifactVersion": 1,
"viewportName": name,
"windowSize": size,
"query": query,
"screenshotBytes": int(bytes_count),
"validatedBy": "browser_real_simulation_page_smoke",
"fullDiagnosticsApiName": full_artifact.get("apiName"),
"fullDiagnosticsPath": str(full_diagnostics_path) if full_artifact else None,
"previewRenderer": full_artifact.get("preview", {}).get("renderer"),
"previewViewMode": full_artifact.get("preview", {}).get("viewMode"),
"threeReady": full_artifact.get("preview", {}).get("three", {}).get("ready") is True,
"threePathPoints": full_artifact.get("preview", {}).get("three", {}).get("pathPoints"),
"screenshotPath": str(screenshot_path),
"diagnosticsPath": str(target_path),
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
if [[ ! -s "$diagnostics_json" ]]; then
echo "AXIS diagnostics artifact missing for $name" >&2
exit 1
fi
if [[ -n "$ARTIFACT_DIR" ]]; then
cp "$shot" "$ARTIFACT_DIR/axis-${name}.png"
if [[ -s "$full_diagnostics_json" ]]; then
cp "$full_diagnostics_json" "$ARTIFACT_DIR/axis-${name}-full-diagnostics.json"
fi
python3 - "$diagnostics_json" "$ARTIFACT_DIR/axis-${name}-diagnostics.json" "$ARTIFACT_DIR/axis-${name}.png" "$ARTIFACT_DIR/axis-${name}-full-diagnostics.json" <<'PY'
import json
import pathlib
import sys
source, target, screenshot, full_diagnostics = sys.argv[1:5]
artifact = json.loads(pathlib.Path(source).read_text(encoding="utf-8"))
artifact["diagnosticsPath"] = str(pathlib.Path(target))
artifact["screenshotPath"] = str(pathlib.Path(screenshot))
artifact["fullDiagnosticsPath"] = str(pathlib.Path(full_diagnostics)) if pathlib.Path(full_diagnostics).exists() else None
pathlib.Path(target).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
fi
}
run_case desktop-preview 1280,900
run_case desktop-dro 1280,900 "?axis_right=dro"
run_case mobile-preview 390,920
run_case mobile-mdi 390,920 "?axis_left=mdi"
echo "axis_screenshot_browser_smoke=ok"

View File

@@ -7,6 +7,8 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const doc = readFileSync(resolve(root, "docs/axis-style-simulation-implementation.md"), "utf8");
const readme = readFileSync(resolve(root, "README.md"), "utf8");
const priority = readFileSync(resolve(root, "docs/real-browser-simulation-priority.md"), "utf8");
const simulationPackage = JSON.parse(readFileSync(resolve(root, "runtime/ui/simulation/package.json"), "utf8"));
const threeVendor = readFileSync(resolve(root, "runtime/ui/simulation/vendor/three/three.core.js"), "utf8");
for (const required of [
"AXIS-Style Browser Simulation Implementation Plan",
@@ -15,7 +17,18 @@ for (const required of [
"Preview and DRO",
"G-code source with active execution line",
"window.linuxCncRealSimulationApi",
"reloadCurrentProgram()",
"getMdiHistory()",
"getRecentPrograms()",
"Three.js `^0.183.2`",
"exportDiagnosticsArtifact()",
"getLimitsHomeState()",
"getAxisStatusbarState()",
"applyAxisViewFromUrl(search)",
"AXIS_SCREENSHOT_ARTIFACT_DIR",
"AXIS_SCREENSHOT_FULL_DIAGNOSTICS=1",
"SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_browser.sh",
"SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_axis_screenshot_browser.sh",
"Immediate Next Batch",
]) {
assert.ok(doc.includes(required), `axis-style simulation doc missing ${required}`);
@@ -35,5 +48,11 @@ assert.ok(
priority.includes("docs/axis-style-simulation-implementation.md"),
"priority doc should link AXIS-style implementation plan",
);
assert.equal(
simulationPackage.dependencies?.three,
"^0.183.2",
"simulation package should pin the requested Three.js dependency range",
);
assert.match(threeVendor, /REVISION = '183'/, "vendored Three.js build should match the requested 0.183.x runtime");
console.log("axis_style_simulation_docs_node_smoke=ok");

View File

@@ -18,6 +18,7 @@ const hostSmokeText = readFileSync(resolve(root, "tests/host/verify_host_smokes.
const releaseGateText = readFileSync(resolve(root, "tests/host/verify_project_release_gate.sh"), "utf8");
const simulationHtmlText = readFileSync(resolve(root, "runtime/ui/simulation/index.html"), "utf8");
const simulationSmokeText = readFileSync(resolve(root, "tests/browser/verify_real_simulation_browser.sh"), "utf8");
const simulationScreenshotSmokeText = readFileSync(resolve(root, "tests/browser/verify_real_simulation_axis_screenshot_browser.sh"), "utf8");
for (const phrase of [
"Real Browser Simulation Priority",
@@ -44,10 +45,17 @@ for (const text of [handoffText, readmeText, trackerText]) {
assert.match(hostSmokeText, /verify_real_browser_simulation_priority_docs\.sh/);
assert.match(hostSmokeText, /verify_real_simulation_browser\.sh/);
assert.match(hostSmokeText, /verify_real_simulation_axis_screenshot_browser\.sh/);
assert.match(releaseGateText, /verify_real_simulation_browser\.sh/);
assert.match(releaseGateText, /verify_real_simulation_axis_screenshot_browser\.sh/);
assert.match(simulationHtmlText, /LinuxCNC Browser Simulation/);
assert.match(simulationHtmlText, /data-toolpath-svg/);
assert.match(simulationHtmlText, /data-toolpath-three/);
assert.match(simulationHtmlText, /data-program-lines/);
assert.match(simulationSmokeText, /browser_real_simulation_page_smoke=ok/);
assert.match(simulationScreenshotSmokeText, /axis_screenshot_browser_smoke=ok/);
assert.match(simulationScreenshotSmokeText, /--screenshot=/);
assert.match(simulationScreenshotSmokeText, /AXIS_SCREENSHOT_ARTIFACT_DIR/);
assert.match(simulationScreenshotSmokeText, /axis-\$\{name\}-diagnostics\.json/);
console.log("real_browser_simulation_priority_docs_node_smoke=ok");

View File

@@ -23,6 +23,7 @@ SKIP_TP_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_tp_wasm.sh"
"$ROOT_DIR/tests/ui/node/verify_ui_node_smokes.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_ini_panel_browser.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_real_simulation_browser.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_real_simulation_axis_screenshot_browser.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_opfs_session_workflow_browser.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_interp_browser.sh"

View File

@@ -13,6 +13,7 @@ SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_interp_wasm.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_sim_configs_inventory_wasm.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_ini_panel_browser.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_real_simulation_browser.sh"
SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_real_simulation_axis_screenshot_browser.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_opfs_session_workflow_browser.sh"
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/browser/verify_release_artifact_url_workflow_browser.sh"
"$ROOT_DIR/tests/sdk/node/verify_project_batch_acceptance_workflow.sh"

View File

@@ -27,6 +27,8 @@ assert.equal(validation.gateExecutionManifestReady, true);
assert.equal(validation.gateExecutionSummaryReady, true);
assert.equal(validation.gateResultMatrixReady, true);
assert.equal(validation.gateActionPlanReady, true);
assert.equal(validation.axisScreenshotArtifactSummaryReady, true);
assert.ok(validation.axisScreenshotArtifactCount === 0 || validation.axisScreenshotArtifactCount === 4);
assert.deepEqual(validation.expectedGateIds, [
"diff-check",
"vendor-sync",
@@ -50,5 +52,11 @@ assert.equal(artifact.gateResultMatrix.apiName, "project-release-gate-result-mat
assert.equal(artifact.gateActionPlan.apiName, "project-release-gate-action-plan");
assert.equal(artifact.gateActionPlan.pendingCount, 0);
assert.equal(artifact.gateActionPlan.nextCommand, null);
assert.equal(artifact.axisScreenshotArtifactSummary.apiName, "project-release-axis-screenshot-artifact-summary");
if (artifact.axisScreenshotArtifactSummary.artifactCount > 0) {
assert.equal(artifact.axisScreenshotArtifactSummary.artifactCount, 4);
assert.equal(artifact.axisScreenshotArtifactSummary.ready, true);
assert.deepEqual(artifact.axisScreenshotArtifactSummary.missingViewports, []);
}
console.log("project_release_readiness_artifact_node_smoke=ok");

View File

@@ -1,4 +1,4 @@
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -7,6 +7,9 @@ import { createProjectReleaseReadinessReport } from "../../runtime/sdk/src/index
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "../..");
const outputPath = process.argv[2] ?? resolve(root, "build/project-release-readiness.json");
const axisScreenshotArtifactDir = process.env.AXIS_SCREENSHOT_ARTIFACT_DIR
? resolve(process.env.AXIS_SCREENSHOT_ARTIFACT_DIR)
: null;
const passedGateResults = {
"diff-check": true,
@@ -23,6 +26,25 @@ const passedGateResults = {
"project-release-gate": true,
};
function loadAxisScreenshotArtifacts(directory) {
if (!directory || !existsSync(directory)) {
return [];
}
return readdirSync(directory)
.filter((filename) => /^axis-.+-diagnostics\.json$/.test(filename))
.sort()
.map((filename) => {
const diagnosticsPath = resolve(directory, filename);
const artifact = JSON.parse(readFileSync(diagnosticsPath, "utf8"));
const screenshotPath = resolve(directory, filename.replace(/-diagnostics\.json$/, ".png"));
return {
...artifact,
diagnosticsPath,
screenshotPath,
};
});
}
const report = createProjectReleaseReadinessReport({
gateResults: passedGateResults,
observedOutputs: [
@@ -38,6 +60,7 @@ const report = createProjectReleaseReadinessReport({
"host_wasm_opfs_browser_smokes=ok",
"project_release_gate=ok",
],
axisScreenshotArtifacts: loadAxisScreenshotArtifacts(axisScreenshotArtifactDir),
});
mkdirSync(dirname(outputPath), { recursive: true });

View File

@@ -248,6 +248,11 @@ assert.deepEqual(createProjectReleaseReadinessSummaryViewModel(report), {
label: "Blocked runtime families",
value: "L4-USER-M-PROCESS, L4-TOOL-DB, L4-PYTHON-REMAP",
},
{
id: "axis-screenshot-artifacts",
label: "AXIS screenshot artifacts",
value: "not captured",
},
{
id: "missing",
label: "Missing readiness",

View File

@@ -545,6 +545,37 @@ assert.equal(createProjectReleaseGateActionPlan({
gateResults: Object.fromEntries(createProjectReleaseGateManifest().gateIds.map((id) => [id, true])),
}).shellScript, "# Project release gates are already marked passed.\n");
assert.equal(createProjectReleaseReadinessSummaryViewModel(releaseReadinessReady).statusLine, "Ready: No blocking reasons");
assert.equal(
createProjectReleaseReadinessSummaryViewModel(releaseReadinessReady).rows.find(({ id }) => id === "axis-screenshot-artifacts")?.value,
"not captured",
);
const releaseReadinessWithScreenshots = createProjectReleaseReadinessReport({
gateResults: Object.fromEntries(createProjectReleaseGateManifest().gateIds.map((id) => [id, true])),
axisScreenshotArtifacts: ["desktop-preview", "desktop-dro", "mobile-preview", "mobile-mdi"].map((viewportName) => ({
apiName: "real-browser-simulation-axis-screenshot-artifact",
artifactVersion: 1,
viewportName,
windowSize: viewportName.startsWith("mobile") ? "390,920" : "1280,900",
query: viewportName.endsWith("dro") ? "?axis_right=dro" : "",
screenshotBytes: 20000,
validatedBy: "browser_real_simulation_page_smoke",
fullDiagnosticsApiName: "real-browser-simulation-diagnostics-artifact",
fullDiagnosticsPath: `/tmp/${viewportName}-full.json`,
previewRenderer: "threejs",
previewViewMode: viewportName.endsWith("dro") ? "top" : "top",
threeReady: true,
threePathPoints: 3,
screenshotPath: `/tmp/${viewportName}.png`,
diagnosticsPath: `/tmp/${viewportName}.json`,
})),
});
assert.equal(releaseReadinessWithScreenshots.ready, true);
assert.equal(releaseReadinessWithScreenshots.axisScreenshotArtifactSummary.ready, true);
assert.equal(releaseReadinessWithScreenshots.axisScreenshotArtifactSummary.artifactCount, 4);
assert.equal(
createProjectReleaseReadinessSummaryViewModel(releaseReadinessWithScreenshots).rows.find(({ id }) => id === "axis-screenshot-artifacts")?.value,
"4/4 captured",
);
assert.equal(
createProjectReleaseGateResultMatrix({
observedOutputs: ["project_release_gate=ok"],
@@ -572,6 +603,8 @@ assert.deepEqual(releaseReadinessArtifactValidation, {
gateExecutionSummaryReady: true,
gateResultMatrixReady: true,
gateActionPlanReady: true,
axisScreenshotArtifactSummaryReady: true,
axisScreenshotArtifactCount: 0,
blockedRuntimeFamilies: [
"L4-USER-M-PROCESS",
"L4-TOOL-DB",
@@ -613,6 +646,11 @@ assert.deepEqual(releaseReadinessArtifactValidation, {
label: "Gate action plan",
value: "ready",
},
{
id: "axis-screenshot-artifacts",
label: "AXIS screenshot artifacts",
value: "not captured",
},
{
id: "validation",
label: "Artifact validation",

View File

@@ -1655,7 +1655,7 @@ const releaseArtifactMountResult = mountIniPanelShellWorkflowOverviewReleaseRead
rowsNode: releaseArtifactRowsNode,
});
assert.equal(releaseArtifactRenderResult.rendered, true);
assert.equal(releaseArtifactRenderResult.rowCount, 8);
assert.equal(releaseArtifactRenderResult.rowCount, 9);
assert.equal(releaseArtifactRenderResult.dataset.handoffScope, "workflow-overview-release-readiness-artifact");
assert.equal(
releaseArtifactRenderResult.rowIds.includes("gate-execution-manifest"),
@@ -1669,8 +1669,12 @@ assert.equal(
releaseArtifactRenderResult.rowIds.includes("gate-action-plan"),
true,
);
assert.equal(
releaseArtifactRenderResult.rowIds.includes("axis-screenshot-artifacts"),
true,
);
assert.equal(releaseArtifactMountResult.ready, true);
assert.equal(releaseArtifactMountResult.renderResult.rowCount, 8);
assert.equal(releaseArtifactMountResult.renderResult.rowCount, 9);
assert.deepEqual(
validateIniPanelShellWorkflowOverviewReleaseReadinessArtifactJson("{").missing,
["artifact-json"],
@@ -1711,7 +1715,7 @@ assert.equal(
);
assert.equal(releaseArtifactUrlWorkflow.renderState.statusLine, "Ready: No blocking reasons");
assert.equal(releaseArtifactUrlWorkflow.mountResult.ready, true);
assert.equal(releaseArtifactUrlWorkflow.mountResult.renderResult.rowCount, 8);
assert.equal(releaseArtifactUrlWorkflow.mountResult.renderResult.rowCount, 9);
const releaseArtifactUrlWorkflowSummary =
createIniPanelShellWorkflowOverviewReleaseReadinessArtifactUrlWorkflowSummaryViewModel(
releaseArtifactUrlWorkflow,

View File

@@ -7,12 +7,19 @@ import {
DEFAULT_SIMULATION_PROGRAM,
DEFAULT_SIMULATION_PROGRAM_ID,
createPlaybackFrame,
createDroState,
createMachineStatusState,
createModalState,
createSimulationSummary,
createToolpathPolylinePoints,
createToolpathViewBox,
getSimulationTestProgram,
getSimulationTestPrograms,
panToolpathViewBox,
parseLinuxCncCanonicalMotion,
runLinuxCncProgram,
runRealBrowserSimulation,
zoomToolpathViewBox,
} from "../../../runtime/ui/simulation/simulation-app.js";
import {
SIMULATION_TEST_PROGRAMS as directoryPrograms,
@@ -64,16 +71,59 @@ const arcCanonical = [
"canon_event=ARC_FEED line=4 first_end=0 second_end=0 first_axis=1 second_axis=0 rotation=1 axis_end_point=0 a=0 b=0 c=0 u=0 v=0 w=0",
].join("\n");
const arcMotion = parseLinuxCncCanonicalMotion(arcCanonical, arcProgram.text);
const arcModal = createModalState(arcCanonical);
const unavailableMachineStatus = createMachineStatusState(arcCanonical);
assert.equal(arcMotion.length, 3);
assert.equal(arcMotion[1].type, "ARC_FEED");
assert.equal(arcMotion[1].statement, "G2 X1 Y1 I1 J0 F80");
assert.deepEqual(arcMotion[1].arc.planeAxes, ["x", "y", "z"]);
assert.equal(arcMotion[1].arc.center.x, 1);
assert.equal(arcMotion[1].arc.center.y, 0);
assert.equal(arcMotion[1].arc.rotation, -1);
assert.deepEqual(
{ x: arcMotion[1].axes.x, y: arcMotion[1].axes.y, z: arcMotion[1].axes.z },
{ x: 1, y: 1, z: 0 },
"arc parser should map LinuxCNC canonical arc endpoints onto active-plane axes",
);
assert.equal(createToolpathPolylinePoints(arcMotion), "0,0 1,-1 0,0");
assert.equal(arcModal.apiName, "real-browser-simulation-modal-state");
assert.equal(arcModal.source, "linuxcnc-update-tag");
assert.ok(arcModal.raw.includes("canon_event=UPDATE_TAG line=1"));
assert.ok(arcModal.rows.some(({ id, value, source }) => id === "plane" && value === "plane=170" && source === "linuxcnc-update-tag"));
assert.equal(unavailableMachineStatus.spindle.state, "n/a");
assert.equal(unavailableMachineStatus.coolant.mist, "n/a");
const runtimeMachineStatus = createMachineStatusState([
"canon_event=SET_SPINDLE_SPEED spindle=0 speed=1200",
"canon_event=START_SPINDLE_CLOCKWISE spindle=0 wait_for_at_speed=1",
"canon_event=STOP_SPINDLE_TURNING spindle=0",
"canon_event=MIST_ON",
"canon_event=FLOOD_ON",
"canon_event=MIST_OFF",
"canon_event=FLOOD_OFF",
"canon_event=DISABLE_FEED_OVERRIDE",
"canon_event=ENABLE_FEED_OVERRIDE",
"canon_event=DISABLE_SPEED_OVERRIDE spindle=0",
"canon_event=ENABLE_SPEED_OVERRIDE spindle=0",
].join("\n"));
assert.equal(runtimeMachineStatus.spindle.state, "off");
assert.equal(runtimeMachineStatus.spindle.direction, "cw");
assert.equal(runtimeMachineStatus.spindle.speed, "1200");
assert.equal(runtimeMachineStatus.coolant.mist, "off");
assert.equal(runtimeMachineStatus.coolant.flood, "off");
assert.equal(runtimeMachineStatus.overrides.feed, "enabled");
assert.equal(runtimeMachineStatus.overrides.speed, "enabled");
const toolMachineStatus = createMachineStatusState([
"canon_event=SELECT_TOOL tool=2",
"canon_event=USE_TOOL_LENGTH_OFFSET x=0 y=0 z=1.25 a=0 b=0 c=0 u=0 v=0 w=0",
"canon_event=CHANGE_TOOL_NUMBER pocket=2",
].join("\n"));
assert.equal(toolMachineStatus.tool.selected, "2");
assert.equal(toolMachineStatus.tool.current, "2");
assert.equal(toolMachineStatus.tool.pocket, "2");
assert.equal(toolMachineStatus.tool.lengthOffset, "z=1.250");
const xzArcCanonical = [
"canon_event=UPDATE_TAG line=1 g0=-1 motion=0 plane=180 origin=530 feed=0 speed=0 flags=1048994",
@@ -101,12 +151,20 @@ const playbackState = {
motion: arcMotion,
};
const firstFrame = createPlaybackFrame(playbackState, 0);
const firstDro = createDroState(firstFrame);
assert.equal(firstFrame.apiName, "real-browser-simulation-playback-frame");
assert.equal(firstFrame.index, 0);
assert.equal(firstFrame.step, 1);
assert.equal(firstFrame.total, 3);
assert.equal(firstFrame.activeLine, 1);
assert.equal(firstFrame.visibleMotion.length, 1);
assert.equal(firstDro.apiName, "real-browser-simulation-dro-state");
assert.equal(firstDro.actual.x, "0.000");
assert.equal(firstDro.distanceToGo.x, "n/a");
assert.equal(firstDro.workOffsetG54.x, "n/a");
assert.equal(firstDro.g92Offset.x, "n/a");
assert.equal(firstDro.toolLengthOffset.x, "n/a");
assert.equal(firstDro.velocity, "n/a");
const arcFrame = createPlaybackFrame(playbackState, 1);
assert.equal(arcFrame.index, 1);
@@ -120,6 +178,34 @@ const clampedFrame = createPlaybackFrame(playbackState, 99);
assert.equal(clampedFrame.index, 2);
assert.equal(clampedFrame.progress, 100);
const fitViewBox = createToolpathViewBox(arcMotion);
const zoomedViewBox = zoomToolpathViewBox(fitViewBox, 2);
assert.ok(zoomedViewBox.width < fitViewBox.width, "zoomed toolpath viewBox should narrow around the fit center");
assert.ok(zoomedViewBox.height < fitViewBox.height, "zoomed toolpath viewBox should narrow around the fit center");
const pannedViewBox = panToolpathViewBox(zoomedViewBox, { x: 0.5, y: -0.25 });
assert.equal(pannedViewBox.minX, zoomedViewBox.minX + 0.5);
assert.equal(pannedViewBox.minY, zoomedViewBox.minY - 0.25);
assert.equal(pannedViewBox.width, zoomedViewBox.width);
assert.equal(pannedViewBox.height, zoomedViewBox.height);
const interpCalls = [];
const fakeInterp = {
runProgram(programText) {
interpCalls.push(["runProgram", programText]);
return arcCanonical;
},
runProgramWithIni(programText, iniPath) {
interpCalls.push(["runProgramWithIni", programText, iniPath]);
return arcCanonical;
},
};
assert.equal(runLinuxCncProgram(fakeInterp, arcProgram.text), arcCanonical);
assert.equal(runLinuxCncProgram(fakeInterp, arcProgram.text, { iniPath: "/work/axis.ini" }), arcCanonical);
assert.deepEqual(interpCalls, [
["runProgram", arcProgram.text],
["runProgramWithIni", arcProgram.text, "/work/axis.ini"],
]);
const customRunDocument = {
body: { dataset: {} },
querySelector: () => null,
@@ -133,5 +219,22 @@ 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);
assert.equal(customRun.modal.apiName, "real-browser-simulation-modal-state");
const sessionRunCalls = [];
const sessionRun = await runRealBrowserSimulation({
documentRef: customRunDocument,
programText: arcProgram.text,
interp: {
runProgramWithIni(programText, iniPath) {
sessionRunCalls.push([programText, iniPath]);
return arcCanonical;
},
},
iniPath: "/work/session.ini",
});
assert.equal(sessionRun.execution.mode, "linuxcnc-wasm-with-ini");
assert.equal(sessionRun.execution.iniPath, "/work/session.ini");
assert.deepEqual(sessionRunCalls, [[arcProgram.text, "/work/session.ini"]]);
console.log("real_simulation_programs_node_smoke=ok");