Show live LinuxCNC G-code execution progress

This commit is contained in:
2026-08-29 07:03:43 +08:00
parent 24d4930803
commit 74ae2538a3
5 changed files with 32 additions and 7 deletions

View File

@@ -397,8 +397,8 @@ uint32_t sim_program_canonical(const sim_program *program, char *output, uint32_
int written;
if (program == NULL) return 0;
written = snprintf(destination, destination_capacity,
"{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_mode\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"interpreter_state_hash\":\"%llu\"}",
program->call_depth, program->control_code, program->current_line, program->error_line, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->interpreter.state.motion_mode, program->rapid_velocity, program->status, program->subroutine_count, program->source_length,
"{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"feed\":%.17g,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_mode\":%d,\"plane\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"speed\":%.17g,\"spindle_mode\":%d,\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"units\":%d,\"distance_mode\":%d,\"interpreter_state_hash\":\"%llu\"}",
program->call_depth, program->control_code, program->current_line, program->error_line, program->interpreter.state.feed, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->interpreter.state.motion_mode, program->interpreter.state.plane, program->rapid_velocity, program->interpreter.state.speed, program->interpreter.state.spindle_mode, program->status, program->subroutine_count, program->source_length, program->interpreter.state.units, program->interpreter.state.distance_mode,
(unsigned long long)sim_interpreter_state_hash(&program->interpreter));
return written < 0 ? 0 : (uint32_t)written + 1u;
}

View File

@@ -87,9 +87,10 @@ function DroView({ axes, state, coordType, displayType, metric, setCoordType, se
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = Number(axes[index] ?? 0); const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, onOpenFile, canLoad, canRun, canStep, currentLine, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; onOpenFile: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; diagnostic: string | null }) {
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, onOpenFile, canLoad, canRun, canStep, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; onOpenFile: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) {
const lines = text.split("\n");
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => <span key={index} className={index + 1 === currentLine ? "current-line" : ""} aria-current={index + 1 === currentLine ? "true" : undefined}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
const mainLine = currentLine && currentLine > 0 && currentLine <= lines.length ? currentLine : null;
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => <span key={index} className={index + 1 === mainLine ? "current-line" : ""} aria-current={index + 1 === mainLine ? "true" : undefined}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code>{executionLineText || "-"}</code></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
}
function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) {
@@ -219,6 +220,8 @@ export function App() {
const activeGcodes = state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9";
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
const currentLine = Number(state.program?.line ?? state.task?.current_line ?? 0) || null;
const executionLine = Number(state.program?.execution_line ?? currentLine ?? 0) || null;
const executionLineText = String(state.program?.execution_line_text ?? "");
const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : [];
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
const xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); };
@@ -232,7 +235,7 @@ export function App() {
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<XyzacProgramPane status={String(state.program?.program_status ?? "idle")} currentLine={currentLine} sourceHash={String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: PROGRAM_PATH }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={currentLine} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<XyzacProgramPane status={String(state.program?.program_status ?? "idle")} currentLine={executionLine} sourceHash={String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: PROGRAM_PATH }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={currentLine} executionLine={executionLine} executionLineText={executionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
</main>;

View File

@@ -183,6 +183,9 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.program-gutter span { height: 17px; }
.program-gutter span.current-line { color: #174d84; background: #c8d7e8; font-weight: 700; }
.program-editor-wrap textarea { width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; resize: none; border: 0; outline: none; background: #fff; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; }
.program-execution-line { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: baseline; gap: 10px; min-width: 0; padding: 5px 8px; border-top: 1px solid #9aa8b4; border-bottom: 1px solid #9aa8b4; background: #e9f1f8; color: #173b5f; font: 12px/1.35 ui-monospace, monospace; }
.program-execution-line span { font-weight: 700; white-space: nowrap; }
.program-execution-line code { min-width: 0; overflow: hidden; color: #111; text-overflow: ellipsis; white-space: nowrap; }
.program-side { display: flex; flex-direction: column; gap: 12px; padding: 8px; border-left: 1px solid #aaa; background: #d9d9d9; }
.program-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.active-gcodes-label { display: grid; gap: 5px; color: #333; }

View File

@@ -161,9 +161,12 @@ export class SimSession {
loaded: program.loaded,
current_line: program.current_line,
line: program.line,
execution_line: program.execution_line,
execution_line_text: program.execution_line_text,
path: program.path,
source_hash: program.source_hash,
state_hash: program.state_hash,
active_gcodes: program.active_gcodes,
} : null;
const trace = Array.isArray(statePayload.motion_trace) ? statePayload.motion_trace : [];
const motionSample = motionDelta ? (trace.at(-1) ?? {

View File

@@ -15,6 +15,16 @@ function programError(errorPrefix, result) {
return { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason, message_key: reason, source: "linuxcnc-wasm-sim_program" };
}
function modalCode(prefix, value, fallback) { return `${prefix}${Number.isFinite(Number(value)) ? Number(value) : fallback}`; }
function activeGcodes(state) {
const motion = { 0: "G0", 1: "G1", 2: "G2", 3: "G3" }[state.motion_mode] ?? "G1";
const spindle = { 3: "M3", 4: "M4", 5: "M5" }[state.spindle_mode] ?? `M${state.spindle_mode ?? 5}`;
const feed = Number(state.feed ?? 0);
const speed = Number(state.speed ?? 0);
return [state.units === 20 ? "G20" : "G21", state.distance_mode === 91 ? "G91" : "G90", modalCode("G", state.plane, 17), motion, spindle, `F${Number.isFinite(feed) ? feed : 0}`, `S${Number.isFinite(speed) ? speed : 0}`].join(" ");
}
function call(core, name, returnType, argTypes, args) {
const fn = core?.[`_${name}`] ?? core?.[name];
if (typeof fn === "function") return fn(...args);
@@ -64,6 +74,7 @@ export class WasmProgramRuntime {
this.source = "";
this.path = null;
this.subroutines = [];
this.executionSource = "";
this.rapidVelocity = 35;
if (!this.available || typeof core?._malloc !== "function") return;
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
@@ -94,6 +105,7 @@ export class WasmProgramRuntime {
this.source = source;
this.path = path;
this.subroutines = [];
this.executionSource = source;
return { status: "verified", program: this.snapshot() };
} finally { this.core._free(pointer); }
}
@@ -154,6 +166,8 @@ export class WasmProgramRuntime {
const code = call(this.core, "sim_program_add_subroutine", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]);
if (code !== 0) return { status: "error", code: `wasm_program_add_subroutine_${code}`, result: code };
this.subroutines.push({ name: String(name ?? "subroutine"), source });
if (this.executionSource && !this.executionSource.endsWith("\n")) this.executionSource += "\n";
this.executionSource += source;
return { status: "verified", command: "sim_program_add_subroutine", name: String(name ?? "subroutine") };
} finally { this.core._free(pointer); }
}
@@ -247,9 +261,11 @@ export class WasmProgramRuntime {
const state = JSON.parse(readString(this.core, buffer, required));
const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]);
const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle";
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
const executionLines = this.executionSource ? this.executionSource.split(/\r?\n/) : [];
const executionLineText = state.current_line > 0 ? (executionLines[state.current_line - 1] ?? "").trim() : "";
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
} finally { this.core._free(buffer); }
}
destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; }
destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; this.executionSource = ""; }
}