Align AXIS Web control lifecycle with LinuxCNC

This commit is contained in:
2026-09-07 13:47:10 +08:00
parent 35adac6471
commit 0a679b5eeb
34 changed files with 2598 additions and 177 deletions

View File

@@ -12,59 +12,58 @@ import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac
export const SELECTED_XYZAC_CASE = XYZAC_CASE;
const DEFAULT_CASE = SELECTED_XYZAC_CASE;
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const DEFAULT_PROGRAM = "";
const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Button-to-command sequencing is defined in
// docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects
// Worker gates and emits the documented operation.
type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void;
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean; feature?: string };
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; disabledReason?: string; danger?: boolean; pressed?: boolean; feature?: string };
function IconButton({ label, asset, onClick, disabled = false, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) {
return <button type="button" data-axis-feature={feature} className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
function IconButton({ label, asset, onClick, disabled = false, disabledReason, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) {
return <button type="button" data-axis-feature={feature} className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={disabled && disabledReason ? disabledReason : label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
}
function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler<HTMLButtonElement>; onPointerUp?: React.PointerEventHandler<HTMLButtonElement>; onPointerCancel?: React.PointerEventHandler<HTMLButtonElement>; onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>; ariaLabel?: string; disabled?: boolean; danger?: boolean; feature?: string }) {
return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeave} aria-label={ariaLabel} disabled={disabled}>{children}</button>;
function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, disabledReason, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler<HTMLButtonElement>; onPointerUp?: React.PointerEventHandler<HTMLButtonElement>; onPointerCancel?: React.PointerEventHandler<HTMLButtonElement>; onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>; ariaLabel?: string; disabled?: boolean; disabledReason?: string; danger?: boolean; feature?: string }) {
return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeave} aria-label={ariaLabel} disabled={disabled} title={disabled && disabledReason ? disabledReason : undefined}>{children}</button>;
}
function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; }
function Menu({ label, children }: { label: string; children: React.ReactNode }) {
// AXIS defines Machine/View menu bindings, but this Web surface already
// exposes the same commands through the toolbar and notebook tabs. Keep one
// visible entry point per action while retaining the source command flow.
if (label === "Machine" || label === "View") return null;
return <details className="axis-menu-item" data-axis-feature="AXIS-UI-02"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
}
function MenuAction({ children, onClick, shortcut, disabled = false }: { children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean }) {
return <button type="button" onClick={onClick} disabled={disabled}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
}
function MenuAction({ children, onClick, shortcut, disabled = false }: { children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean }) { return <button type="button" onClick={onClick} disabled={disabled}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>; }
function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) {
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
}
function MachineSequence({ wasmReady, isEstop, machineOn, allHomed, programLoaded, programRunning, programLoading, canPower, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { wasmReady: boolean; isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; programLoading: boolean; canPower: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) {
function MachineSequence({ wasmReady, allHomed, canHome, onHomeAll }: { wasmReady: boolean; allHomed: boolean; canHome: boolean; onHomeAll: () => void }) {
return <section className="machine-sequence" data-axis-feature="AXIS-MACHINE-SEQUENCE" aria-label="Machine sequence">
<div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{wasmReady ? (isEstop ? "ESTOP" : machineOn ? "ON" : "OFF") : "WASM PENDING"} / {wasmReady ? (allHomed ? "HOME ALL" : "NOT HOMED") : "WASM PENDING"}</span></div>
<div className="machine-sequence-actions" role="group" aria-label="Emergency stop, power, homing and program controls">
<TextButton danger disabled={isEstop} onClick={onEstop} ariaLabel="Emergency stop">Emergency Stop</TextButton>
<TextButton disabled={!isEstop} onClick={onRelease} ariaLabel="Release emergency stop">Release E-stop</TextButton>
<TextButton disabled={!canPower} onClick={onPower} ariaLabel={machineOn ? "Power off" : "Power on"}>{machineOn ? "Power Off" : "Power On"}</TextButton>
<TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel="Home all axes">HOME ALL</TextButton>
<TextButton disabled={!canRun || programLoading} onClick={onRun} ariaLabel="Run program">Run Program</TextButton>
<TextButton feature="AXIS-MACHINE-SEQUENCE" disabled={!programLoaded || !canRun || programRunning || programLoading} onClick={onSequence} ariaLabel="Run startup sequence">Run Startup Sequence</TextButton>
<div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{wasmReady ? (allHomed ? "HOMED" : "NOT HOMED") : "WASM PENDING"}</span></div>
<div className="machine-sequence-actions" role="group" aria-label="Homing controls">
<TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel={allHomed ? "Re-home all axes" : "Home all axes"}>{allHomed ? "RE-HOME ALL" : "HOME ALL"}</TextButton>
</div>
</section>;
}
function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTool, canProbe, canTouchOff, canToolTouchOff, onHomeAll, selectedAxis, setSelectedAxis, spindleOn, spindleBrake, coolant, onSpindle, onCoolant, jogScale, onJogScale, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTool: boolean; canProbe: boolean; canTouchOff: boolean; canToolTouchOff: boolean; onHomeAll: () => void; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; jogScale: number | null; onJogScale: (event: React.ChangeEvent<HTMLInputElement>) => void; devices: Record<string, any> }) {
function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTool, canProbe, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, jogScale, onJogScale, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTool: boolean; canProbe: boolean; canTouchOff: boolean; canToolTouchOff: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleDirection: number; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; onHomeAxis: () => void; onTouchOff: () => void; onToolTouchOff: () => void; jogScale: number | null; onJogScale: (event: React.ChangeEvent<HTMLInputElement>) => void; devices: Record<string, any> }) {
const [increment, setIncrement] = useState("Continuous");
const [jogging, setJogging] = useState(false);
const jog = (direction: number) => { setJogging(true); const request = action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment) }); if (increment !== "Continuous" && request && typeof (request as Promise<void>).then === "function") void (request as Promise<void>).then(() => action("SIM_STEP", { cycles: 1 })); };
const jog = (direction: number) => { setJogging(true); const continuous = increment === "Continuous"; const request = action("JOG_START", { axis: selectedAxis, direction, increment: continuous ? null : Number(increment), continuous }); if (!continuous && request && typeof (request as Promise<void>).then === "function") void (request as Promise<void>).then(() => action("SIM_STEP", { cycles: 1 })); };
const stopJog = () => { if (!jogging) return; setJogging(false); void action("JOG_STOP"); };
return <section className="manual-control" data-axis-feature="AXIS-MANUAL-01" aria-label="Manual Control"><div className="manual-grid">
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "c"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!canJog} onPointerDown={() => jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>-</TextButton><TextButton disabled={!canJog} onPointerDown={() => jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!canJog} onClick={stopJog}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!canHome} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</TextButton><TextButton disabled={!canHome} onClick={onHomeAll}>Home All</TextButton><TextButton disabled={!canTouchOff} onClick={() => action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off</TextButton><TextButton disabled={!canToolTouchOff} onClick={() => action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" disabled title="LinuxCNC WASM override-limits command unavailable" /> Override Limits (WASM unavailable)</label></div>
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!canSpindle} pressed={spindleOn === false} onClick={() => onSpindle(-1)} /><TextButton disabled={!canSpindle} onClick={() => onSpindle(0)}>Stop</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!canSpindle} pressed={spindleOn} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!canSpindle} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>-</TextButton><TextButton disabled={!canSpindle} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+</TextButton><label className="check-line"><input type="checkbox" checked={spindleBrake} disabled={!canSpindle} onChange={(event) => void action("SPINDLE_BRAKE", { spindle: 0, engaged: event.target.checked })} /> Brake</label></div></div>
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!canJog} onPointerDown={() => jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>-</TextButton><TextButton disabled={!canJog} onPointerDown={() => jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!canJog} onClick={stopJog} ariaLabel="Stop jog">Stop Jog</TextButton></div><div className="manual-action-row"><TextButton disabled={!canHome} onClick={onHomeAxis}>Home Axis</TextButton><TextButton disabled={!canTouchOff} onClick={onTouchOff}>Touch Off</TextButton><TextButton disabled={!canToolTouchOff} disabledReason="blocked_by_abi_tool_offsets" onClick={onToolTouchOff}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" disabled title="LinuxCNC WASM override-limits command unavailable" /> Override Limits (WASM unavailable)</label></div>
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!canSpindle} pressed={spindleDirection === -1} onClick={() => onSpindle(-1)} /><TextButton disabled={!canSpindle} onClick={() => onSpindle(0)} ariaLabel="Stop spindle">Stop Spindle</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!canSpindle} pressed={spindleDirection === 1} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!canSpindle} onPointerDown={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })} onPointerUp={() => action("SPINDLE_CONSTANT", { spindle: 0 })} onPointerCancel={() => action("SPINDLE_CONSTANT", { spindle: 0 })} ariaLabel="Decrease spindle speed">-</TextButton><TextButton disabled={!canSpindle} onPointerDown={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })} onPointerUp={() => action("SPINDLE_CONSTANT", { spindle: 0 })} onPointerCancel={() => action("SPINDLE_CONSTANT", { spindle: 0 })} ariaLabel="Increase spindle speed">+</TextButton><label className="check-line"><input type="checkbox" checked={spindleBrake} disabled={!canSpindle} onChange={(event) => void action("SPINDLE_BRAKE", { spindle: 0, engaged: event.target.checked })} /> Brake</label></div></div>
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} disabled={!canCoolant} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} disabled={!canCoolant} onChange={() => onCoolant("flood")} /> Flood</label></div>
</div><div className="manual-device-block"><h3>Tool / Probe:</h3><div className="manual-action-row"><TextButton disabled={!canTool} onClick={() => action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1</TextButton><TextButton disabled={!canTool} onClick={() => action("TOOL_CHANGE", {})}>Change</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "disarm" })}>Disarm</TextButton></div><span className="device-status">{devices.tool ? `Tool ${devices.tool.current ?? 0} / ${devices.tool.state ?? "idle"}; Probe ${devices.probe?.state ?? "idle"}` : "WASM state pending"}</span></div><div className="manual-sliders"><label>Jog Speed:<output>{jogScale === null ? "-" : `${Math.round(jogScale * 100)}%`}</output><input type="range" min="6" max="100" value={jogScale === null ? 6 : Math.round(jogScale * 100)} onChange={onJogScale} disabled={!canJog || jogScale === null} /></label></div></section>;
</div><div className="manual-device-block"><h3>Tool / Probe:</h3><div className="manual-action-row"><TextButton disabled={!canTool} onClick={() => action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1</TextButton><TextButton disabled={!canTool} onClick={() => action("TOOL_CHANGE", {})}>Change</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "disarm" })}>Disarm</TextButton></div><span className="device-status">{devices.tool ? `Tool ${devices.tool.current ?? 0} / ${devices.tool.state ?? "idle"}; Probe ${devices.probe?.state ?? "idle"}` : "WASM state pending"}</span></div></section>;
}
function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
@@ -113,7 +112,7 @@ function VirtualizedExecutionTrace({ entries, executing }: { entries: Array<{ li
return <div ref={traceRef} className="program-trace-scroll" onScroll={(event) => { const node = event.currentTarget; followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < TRACE_ROW_HEIGHT * 1.5; updateWindow(); }}><ol style={{ height: `${Math.max(1, entries.length * TRACE_ROW_HEIGHT)}px` }}>{visible.map((entry, offset) => { const index = windowRange.start + offset; const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} style={{ position: "absolute", top: `${index * TRACE_ROW_HEIGHT}px`, left: 0, height: `${TRACE_ROW_HEIGHT}px` }} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div>;
}
function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatus, onLoad, onRun, onStep, canLoad, canRun, canStep, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; programStatus: string; onLoad: () => void; onRun: () => void; onStep: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) {
function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatus, onLoad, canLoad, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; programStatus: string; onLoad: () => void; onRun?: () => void; onStep?: () => void; canLoad: boolean; canRun?: boolean; canStep?: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) {
const lines = text.split("\n");
const executing = ["running", "paused"].includes(programStatus.toLowerCase());
const mainLine = executing && currentLine && currentLine > 0 && currentLine <= lines.length ? currentLine : null;
@@ -141,7 +140,7 @@ function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatu
}, [mainLine]);
const visibleLines = lines.slice(editorWindow.start, editorWindow.end);
const lineClass = (line: number) => line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : "";
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers"><div className="program-gutter-content" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }}>{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; const line = index + 1; return <span key={index} className={lineClass(line)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div></div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }} aria-hidden="true"><span className="program-highlights-spacer" />{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; return <span key={index} className={lineClass(index + 1)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } updateEditorWindow(); } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><VirtualizedExecutionTrace entries={executionTrace} executing={executing} /></div></div></div><aside className="program-side"><div className="program-actions"><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>;
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers"><div className="program-gutter-content" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }}>{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; const line = index + 1; return <span key={index} className={lineClass(line)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div></div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }} aria-hidden="true"><span className="program-highlights-spacer" />{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; return <span key={index} className={lineClass(index + 1)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } updateEditorWindow(); } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><VirtualizedExecutionTrace entries={executionTrace} executing={executing} /></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</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 }> }) {
@@ -162,11 +161,11 @@ export function App() {
const operationController = useMemo(() => new AxisOperationController(client), [client]);
const [state, setState] = useState(INITIAL_STATE);
const [casePath, setCasePath] = useState<string>(DEFAULT_CASE);
const [caseStatus, setCaseStatus] = useState("ready to load");
const [caseStatus, setCaseStatus] = useState("loading configured program...");
const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramTextState] = useState(DEFAULT_PROGRAM);
const [programText, setProgramTextState] = useState("");
const [programPath, setProgramPath] = useState(PROGRAM_PATH);
const [programLoading, setProgramLoading] = useState(false);
const [programLoading, setProgramLoading] = useState(true);
const [selectedAxis, setSelectedAxis] = useState("x");
const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
@@ -176,11 +175,13 @@ export function App() {
const [coordType, setCoordType] = useState<"machine" | "relative">("machine");
const [displayType, setDisplayType] = useState<"actual" | "commanded">("actual");
const [metric, setMetric] = useState(true);
const [touchOffSystem, setTouchOffSystem] = useState("P1 G54");
const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false });
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const runStepPending = useRef(false);
const jogStepPending = useRef(false);
const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
const programFileInput = useRef<HTMLInputElement>(null);
const [plotCleared, setPlotCleared] = useState(false);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
@@ -197,7 +198,6 @@ export function App() {
const cachedResponse = await client.loadUiState(UI_CACHE_KEY).catch(() => null);
const cachedPayload = cachedResponse?.payload && typeof cachedResponse.payload === "object" ? cachedResponse.payload as Record<string, unknown> : null;
const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record<string, unknown> : null;
const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record<string, unknown> : null;
if (cached) {
if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
@@ -209,6 +209,7 @@ export function App() {
if (["machine", "relative"].includes(String(cached.coordType))) setCoordType(cached.coordType as "machine" | "relative");
if (["actual", "commanded"].includes(String(cached.displayType))) setDisplayType(cached.displayType as "actual" | "commanded");
if (typeof cached.metric === "boolean") setMetric(cached.metric);
if (typeof cached.touchOffSystem === "string" && /^(?:P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/i.test(cached.touchOffSystem)) setTouchOffSystem(cached.touchOffSystem);
if (cached.layers && typeof cached.layers === "object") {
const savedLayers = cached.layers as Record<string, unknown>;
setLayers((current) => {
@@ -227,15 +228,11 @@ export function App() {
const configuration = toWorkerConfiguration(bundle);
await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false });
if (typeof cachedProgram?.source === "string" && cachedProgram.source.trim() && cachedProgram.source.length <= 900000 && typeof cachedProgram.path === "string" && cachedProgram.path.length <= 512 && (cachedProgram.source !== normalizedSource || cachedProgram.path !== PROGRAM_PATH)) {
const cachedSource = normalizeProgramSource(cachedProgram.source);
setProgramTextState(cachedSource); setProgramPath(cachedProgram.path);
await operationController.execute("PROGRAM_LOAD", { source: cachedSource, path: cachedProgram.path }, { state: {}, gate: false });
}
uiCacheHydrated.current = true;
setOperationTrace(operationController.exportTrace());
setCaseStatus(`${DEFAULT_CASE} + ${cachedProgram?.path ?? PROGRAM_PATH} loaded`);
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); });
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`);
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false));
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
}, [client, operationController]);
const axes = state.snapshot?.motion?.axes ?? [];
@@ -252,15 +249,26 @@ export function App() {
const isEstop = wasmSnapshotReady && machineState === "ESTOP";
const machineOn = wasmSnapshotReady && machineState === "ON";
const ready = wasmSnapshotReady && machineOn && !["error", "recovering"].includes(state.status);
const programLoaded = Boolean(state.program?.source_hash || state.program?.path || state.program?.loaded || state.program?.program_status && state.program.program_status !== "idle");
const programRunning = operationState === "RUNNING";
const programPaused = operationState === "PAUSED";
const idle = operationState === "IDLE";
const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" };
const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override"), spindle: wasmGate("spindle"), coolant: wasmGate("coolant"), tool: wasmGate("tool"), probe: wasmGate("probe"), touchOff: wasmGate("touchOff"), toolTouchOff: wasmGate("toolTouchOff") };
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); };
const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
const startJogTimer = (axis: string, direction: number) => {
continuousJog.current = { axis, direction };
if (jogTimer.current) return;
jogTimer.current = setInterval(() => {
const jog = continuousJog.current;
if (!jog || jogStepPending.current) return;
jogStepPending.current = true;
void operationController.execute("JOG_START", { axis: jog.axis, direction: jog.direction, increment: null, continuous: true, continuousSample: true }, { state: axisState, gate: false })
.then(() => operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }))
.catch(() => undefined)
.finally(() => { jogStepPending.current = false; });
}, 50);
};
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); };
const dispatch = async (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => {
setCommandStatus(`${kind} pending`);
@@ -279,7 +287,7 @@ export function App() {
}
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
setOperationTrace(operationController.exportTrace());
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START" && payload.continuous === true && payload.continuousSample !== true) startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
setOperationTrace(operationController.exportTrace());
const code = error.message;
setCommandStatus(`${kind} blocked: ${code}`);
@@ -288,21 +296,21 @@ export function App() {
};
const execute: Action = (kind, payload = {}) => dispatch(kind, payload).catch(() => undefined);
const runProgram = () => { if (programLoading) return; void dispatch("PROGRAM_RUN").catch(() => undefined); };
const stopProgram = () => { const wasRunning = Boolean(runTimer.current) || gates.abort.enabled; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, gates.abort.enabled).catch(() => undefined); };
const stopProgram = () => { const wasRunning = programRunning || programPaused; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, true).catch(() => undefined); };
const emergencyStop = () => { stopRunTimer(); stopJogTimer(); void dispatch("ESTOP", { active: true }, false).catch(() => undefined); };
const releaseEstop = () => dispatch("ESTOP", { active: false }, false);
const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); };
const powerOn = () => { if (!gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); };
const resetMachine = () => { stopRunTimer(); stopJogTimer(); if (!gates.reset.enabled) return; void dispatch("RESET").catch(() => undefined); };
const homeAll = () => { void dispatch("HOME", { all: true }).catch(() => undefined); };
const runStartupSequence = () => {
if (programLoading) return;
const release = isEstop ? releaseEstop() : Promise.resolve();
const power = () => machineOn ? Promise.resolve() : dispatch("POWER", { active: true }, false);
void release.then(power)
.then(() => dispatch("HOME", { all: true }, false))
.then(() => dispatch("PROGRAM_RUN", {}, false))
.catch(() => undefined);
const homeAll = () => {
const confirm = globalThis.confirm;
if (allHomed && typeof confirm === "function" && !confirm("All joints are already homed. Re-home all joints?")) return;
void dispatch("HOME", { all: true }).catch(() => undefined);
};
const homeAxis = () => {
const index = ["x", "y", "z", "a", "c"].indexOf(selectedAxis);
const confirm = globalThis.confirm;
if (index >= 0 && homedAxes[index] && typeof confirm === "function" && !confirm(`${selectedAxis.toUpperCase()} is already homed. Re-home this joint?`)) return;
void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined);
};
const clearLoadedPreview = async () => {
await operationController.execute("VISUAL_CLEAR", {}, { state: axisState, gate: false });
@@ -370,17 +378,7 @@ export function App() {
setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`);
} finally { setProgramLoading(false); }
};
const reopenDefaults = () => {
if (programLoading || programRunning || !gates.load.enabled) return;
void loadCase();
};
// The legacy menu/toolbar handlers call setProgramText(DEFAULT_PROGRAM).
// Route that action through the same WASM-backed reopen transaction instead
// of changing only a local editor string.
const setProgramText = (value: string) => {
if (value === DEFAULT_PROGRAM) { reopenDefaults(); return; }
setProgramTextState(value);
};
const setProgramText = (value: string) => { setProgramTextState(value); };
const loadPackagedProgram = async () => {
if (programLoading || programRunning) return;
const path = "linuxcnc/nc_files/3D_Chips.ngc";
@@ -463,24 +461,52 @@ export function App() {
setProgramLoading(false);
}
};
const enterManualMode = () => { setLeftTab("manual"); if (String(axisState.task.mode).toUpperCase() !== "MANUAL") void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined); };
const enterMdiMode = () => { setLeftTab("mdi"); if (String(axisState.task.mode).toUpperCase() !== "MDI") void dispatch("SET_MODE", { mode: "MDI" }, false).catch(() => undefined); };
useEffect(() => {
if (!wasmSnapshotReady || programRunning || programPaused || programLoading || leftTab === "diagnostics") return;
const desiredMode = leftTab === "mdi" ? "MDI" : "MANUAL";
if (String(axisState.task.mode).toUpperCase() === desiredMode) return;
void dispatch("SET_MODE", { mode: desiredMode }, false).catch(() => undefined);
}, [leftTab, axisState.task.mode, wasmSnapshotReady, programRunning, programPaused, programLoading]);
const enterManualMode = () => {
setLeftTab("manual");
if (!wasmSnapshotReady || programRunning || programPaused || programLoading) return;
if (String(axisState.task.mode).toUpperCase() === "MANUAL") return;
// Send the explicit tab gesture in the same Worker queue as any following
// Run command. A state-sync effect can otherwise arrive after AUTO starts
// and be rejected as an invalid AUTO -> MANUAL transition.
void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined);
};
const enterMdiMode = () => {
setLeftTab("mdi");
if (!wasmSnapshotReady || programRunning || programPaused || programLoading) return;
if (String(axisState.task.mode).toUpperCase() === "MDI") return;
void dispatch("SET_MODE", { mode: "MDI" }, false).catch(() => undefined);
};
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
if (!event.ctrlKey && event.key === "R") { event.preventDefault(); setCommandStatus("PROGRAM_REVERSE blocked: LinuxCNC reverse command is not exposed by the selected WASM program ABI"); return; }
if (!event.ctrlKey && event.key === "F") { event.preventDefault(); setCommandStatus("PROGRAM_FORWARD blocked: LinuxCNC forward command is not exposed by the selected WASM program ABI"); return; }
if (!event.ctrlKey && event.key === "B") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: true }); return; }
if (!event.ctrlKey && event.key === "b") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: false }); return; }
const key = event.key.toLowerCase();
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").then(() => setPlotCleared(true)).catch(() => undefined); return; }
if (event.ctrlKey && key === "r") { event.preventDefault(); loadProgram(); return; }
if (event.ctrlKey && event.key === "Home") { event.preventDefault(); homeAll(); return; }
if (event.ctrlKey && event.key === "End") { event.preventDefault(); promptTouchOff(true); return; }
if (key === "f3") { event.preventDefault(); enterManualMode(); return; }
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; }
if (key === "f5") { event.preventDefault(); enterMdiMode(); return; }
if (key === "f1") { event.preventDefault(); toggleEstop(); return; }
if (key === "f2") { event.preventDefault(); powerOn(); return; }
if (key === "f7") { event.preventDefault(); onCoolant("mist"); return; }
if (key === "f8") { event.preventDefault(); onCoolant("flood"); return; }
if (key === "f9") { event.preventDefault(); onSpindle(spindleDirection === 1 ? 0 : 1); return; }
if (key === "f10") { event.preventDefault(); onSpindle(spindleDirection === -1 ? 0 : -1); return; }
if (key === "f11") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: -100 }); return; }
if (key === "f12") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: 100 }); return; }
if (key === "v") { event.preventDefault(); setView((current) => current === "x" ? "y" : current === "y" ? "p" : current === "z" ? "z2" : current === "z2" ? "x" : "z"); setRightTab("preview"); return; }
if (key === "d") { event.preventDefault(); setRotateMode((current) => !current); return; }
if (key === "!") { event.preventDefault(); setMetric((current) => !current); return; }
if (key === "@") { event.preventDefault(); setDisplayType((current) => current === "actual" ? "commanded" : "actual"); return; }
if (key === "#") { event.preventDefault(); setCoordType((current) => current === "machine" ? "relative" : "machine"); return; }
if (key === "f4") { event.preventDefault(); setRightTab((current) => current === "preview" ? "dro" : "preview"); return; }
if (event.key === "Home") { event.preventDefault(); homeAxis(); return; }
if (event.key === "End") { event.preventDefault(); promptTouchOff(false); return; }
if (key === "r") { event.preventDefault(); runProgram(); return; }
if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; }
if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; }
@@ -498,15 +524,30 @@ export function App() {
if (!ready) { stopRunTimer(); stopJogTimer(); }
}, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]);
const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined;
const spindleOn = spindleState ? Boolean(spindleState.enabled) : false;
const spindleDirection = spindleState?.enabled ? Number(spindleState.command_rpm ?? 0) < 0 ? -1 : 1 : 0;
const spindleBrake = spindleState ? Boolean(spindleState.brake) : false;
const ioState = (state.snapshot?.io as Record<string, any> | undefined)?.state as Record<string, any> | undefined;
const manualReady = wasmSnapshotReady && Boolean(ioState?.ready) && !Boolean(ioState?.estop);
const deviceIoEnvelope = state.deviceIo as Record<string, any> | null;
const deviceIoCanonical = (deviceIoEnvelope?.state as Record<string, any> | undefined) ?? deviceIoEnvelope;
const coolant = { mist: Boolean(deviceIoCanonical?.coolant?.mist ?? deviceIoCanonical?.coolant?.mist_requested), flood: Boolean(deviceIoCanonical?.coolant?.flood ?? deviceIoCanonical?.coolant?.flood_requested) };
const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 1, enabled: direction !== 0 }); };
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
const promptTouchOff = (toolOnly: boolean) => {
if (toolOnly) { setCommandStatus("TOOL_TOUCH_OFF blocked: blocked_by_abi_tool_offsets"); return; }
const prompt = globalThis.prompt;
if (typeof prompt !== "function") return;
const entered = prompt(toolOnly ? `Tool ${toolNumber} Touch Off: enter ${selectedAxis.toUpperCase()} coordinate` : `Touch Off: enter ${selectedAxis.toUpperCase()} coordinate`, "0");
if (entered === null) return;
const value = Number(entered);
if (!Number.isFinite(value)) { setCommandStatus("TOUCH_OFF blocked: touch_off_value_invalid"); return; }
const system = prompt("Coordinate system (P0 Current, P1 G54 ... P9 G59.3)", touchOffSystem);
if (system === null) return;
const normalizedSystem = system.trim().toUpperCase();
if (!/^(?:P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/.test(normalizedSystem)) { setCommandStatus("TOUCH_OFF blocked: touch_off_system_invalid"); return; }
setTouchOffSystem(normalizedSystem);
void dispatch(toolOnly ? "TOOL_TOUCH_OFF" : "TOUCH_OFF", { axis: selectedAxis, value, system: normalizedSystem, units: metric ? "mm" : "inch" }).catch(() => undefined);
};
const wasmMotion = (state.snapshot?.wasm_motion as { state?: Record<string, unknown> } | undefined)?.state ?? {};
const sourceFeedOverride = Number(state.snapshot?.overrides?.feed ?? wasmMotion.feed_override);
const feedOverridePercent = Number.isFinite(sourceFeedOverride) ? Math.max(0, Math.min(200, Math.round(sourceFeedOverride * 100))) : null;
@@ -546,7 +587,7 @@ export function App() {
if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
const cacheState = {
version: 1,
version: 2,
selectedAxis,
leftTab,
rightTab,
@@ -558,24 +599,24 @@ export function App() {
metric,
layers,
casePath,
program: { path: programPath, source: programText },
touchOffSystem,
};
uiCacheSaveTimer.current = setTimeout(() => {
uiCacheSaveTimer.current = null;
void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined);
}, 500);
return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, layers, casePath, programPath, programText]);
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, touchOffSystem, layers, casePath]);
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled && !programLoading)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")} data-axis-program-loading={String(programLoading)} data-axis-program-path={String(state.program?.path ?? "")} data-axis-program-source-hash={String(state.program?.source_hash ?? "")}>
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); }} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction onClick={resetMachine} disabled={!gates.reset.enabled}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!gates.abort.enabled}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled} onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" disabled={!gates.reset.enabled} onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled || programLoading}>Load case</TextButton><TextButton feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!gates.load.enabled || programLoading}>Load 3D_Chips.ngc</TextButton><span className={`case-status${programLoading ? " loading" : ""}`} role="status" aria-live="polite" aria-busy={programLoading}>{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}</span>{previewProgress && <div className={`preview-load-indicator ${previewProgress.phase}`} role="status" aria-live="polite" aria-label="Toolpath preview loading progress"><span title={previewProgress.detail}>{previewProgress.detail}</span><progress max="100" value={previewProgress.percent}>{previewProgress.percent}%</progress><output>{previewProgress.percent}%</output></div>}<span className="command-status" role="status">{commandStatus}</span></section>
<MachineSequence wasmReady={wasmSnapshotReady} isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} programLoading={programLoading} canPower={gates.power.enabled} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={loadProgram} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!programRunning && !programPaused}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.power.reason} onClick={powerOn} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.load.reason} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reload current G-code [Ctrl-R]" asset="tool_reload.gif" disabled={!gates.load.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.load.reason} onClick={loadProgram} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.run.reason} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.step.reason} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programLoading || (programPaused ? !gates.resume.enabled : !gates.pause.enabled)} disabledReason={programLoading ? "axis_program_load_in_progress" : programPaused ? gates.resume.reason : gates.pause.reason} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!programRunning && !programPaused} disabledReason={!programRunning && !programPaused ? "axis_stop_state_gate" : gates.abort.reason} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled disabledReason="blocked_by_abi: LinuxCNC block-delete state is unavailable" /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled disabledReason="blocked_by_abi: LinuxCNC optional-stop state is unavailable" /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!gates.load.enabled || programLoading}>Load 3D_Chips.ngc</TextButton><span className={`case-status${programLoading ? " loading" : ""}`} role="status" aria-live="polite" aria-busy={programLoading}>{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}</span>{previewProgress && <div className={`preview-load-indicator ${previewProgress.phase}`} role="status" aria-live="polite" aria-label="Toolpath preview loading progress"><span title={previewProgress.detail}>{previewProgress.detail}</span><progress max="100" value={previewProgress.percent}>{previewProgress.percent}%</progress><output>{previewProgress.percent}%</output></div>}<span className="command-status" role="status">{commandStatus}</span></section>
<MachineSequence wasmReady={wasmSnapshotReady} allHomed={allHomed} canHome={gates.home.enabled && !programLoading} onHomeAll={homeAll} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} loading={programLoading} />
<XyzacLiveParameters snapshot={state.snapshot} />
<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={enterMdiMode}>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} canJog={manualReady && gates.jog.enabled} canHome={gates.home.enabled} canSpindle={manualReady && Boolean(gates.spindle?.enabled)} canCoolant={wasmSnapshotReady && Boolean(gates.coolant?.enabled)} canTool={manualReady && Boolean(gates.tool?.enabled)} canProbe={manualReady && Boolean(gates.probe?.enabled)} canTouchOff={manualReady && Boolean(gates.touchOff?.enabled)} canToolTouchOff={manualReady && Boolean(gates.toolTouchOff?.enabled)} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} spindleBrake={spindleBrake} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} jogScale={Number.isFinite(sourceJogScale) ? sourceJogScale : null} onJogScale={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={manualReady && 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={programLoading ? null : 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-panel-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>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={!gates.jog.enabled || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={!gates.override.enabled || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={!gates.override.enabled || maxVelocityPercent === null} aria-label="Max velocity" /></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>
<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={enterMdiMode}>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} canJog={!programLoading && manualReady && gates.jog.enabled} canHome={!programLoading && gates.home.enabled} canSpindle={!programLoading && manualReady && Boolean(gates.spindle?.enabled)} canCoolant={!programLoading && wasmSnapshotReady && Boolean(gates.coolant?.enabled)} canTool={!programLoading && manualReady && Boolean(gates.tool?.enabled)} canProbe={!programLoading && manualReady && Boolean(gates.probe?.enabled)} canTouchOff={!programLoading && manualReady && Boolean(gates.touchOff?.enabled)} canToolTouchOff={!programLoading && manualReady && Boolean(gates.toolTouchOff?.enabled)} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleDirection={spindleDirection} spindleBrake={spindleBrake} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} onHomeAxis={homeAxis} onTouchOff={() => promptTouchOff(false)} onToolTouchOff={() => promptTouchOff(true)} jogScale={Number.isFinite(sourceJogScale) ? sourceJogScale : null} onJogScale={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={!programLoading && manualReady && 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={programLoading ? null : 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>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={programLoading || !gates.jog.enabled || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={programLoading || !gates.override.enabled || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={programLoading || !gates.override.enabled || maxVelocityPercent === null} aria-label="Max velocity" /></label><label title="blocked_by_abi: LinuxCNC rapidrate is not exposed by the selected WASM TP"><span>Rapid Override</span><output>Unavailable</output><input type="range" min="0" max="120" value="0" disabled aria-label="Rapid override" /></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={visibleProgramStatus} currentLine={visibleCurrentLine} sourceHash={programLoading ? "" : String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={visibleExecutionTrace} programStatus={visibleProgramStatus} onLoad={loadProgram} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} 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>

View File

@@ -110,6 +110,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let paintedProgress = 0;
let liveHistoryLine: THREE.Line | null = null;
let activeLiveLine: THREE.Line | null = null;
let livePathEpoch = -1;
let lastHistoryTick = -1;
let lastActiveKey = "";
let lastPreset = "";
@@ -145,6 +146,29 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const segments = state.toolpath as Segment[];
const layer = layersRef.current;
const now = performance.now();
if (state.livePathEpoch !== livePathEpoch) {
livePathEpoch = state.livePathEpoch;
clearLines(liveGroup);
liveTrace = [];
playbackQueue = [];
playbackStart = 0;
queuedLastTick = state.tick;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
liveTick = renderedTick;
liveHistoryLine = null;
activeLiveLine = null;
lastHistoryTick = -1;
lastActiveKey = "";
}
// React can deliver a full snapshot just behind an already queued
// motion-delta response. Keep the rendered pose monotonic until the
// newer samples arrive; otherwise the tool visibly jumps backward.
@@ -217,28 +241,6 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
segmentCandidatesBySourceLine.set(sourceLine, candidates);
}
clearLines(pathGroup);
clearLines(liveGroup);
// A new PROGRAM_LOAD, including the transient empty snapshot while
// loading, starts a new live-trace epoch. Clear internal playback and
// history buffers as well as rendered geometry so the previous
// program's yellow/orange path cannot be painted again.
liveTrace = [];
playbackQueue = [];
playbackStart = 0;
queuedLastTick = -1;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
// Seed the new epoch without adding a visible history point. The
// first post-load motion delta will create the first live segment.
liveTick = renderedTick;
bounds = fit(segments);
// Long programs can contain thousands of linear blocks. Batch their
// vertices so preview loading does not block the UI on one WebGL
@@ -270,10 +272,6 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
addSegments(batched.rapid, rapid, true);
addSegments(batched.completed, completed);
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
liveHistoryLine = null;
activeLiveLine = null;
lastHistoryTick = -1;
lastActiveKey = "";
}
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
@@ -289,6 +287,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
liveTick = renderedTick;
}
element.dataset.xyzacLiveTraceCount = String(liveTrace.length);
element.dataset.xyzacLivePathEpoch = String(livePathEpoch);
element.dataset.xyzacRenderedTick = String(renderedTick);
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
if (historyDue) {
@@ -346,7 +345,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const pose = visual.pose;
const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length;
const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
const controllerButton = (action: string, label: string, glyph: string) => <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
}

View File

@@ -4,18 +4,17 @@ import type { SimSnapshotV1 } from "./protocol";
type Command = (kind: string, payload?: Record<string, unknown>, action?: string) => void;
export function XyzacKinematicsControl({ view, command }: { view: XyzacPanelView; command: Command }) {
export function XyzacKinematicsControl({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) {
const active = view.kinematics?.active_type;
return <section className="xyzac-kinematics" aria-label="XYZAC kinematics">
<div className="xyzac-section-title"><strong>Switchkins</strong><span>{view.kinematics?.native_type ?? "Unavailable"}</span></div>
<div className="xyzac-segments" role="group" aria-label="Kinematics mode">
{XYZAC_KINEMATICS.map((item) => {
const gate = view.gates[item.action];
const disabled = view.status !== "ready" || gate?.enabled === false;
const disabled = loading || view.status !== "ready" || gate?.enabled === false;
return <button key={item.type} type="button" className={active === item.type ? "active" : ""} aria-pressed={active === item.type} disabled={disabled} title={gate?.reason ?? item.mcode} onClick={() => command("KINEMATICS_SELECT", { type: item.type }, item.action)}><span>{item.label}</span><small>{item.mcode}</small></button>;
})}
</div>
<button type="button" className="xyzac-clear" title={view.gates.vismachClear?.reason ?? "Clear visual plot"} disabled={view.gates.vismachClear?.enabled === false} onClick={() => command("VISUAL_CLEAR", {}, "vismachClear")}>Clear plot</button>
</section>;
}
@@ -66,6 +65,6 @@ export function XyzacDiagnostics({ view, operations, alarms, error }: { view: Xy
</section>;
}
export function XyzacTrtPanel({ view, command }: { view: XyzacPanelView; command: Command }) {
return <section className="xyzac-control-band" aria-label="XYZAC TRT controls"><XyzacKinematicsControl view={view} command={command} /><XyzacManualPanel view={view} /></section>;
export function XyzacTrtPanel({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) {
return <section className="xyzac-control-band" aria-label="XYZAC TRT controls"><XyzacKinematicsControl view={view} command={command} loading={loading} /><XyzacManualPanel view={view} /></section>;
}

View File

@@ -133,6 +133,7 @@ export type PanelSnapshotV1 = {
schema: "axis-visual.v1";
pose: Record<string, number>;
joints: number[];
live_path_epoch?: number;
toolpath?: Array<Record<string, unknown>>;
active_segment_id?: number | null;
active_progress?: number;

View File

@@ -118,9 +118,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
};
}
if (panel?.schema === "axis-panel-snapshot.v1") {
const visualSegments = (Array.isArray(panel.visual.toolpath) ? panel.visual.toolpath : previous.toolpath)
.filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0))
.map((segment, index) => {
const visualSegments = Array.isArray(panel.visual.toolpath)
? panel.visual.toolpath.filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0)).map((segment, index) => {
const value = segment as Record<string, unknown>;
return {
id: Number(value.id ?? index + 1),
@@ -142,7 +141,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
...(typeof value.turn === "number" ? { turn: value.turn } : {}),
active: value.id === panel.visual.active_segment_id,
};
});
})
: previous.toolpath;
const pose = panel.visual.pose;
const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0);
const snapshot = {
@@ -203,10 +203,10 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
].filter((segment): segment is NonNullable<typeof segment> & { id: number; start: Record<string, number>; end: Record<string, number> } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end));
const command = String(result.command ?? "");
const visualClear = command === "VISUAL_CLEAR";
const resetPath = visualClear || command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id));
const nextToolpath = resetPath
? visualClear ? [] : previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
for (const segment of plannerSegments) {
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
@@ -224,7 +224,14 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
active_progress: 0,
motion_trace: [],
}
: previous.snapshot?.visual;
: visualClear && previous.snapshot?.visual
? {
...previous.snapshot.visual,
toolpath: nextToolpath,
live_path_epoch: Number(result.live_path_epoch ?? previous.snapshot.visual.live_path_epoch ?? 0),
motion_trace: [],
}
: previous.snapshot?.visual;
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot?.overrides, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1;
return { snapshot, toolpath: nextToolpath, task: (result.task as Record<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace };
}

View File

@@ -8,15 +8,32 @@ const base = {
simulation_tick: 1,
state_hash: "fnv1a64:base",
task: {}, motion: {}, wasm_motion: {}, gates: {},
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" },
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], live_path_epoch: 3, toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" },
authoritative: {}, motion_authority: "wasm", task_authority: "wasm_task_canonical",
},
toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }],
};
const result = reduceFrontendState(base, {
schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 1,
payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, panel_snapshot: undefined },
payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, live_path_epoch: 4, panel_snapshot: undefined },
});
assert.deepEqual(result.toolpath, []);
assert.deepEqual(result.snapshot?.visual?.toolpath, []);
console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", stale_toolpath_removed: true }));
assert.equal(result.toolpath.length, 1);
assert.equal(result.toolpath[0].id, 1);
assert.deepEqual(result.toolpath[0].start, { x: 0 });
assert.deepEqual(result.toolpath[0].end, { x: 1 });
assert.equal(result.snapshot?.visual?.toolpath?.length, 1);
assert.equal(result.snapshot?.visual?.live_path_epoch, 4);
const paused = reduceFrontendState(base, {
schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 2,
payload: {
status: "verified", command: "PROGRAM_PAUSE",
panel_snapshot: {
...base.snapshot,
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], live_path_epoch: 3, source: "wasm_canonical_motion" },
},
},
});
assert.equal(paused.toolpath, base.toolpath);
assert.equal(paused.snapshot?.visual?.toolpath, base.toolpath);
assert.equal(paused.snapshot?.visual?.live_path_epoch, 3);
console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", static_toolpath_retained: true, pause_toolpath_reference_retained: true, live_path_epoch: 4 }));

View File

@@ -7,7 +7,7 @@ const visual = projectXyzacVisual({
simulation_tick: 12,
state_hash: "fnv1a64:tool-preview",
kinematics: { schema: "wasm-xyzac-kinematics.v1", status: "verified", active_type: 1, native_type: "xyzac-trt-kins", coordinates: "XYZAC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: { x: 10, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 }, joints: [10, -5, 20, 30, 45], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 5 }, generation: 1, switch_count: 1, state_hash: "fnv1a64:k", wasm_state_hash: "fnv1a64:w" },
visual: { active_segment_id: 3, active_progress: 0.375, pose: { x: 11, y: -4, z: 19, a: 31, c: 44 }, joints: [11, -4, 19, 31, 44], toolpath: [{ id: 3, active: true, motion_type: 1, start: { x: 0, y: 0, z: 0 }, end: { x: 10, y: -5, z: 20 } }] },
visual: { active_segment_id: 3, active_progress: 0.375, live_path_epoch: 7, pose: { x: 11, y: -4, z: 19, a: 31, c: 44 }, joints: [11, -4, 19, 31, 44], toolpath: [{ id: 3, active: true, motion_type: 1, start: { x: 0, y: 0, z: 0 }, end: { x: 10, y: -5, z: 20 } }] },
});
assert.equal(visual.tick, 12);
assert.equal(visual.stateHash, "fnv1a64:tool-preview");
@@ -16,8 +16,9 @@ assert.deepEqual(visual.pose, { x: 11, y: -4, z: 19, a: 31, b: 0, c: 44, u: 0, v
assert.deepEqual(visual.livePose, visual.pose);
assert.equal(visual.activeProgress, 0.375);
assert.equal(visual.activeSegmentId, 3);
assert.equal(visual.livePathEpoch, 7);
for (const token of ["liveTrace = []", "playbackQueue = []", "queuedLastTick = -1", "liveTick = -1"]) assert.ok(source.includes(token), token);
for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "A new PROGRAM_LOAD"]) assert.ok(source.includes(token), token);
for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "state.livePathEpoch !== livePathEpoch", "xyzacLivePathEpoch"]) assert.ok(source.includes(token), token);
for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "activeSegment.samples.map(sourcePoint)", "segmentCandidatesBySourceLine", "candidates.length === 1", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token);
for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden));
for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden));

View File

@@ -9,6 +9,7 @@ export type XyzacVisualState = {
joints: [number, number, number, number, number];
livePose: XyzacKinematicsSnapshot["pose"];
activeProgress: number;
livePathEpoch: number;
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
offsets: XyzacKinematicsSnapshot["offsets"];
activeSegmentId: number | null;
@@ -23,7 +24,7 @@ export type XyzacVisualSource = {
simulation_tick?: number;
state_hash?: string;
kinematics?: XyzacKinematicsSnapshot;
visual?: { active_segment_id?: number | null; active_progress?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
};
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
@@ -48,6 +49,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
joints,
livePose: pose,
activeProgress,
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
motionTrace,
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,

View File

@@ -37,14 +37,14 @@ const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP",
"HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE",
"HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "TOOL_PREPARE",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "TOUCH_OFF", "TOOL_TOUCH_OFF", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
"POWER", "KINEMATICS_SELECT", "VISUAL_CLEAR",
]);
@@ -53,7 +53,7 @@ const PANEL_GATE_ACTIONS = Object.freeze({
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
JOG_START: "jogStart", JOG_STOP: "jogStop", SET_JOG_SCALE: "jogStart", SET_MAX_VELOCITY: "override", SET_SPINDLE_OVERRIDE: "override", HOME: "home", SET_OVERRIDE: "override",
SPINDLE_COMMAND: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe",
SPINDLE_COMMAND: "spindle", SPINDLE_CONSTANT: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe",
ESTOP: "estop", IO_RECOVER: "recover",
POWER: "power",
});
@@ -103,6 +103,8 @@ function canonicalPayload(value) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`;
}
// Control-flow contract: docs/linuxcnc-axis-web-control-panel-source-logic.md
// maps each browser operation to its LinuxCNC AXIS source symbol and gate.
export class SimSession {
constructor(core, { contextId = CONTEXT_ID, sessionId = "", planner = "queue" } = {}) {
this.core = core;
@@ -161,6 +163,7 @@ export class SimSession {
this.configurationState = null;
this.halState = null;
this.previewState = null;
this.livePathEpoch = 0;
this.workspaceFiles = {};
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
@@ -173,7 +176,8 @@ export class SimSession {
// The loaded toolpath is immutable during execution. Avoid cloning all
// WASM pose samples into every 10 ms step response; the frontend retains
// the canonical path from PROGRAM_LOAD/PROGRAM_RESTART.
const lightweightXyzacStep = input.kind === "SIM_STEP" && Boolean(this.xyzacProgram);
const xyzacProgramActive = Boolean(this.xyzacProgram);
const lightweightXyzacStep = input.kind === "SIM_STEP" && xyzacProgramActive;
const traceOnlyStep = lightweightXyzacStep && input.payload?.trace_only === true;
const currentMotion = traceOnlyStep && statePayload.wasm_motion ? statePayload.wasm_motion : this.wasmMotion.read();
const completedMotion = program.status === 4 && currentMotion.state?.queue_depth === 0;
@@ -181,7 +185,21 @@ export class SimSession {
// responses must retain their code/reason instead of being rewritten into
// a delta with undefined fields.
const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion;
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep });
// The frontend retains the canonical preview from PROGRAM_LOAD. Repeating
// thousands of immutable segments on unrelated control responses makes a
// loaded program delay abort/power/home commands behind structured-clone
// work. Explicit preview transactions still publish the full replacement.
const publishXyzacToolpath = ["PROGRAM_LOAD", "PROGRAM_RESTART", "RECOVER", "SNAPSHOT_REQUEST", "VISUAL_CLEAR"].includes(input.kind);
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({
task,
program,
input,
tick,
motionTrace: statePayload.motion_trace,
includeToolpath: xyzacProgramActive ? publishXyzacToolpath : !lightweightXyzacStep,
includeConfiguration: !lightweightXyzacStep,
includeStableRuntime: !lightweightXyzacStep,
});
const compactProgram = motionDelta ? {
schema: program.schema,
source: program.source,
@@ -337,8 +355,11 @@ export class SimSession {
if (!this.xyzacProgram?.source) return { status: "verified" };
const source = this.xyzacProgram.source;
const path = this.xyzacProgram.path;
const synchronizedPosition = this.wasmMotion.read().state.position;
const loaded = this.xyzacProgram.load(source, path);
if (loaded.status !== "verified") return loaded;
const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
const subroutines = this.loadXyzacSubroutines(path);
if (subroutines.status !== "verified") return subroutines;
@@ -415,6 +436,7 @@ export class SimSession {
schema: "axis-visual.v1",
pose: { ...position },
joints: Array.isArray(joints) ? [...joints] : [],
live_path_epoch: this.livePathEpoch,
...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
@@ -502,6 +524,9 @@ export class SimSession {
const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {};
const wasmMotion = this.wasmMotion.read();
if (input.kind === "PROGRAM_LOAD" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: null, enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
}
if (input.kind === "KINEMATICS_SELECT" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
}
@@ -510,10 +535,11 @@ export class SimSession {
const ready = Boolean(io.ready) && !estop;
const loaded = program.status !== 0;
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG
: input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
: input.kind === "TOOL_PREPARE" || input.kind === "TOOL_CHANGE" ? WASM_GATE_COMMANDS.TOOL
: input.kind === "PROBE_CONFIG" ? WASM_GATE_COMMANDS.PROBE
@@ -549,9 +575,6 @@ export class SimSession {
});
if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" };
let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command";
if (input.kind === "PROGRAM_RUN" && Number(program.status) === 5) {
return { action, enabled: false, reason: "xyzac_program_aborted_requires_restart", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth, gate_result: result } };
}
if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off";
if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on";
if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active";
@@ -576,6 +599,44 @@ export class SimSession {
return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() };
}
xyzacTouchOff(input) {
if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" };
const payload = input.payload ?? {};
const axis = String(payload.axis ?? "").trim().toUpperCase();
if (!["X", "Y", "Z", "A", "C"].includes(axis)) return { status: "error", code: "touch_off_axis_invalid" };
const value = Number(payload.value);
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
const systemMatch = selectedSystem.match(/^(P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/);
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
const systemToken = systemMatch[1];
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
const displayUnits = String(payload.units ?? "").toLowerCase();
const programUnits = Number(this.xyzacProgram.snapshot().units);
const converted = displayUnits === "mm" && programUnits === 20 ? value / 25.4
: displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
const formatted = Number(converted.toFixed(12));
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
// the selected WASM interpreter exposes the same operation through its
// canonical P-number form (G54=P1, ..., G59.3=P9).
const block = `G10 L20 P${systemNumber} ${axis}${formatted}`;
const savedMode = this.task.state.mode;
try {
if (savedMode !== "MDI") this.task.setMode("MDI");
this.wasmMotion.setPaused(false);
const offset = this.xyzacProgram.executeBlock(block);
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
if (savedMode !== "MDI") this.task.setMode(savedMode);
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
} catch (error) {
try { if (savedMode !== "MDI" && this.task.state.mode !== savedMode) this.task.setMode(savedMode); } catch { /* preserve the original interpreter error */ }
return { status: "error", code: String(error?.message ?? error) };
}
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -600,6 +661,7 @@ export class SimSession {
wasm_home_limit: this.wasmHomeLimit.exportState(),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
preview: this.previewState,
live_path_epoch: this.livePathEpoch,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
@@ -634,6 +696,7 @@ export class SimSession {
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
live_path_epoch: this.livePathEpoch,
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
};
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
@@ -711,6 +774,7 @@ export class SimSession {
this.configurationState = state.configuration ?? this.configurationState;
this.halState = state.hal ?? this.halState;
this.previewState = state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(state.live_path_epoch) ? state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python);
const liveEvents = liveTimeline.events.events;
const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: record.simulation_tick } : event);
@@ -806,6 +870,7 @@ export class SimSession {
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
if (Number.isSafeInteger(trace.state?.live_path_epoch)) this.livePathEpoch = trace.state.live_path_epoch;
if (trace.state?.plc) this.plc.restore(trace.state.plc);
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
@@ -856,6 +921,7 @@ export class SimSession {
this.configurationState = checkpoint.state.configuration ?? this.configurationState;
this.halState = checkpoint.state.hal ?? this.halState;
this.previewState = checkpoint.state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(checkpoint.state.live_path_epoch) ? checkpoint.state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
this.timeline = new ReplayTimeline(checkpoint.timeline);
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
@@ -1104,6 +1170,8 @@ export class SimSession {
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
const recoveredLivePathEpoch = result.state?.live_path_epoch ?? checkpoint?.state?.live_path_epoch;
if (Number.isSafeInteger(recoveredLivePathEpoch)) this.livePathEpoch = recoveredLivePathEpoch;
this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core);
@@ -1322,9 +1390,9 @@ export class SimSession {
const visualClearResponse = () => {
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
this.previewState = null;
this.livePathEpoch += 1;
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
};
switch (input.kind) {
@@ -1929,6 +1997,17 @@ export class SimSession {
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
this.livePathEpoch += 1;
const savedMode = this.task.state.mode;
const restoreMode = () => {
if (savedMode !== "MANUAL" && this.task.state.mode !== savedMode) this.task.setMode(savedMode);
};
// AXIS open_file_guts() enters MANUAL before the replacement setup,
// then restores the tab-selected task mode before PLAN_OPEN. The
// final restore below mirrors set_mode_from_tab after the UI refresh.
try { if (savedMode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) {
return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed");
}
// PROGRAM_LOAD is a replacement transaction. Clear the previous
// canonical preview before asking the WASM interpreter to parse the
// new source, so a parse/subroutine error cannot re-publish the old
@@ -1952,9 +2031,12 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false;
restoreMode();
const synchronizedPosition = this.wasmMotion.read().state.position;
const result = this.xyzacProgram.load(source, input.payload?.path ?? null);
if (result.status === "verified") { const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition); if (synchronized.status !== "verified") { restoreMode(); return finish(this.error(input, "program_synch", synchronized.code), "failed"); } }
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); }
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") { restoreMode(); return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); } }
const preview = result.status === "verified" ? this.xyzacProgram.preview() : result;
if (result.status === "verified" && preview.status === "verified") {
this.previewState = preview;
@@ -1964,9 +2046,11 @@ export class SimSession {
if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setProgram("loaded", 0);
}
restoreMode();
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
}
restoreMode();
const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
@@ -1978,6 +2062,7 @@ export class SimSession {
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = result.preview;
}
restoreMode();
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
@@ -1990,12 +2075,14 @@ export class SimSession {
}
case "PROGRAM_RUN": {
if (this.xyzacProgram) {
const previousProgramStatus = this.xyzacProgram.snapshot().program_status;
const result = this.xyzacProgram.start();
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
const result = this.program.start();
if (result.status === "verified") {
this.livePathEpoch += 1;
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
@@ -2094,14 +2181,13 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false;
const source = this.xyzacProgram.source;
const result = this.xyzacProgram.load(source, this.xyzacProgram.path);
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(this.xyzacProgram.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); const preview = this.xyzacProgram.preview(); if (preview.status === "verified") this.previewState = preview; this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
const result = this.resetXyzacProgramToLoaded();
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.abort(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
const result = this.program.restart();
if (result.status === "verified") {
this.livePathEpoch += 1;
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover();
@@ -2225,6 +2311,17 @@ export class SimSession {
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "TOUCH_OFF": case "TOOL_TOUCH_OFF": {
if (input.kind === "TOOL_TOUCH_OFF") return finish(this.error(input, "abi_capability", "blocked_by_abi_tool_offsets"), "failed");
const result = this.xyzacTouchOff(input);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.xyzacProgram ? "wasm" : this.motionAuthority,
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "KINEMATICS_SELECT": {
if (!this.xyzacKinematics) return finish(this.error(input, "kinematics", "xyzac_kinematics_unavailable"), "failed");
const mode = Number(input.payload?.type);
@@ -2239,7 +2336,12 @@ export class SimSession {
case "HOME": {
if (this.xyzacProgram) {
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : input.payload?.axis ?? null);
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
if (requestedAxis && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) return finish(this.error(input, "home_limit", "home_joint_requires_identity"), "failed");
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
// before issuing the homing command.
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
const home = this.wasmHomeLimit.home(input.seq, requestedAxis);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
@@ -2291,7 +2393,10 @@ export class SimSession {
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
this.wasmMotion.setPaused(false);
const distance = input.payload?.increment ?? 1;
const requestedIncrement = input.payload?.increment;
const distance = requestedIncrement === null || input.payload?.continuous === true
? Math.max(Number(this.wasmMotion.jogVelocity(axis)) * this.xyzacMotionCycleTime * 5, Number.EPSILON)
: requestedIncrement ?? 1;
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
@@ -2308,7 +2413,7 @@ export class SimSession {
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_CONSTANT": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":

View File

@@ -40,10 +40,16 @@ assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
assert.equal(loaded.payload.preview.line_count, 4711);
assert.equal(loaded.payload.preview.segments.length, 4684);
assert.equal(send("POWER", { active: true }).kind, "COMMAND_RESULT");
assert.equal(send("HOME", { all: true }).kind, "COMMAND_RESULT");
const power = send("POWER", { active: true });
assert.equal(power.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(power.payload.panel_snapshot.visual, "toolpath"), false);
const home = send("HOME", { all: true });
assert.equal(home.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(home.payload.panel_snapshot.visual, "toolpath"), false);
const run = send("PROGRAM_RUN");
assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
let stepped = null;
let executionTraceEntries = 0;
let runningQueueGaps = 0;
@@ -69,6 +75,18 @@ assert.equal(session.timeline.snapshots.snapshots.size, 0);
assert.equal(session.ledger.audit({ maxEntries: 32 }).entries.length, 32);
assert.equal(runningQueueGaps, 0);
// Replacing a large preview must leave the worker responsive for the next
// control transaction and must publish only the new program's path.
const aborted = send("PROGRAM_ABORT");
assert.equal(aborted.kind, "COMMAND_RESULT");
const replacement = send("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 Y2 F100\nM2\n", path: "replacement.ngc" });
assert.equal(replacement.kind, "COMMAND_RESULT");
assert.equal(replacement.payload.program.path, "replacement.ngc");
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const replacementPower = send("POWER", { active: false });
assert.equal(replacementPower.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(replacementPower.payload.panel_snapshot.visual, "toolpath"), false);
console.log(JSON.stringify({
schema: "xyzac-3d-chips-program.v1",
status: "verified",
@@ -84,4 +102,5 @@ console.log(JSON.stringify({
motion_authority: stepped.payload.motion_authority,
motion_delta_snapshots: session.timeline.snapshots.snapshots.size,
ledger_audit_window: session.ledger.audit({ maxEntries: 32 }).entries.length,
replacement_segments: replacement.payload.panel_snapshot.visual.toolpath.length,
}));

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-live-path-lifecycle" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const epoch = (response) => response.payload.panel_snapshot.visual.live_path_epoch;
send("HELLO");
send("SESSION_ATTACH");
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const source = ["G21", "G90", ...Array.from({ length: 80 }, (_, index) => `G1 X${index % 2 ? 0 : 5} Y${index % 2 ? 0 : 3} Z${index % 2 ? 10 : 8} F300`), "M2", ""].join("\n");
const loaded = send("PROGRAM_LOAD", { source, path: "live-path.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
const loadEpoch = epoch(loaded);
const previewCount = loaded.payload.panel_snapshot.visual.toolpath.length;
assert.ok(loadEpoch > 0 && previewCount > 0);
send("POWER", { active: true });
send("HOME", { all: true });
const run = send("PROGRAM_RUN");
const runEpoch = epoch(run);
assert.equal(runEpoch, loadEpoch + 1);
send("SIM_STEP", { cycles: 5, response: "motion_delta" });
const pause = send("PROGRAM_PAUSE");
assert.equal(epoch(pause), runEpoch);
assert.equal(Object.hasOwn(pause.payload.panel_snapshot.visual, "toolpath"), false);
const resume = send("PROGRAM_RESUME");
assert.equal(epoch(resume), runEpoch);
assert.equal(Object.hasOwn(resume.payload.panel_snapshot.visual, "toolpath"), false);
let completed = resume;
for (let index = 0; index < 5000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program?.program_status === "completed" && Number(completed.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(epoch(completed), runEpoch);
assert.equal(Object.hasOwn(completed.payload.panel_snapshot.visual, "toolpath"), false);
const rerun = send("PROGRAM_RUN");
assert.equal(epoch(rerun), runEpoch + 1);
const abort = send("PROGRAM_ABORT");
assert.equal(epoch(abort), runEpoch + 1);
const replacement = send("PROGRAM_LOAD", { source: "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n", path: "replacement-live-path.ngc" });
assert.equal(epoch(replacement), runEpoch + 2);
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const clear = send("VISUAL_CLEAR");
assert.equal(epoch(clear), runEpoch + 3);
assert.equal(clear.payload.panel_snapshot.visual.toolpath.length, 1);
assert.equal(session.previewState.segments.length, 1);
console.log(JSON.stringify({
schema: "xyzac-live-path-lifecycle.v1",
status: "verified",
source: "axis.py:LivePlotter/clear_live_plot/task_pause/task_resume/task_stop",
epochs: { load: loadEpoch, run: runEpoch, pause: epoch(pause), resume: epoch(resume), completed: epoch(completed), rerun: epoch(rerun), abort: epoch(abort), replacement: epoch(replacement), clear: epoch(clear) },
static_preview: { initial_segments: previewCount, clear_plot_segments: clear.payload.panel_snapshot.visual.toolpath.length },
}));

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { fnv1a64 } from "./replay.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const programs = [
{ name: "program-a.ngc", source: "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y0 Z10 A0 C0 F600\nM2\n", expected: { x: 10, y: 0, z: 10 } },
{ name: "program-b.ngc", source: "G21\nG91\nG1 X5 F600\nG1 Y4 F600\nM2\n", delta: { x: 5, y: 4, z: 0 } },
{ name: "program-c.ngc", source: "G21\nG91\nG1 X-3 Y-2 Z2 F600\nM2\n", delta: { x: -3, y: -2, z: 2 } },
];
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "xyzac-multi-program-load" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const close = (actual, expected, label) => assert.ok(Math.abs(Number(actual) - Number(expected)) <= 1e-7, `${label}: ${actual} != ${expected}`);
const runToCompletion = () => {
const started = send("PROGRAM_RUN");
assert.equal(started.kind, "COMMAND_RESULT", JSON.stringify(started));
let response = started;
for (let index = 0; index < 2000; index += 1) {
response = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
assert.equal(response.kind, "COMMAND_RESULT", JSON.stringify(response));
if (response.payload.program?.program_status === "completed" && Number(response.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) return response;
}
throw new Error(`program_completion_timeout:${JSON.stringify(response.payload?.program)}`);
};
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const evidence = [];
let expectedPose = { x: 0, y: 0, z: 0 };
for (const program of programs) {
const before = session.wasmMotion.read().state.position;
close(before.x, expectedPose.x, `${program.name}:before.x`);
close(before.y, expectedPose.y, `${program.name}:before.y`);
close(before.z, expectedPose.z, `${program.name}:before.z`);
const loaded = send("PROGRAM_LOAD", { source: program.source, path: `browser/${program.name}` });
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify(loaded));
assert.equal(loaded.payload.program.source_hash, fnv1a64(program.source));
assert.equal(loaded.payload.program.current_line, 0);
const firstMotion = loaded.payload.preview.segments[0];
close(firstMotion.start.x, before.x, `${program.name}:preview.start.x`);
close(firstMotion.start.y, before.y, `${program.name}:preview.start.y`);
close(firstMotion.start.z, before.z, `${program.name}:preview.start.z`);
const completed = runToCompletion();
expectedPose = program.expected ?? {
x: before.x + program.delta.x,
y: before.y + program.delta.y,
z: before.z + program.delta.z,
};
close(completed.payload.wasm_motion.state.position.x, expectedPose.x, `${program.name}:final.x`);
close(completed.payload.wasm_motion.state.position.y, expectedPose.y, `${program.name}:final.y`);
close(completed.payload.wasm_motion.state.position.z, expectedPose.z, `${program.name}:final.z`);
evidence.push({ name: program.name, source_hash: loaded.payload.program.source_hash, preview_segments: loaded.payload.preview.segments.length, synchronized_start: { x: firstMotion.start.x, y: firstMotion.start.y, z: firstMotion.start.z }, final: expectedPose });
}
const beforeRoundTrip = session.wasmMotion.read().state.position;
const roundTrip = send("PROGRAM_LOAD", { source: programs[0].source, path: `browser/${programs[0].name}` });
assert.equal(roundTrip.payload.program.source_hash, evidence[0].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[1].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[2].source_hash);
close(roundTrip.payload.preview.segments[0].start.x, beforeRoundTrip.x, "roundtrip:start.x");
close(roundTrip.payload.preview.segments[0].start.y, beforeRoundTrip.y, "roundtrip:start.y");
close(roundTrip.payload.preview.segments[0].start.z, beforeRoundTrip.z, "roundtrip:start.z");
console.log(JSON.stringify({ schema: "xyzac-multi-program-load.v1", status: "verified", source: "axis.py:open_file_guts/task_plan_synch", programs: evidence, round_trip_hash: roundTrip.payload.program.source_hash }));

View File

@@ -136,8 +136,10 @@ record("machine", "Power after ESTOP", "POWER", "axis.py:commands.power", io.sen
const unsupported = [
{ button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" },
{ button: "Rapid Override", reason: "blocked_by_abi_rapidrate: selected WASM TP exposes max velocity but not c.rapidrate" },
{ button: "Angular Jog Speed", reason: "blocked_by_abi_angular_jog_scale: selected WASM TP exposes one shared jog scale" },
{ button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" },
{ button: "Tool Touch Off", reason: "UI emits fixed G43 and does not expose LinuxCNC G10 prompt/value flow" },
{ button: "Tool Touch Off", reason: "blocked_by_abi_tool_offsets: selected WASM interpreter has no canonical G10 L10/L11 tool-offset storage" },
{ button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" },
];
for (const item of unsupported) rows.push({ group: "scope", button: item.button, command: null, source: "axis.py source mapped", result: "blocked_by_abi", reason: item.reason });

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "xyzac-program-rerun" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
const source = "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A15 C20 F600\nG1 X0 Y0 Z10 A0 C0 F600\nM2\n";
assert.equal(send("PROGRAM_LOAD", { source, path: "rerun.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING");
let completed;
for (let index = 0; index < 1000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program.program_status === "completed" && completed.payload.wasm_motion.state.queue_depth === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(completed.payload.task.state, "STOPPED");
const rerunCompleted = send("PROGRAM_RUN");
assert.equal(rerunCompleted.kind, "COMMAND_RESULT");
assert.equal(rerunCompleted.payload.status, "verified");
assert.equal(rerunCompleted.payload.reopened_taskfile, true);
assert.equal(rerunCompleted.payload.start_line, 0);
assert.equal(rerunCompleted.payload.program.current_line, 0);
assert.equal(rerunCompleted.payload.task.state, "RUNNING");
const rerunStep = send("SIM_STEP", { cycles: 2, response: "motion_delta" });
assert.equal(rerunStep.kind, "COMMAND_RESULT", JSON.stringify(rerunStep));
assert.equal(rerunStep.payload.program?.program_status, "running", JSON.stringify(rerunStep));
assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
const rerunAborted = send("PROGRAM_RUN");
assert.equal(rerunAborted.kind, "COMMAND_RESULT");
assert.equal(rerunAborted.payload.status, "verified");
assert.equal(rerunAborted.payload.reopened_taskfile, true);
assert.equal(rerunAborted.payload.program.current_line, 0);
assert.equal(rerunAborted.payload.task.state, "RUNNING");
console.log(JSON.stringify({
schema: "xyzac-program-rerun.v1",
status: "verified",
source: "linuxcnc-emctaskmain-auto-run",
completed_rerun: { reopened_taskfile: rerunCompleted.payload.reopened_taskfile, start_line: rerunCompleted.payload.start_line },
aborted_rerun: { reopened_taskfile: rerunAborted.payload.reopened_taskfile, start_line: rerunAborted.payload.start_line },
}));

View File

@@ -48,7 +48,10 @@ const clear = send("VISUAL_CLEAR", "visual-clear");
assert.equal(clear.kind, "COMMAND_RESULT");
assert.equal(clear.payload.visual_only, true);
assert.deepEqual(clear.payload.before, clear.payload.after);
assert.equal(send("PROGRAM_RUN", "program-run").kind, "COMMAND_RESULT");
const run = send("PROGRAM_RUN", "program-run");
assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 });
assert.equal(runningSwitch.kind, "ERROR");
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_running");
@@ -78,7 +81,7 @@ const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.ur
const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8");
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-touch-off-source-flow" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `touch-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n", path: "touch.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const jogStart = send("JOG_START", { axis: "X", direction: 1, increment: null, continuous: true });
assert.equal(jogStart.payload.status, "verified");
let jogSample = jogStart;
for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
assert.equal(systemTouch.kind, "COMMAND_RESULT");
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.task.mode, "MANUAL");
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
assert.equal(send("TOOL_CHANGE", {}).payload.status, "verified");
assert.equal(send("TOOL_CHANGED", { changed: true }).payload.status, "verified");
const toolTouch = send("TOOL_TOUCH_OFF", { axis: "Z", value: 3, system: "G54", units: "mm" });
assert.equal(toolTouch.kind, "ERROR");
assert.equal(toolTouch.payload.gate_reason, "blocked_by_abi_tool_offsets");
const malformed = send("TOUCH_OFF", { axis: "Q", value: 0, system: "G54", units: "mm" });
assert.equal(malformed.kind, "ERROR");
assert.equal(malformed.payload.code, "touch_off_axis_invalid");
console.log(JSON.stringify({ schema: "xyzac-touch-off-source-flow.v1", status: "verified", source_block: systemTouch.payload.touch_off.source_block, wasm_block: systemTouch.payload.touch_off.block, tool_touch_off: toolTouch.payload.gate_reason, invalid: malformed.payload.code }));

View File

@@ -78,6 +78,7 @@ export class WasmProgramRuntime {
this.executionLines = [];
this.executionLineMetadata = [];
this.rapidVelocity = 35;
this.synchronizedPosition = Object.fromEntries(AXES.map((axis) => [axis, 0]));
if (!this.available || typeof core?._malloc !== "function") return;
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
this.motionPointer = core._malloc(SEGMENT_BYTES);
@@ -137,6 +138,8 @@ export class WasmProgramRuntime {
try {
const loaded = temporary.load(this.source, this.path);
if (loaded.status !== "verified") return loaded;
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
temporary.setRapidVelocity(this.rapidVelocity);
for (const subroutine of this.subroutines) {
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
@@ -158,6 +161,15 @@ export class WasmProgramRuntime {
}
start() { return this.invoke("sim_program_start", [], "program_start"); }
synchronizePosition(value) {
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
this.core.HEAPF64.set(AXES.map((axis) => position[axis]), this.samplePosePointer / 8);
const result = call(this.core, "sim_program_set_position", "number", ["number", "number"], [this.pointer, this.samplePosePointer]);
if (result !== 0) return programError("program_set_position", result);
this.synchronizedPosition = position;
return { status: "verified", command: "sim_program_set_position", position: { ...position } };
}
setRapidVelocity(velocity) {
if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" };
this.rapidVelocity = Number(velocity);
@@ -200,6 +212,8 @@ export class WasmProgramRuntime {
const subroutines = [...this.subroutines];
const loaded = this.load(this.source, this.path);
if (loaded.status !== "verified") throw new Error(loaded.code);
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
if (synchronized.status !== "verified") throw new Error(synchronized.code);
for (const subroutine of subroutines) {
const added = this.addSubroutine(subroutine.name, subroutine.source);
if (added.status !== "verified") throw new Error(added.code);
@@ -243,6 +257,15 @@ export class WasmProgramRuntime {
const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]);
if (result !== 0) return null;
const value = segment(this.core, this.motionPointer);
// LinuxCNC does not enqueue a trajectory segment when a linear/rapid
// block resolves to the current synchronized pose. This occurs naturally
// when AUTO_RUN reopens a completed/aborted file and its first positioning
// block names the position where the previous run stopped.
const linearNoop = value.motion_type === 1 && AXES.every((axis) => Math.abs(Number(value.start?.[axis] ?? 0) - Number(value.end?.[axis] ?? 0)) <= 1e-12);
if (linearNoop) {
call(this.core, "sim_program_clear_motion", "number", ["number"], [this.pointer]);
return null;
}
// LinuxCNC rapid blocks do not carry an F word. The trajectory queue
// requires a positive velocity, so use the simulation's configured rapid
// rate for that WASM-produced linear segment.

View File

@@ -139,6 +139,7 @@ export class WasmXyzacIo {
command(kind, payload = {}) {
if (kind === "RESET") return this.invoke("sim_io_reset");
if (kind === "SPINDLE_CONSTANT") return { status: "verified", command: "SPINDLE_CONSTANT", source: "linuxcnc_spindle_constant" };
if (kind === "SPINDLE_COMMAND") {
if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]);
const requestedRpm = Number(payload.rpm ?? payload.speed ?? 0);

View File

@@ -34,7 +34,11 @@ export class XyzacSession extends SimSession {
// every previous response inside the stored response object.
const payload = { ...response.payload };
payload.xyzac_session = this.authority();
payload.xyzac_ledger = this.ledger.audit({ includeResponses: input.kind !== "SIM_STEP", maxEntries: input.kind === "SIM_STEP" ? 32 : 2048 });
// The operation ledger remains complete inside the worker for recovery.
// Browser responses need only an audit summary: including historical
// PROGRAM_LOAD responses repeatedly clones the full toolpath after every
// control click, which can starve an abort behind a large G-code preview.
payload.xyzac_ledger = this.ledger.audit({ includeResponses: false, maxEntries: input.kind === "SIM_STEP" ? 32 : 128 });
payload.xyzac_scheduler = this.scheduler.export();
return { ...response, payload };
}