Files

905 lines
94 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { INITIAL_STATE, reduceFrontendState } from "./store";
import { ReactWorkerClient } from "./worker-client";
import { XyzacToolPreviewFromSnapshot } from "./XyzacToolPreview";
import { XyzacMachineViewFromSnapshot } from "./XyzacMachineView";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
import { MachineImportPanel } from "./MachineImportPanel";
import { loadImportedMachine, configureImportedMachine, updateImportedMachineGeometry, disposeMachineScene, type ImportedMachine } from "./imported-machine";
import { MAX_MACHINE_BYTES } from "./machine-urdf";
import { TrtGeometryPanel } from "./TrtGeometryPanel";
import { readTrtGeometry } from "../../workspace/trt-geometry.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
import { axisIcon } from "./sourceAssets";
import { HoldButton } from "./HoldButton";
import { MachineStatus } from "./MachineStatus";
import { AxisOperationController } from "../../app/axis-operation-controller.mjs";
import { XyzacDiagnostics, XyzacLiveParameters, XyzacProgramPane, XyzacTrtPanel } from "./XyzacTrtPanel";
import { projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract";
import { DEFAULT_MACHINE, MACHINE_DEFINITIONS, machineDefinition } from "../../workspace/machine-definitions.mjs";
import { RtcpProgramLibrary, type RtcpProgram } from "./RtcpProgramLibrary";
import { assertProgramMachine } from "../../workspace/program-machine-compatibility.mjs";
import { MachineManagerPanel } from "./MachineManagerPanel";
export const SELECTED_XYZAC_CASE = DEFAULT_MACHINE.entrypoint;
const DEFAULT_CASE = DEFAULT_MACHINE.entrypoint;
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 = { controlId: string; label: string; asset: string; onClick?: () => void; disabled?: boolean; disabledReason?: string; danger?: boolean; pressed?: boolean; feature?: string };
function IconButton({ controlId, label, asset, onClick, disabled = false, disabledReason, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) {
return <button type="button" data-axis-control={controlId} 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({ controlId, children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, disabledReason, danger = false, feature = "AXIS-UI-07" }: { controlId: string; 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-control={controlId} 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 }) {
const element = useRef<HTMLDetailsElement>(null);
useEffect(() => {
const outside = (event: PointerEvent) => { if (element.current && !element.current.contains(event.target as Node)) element.current.open = false; };
const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && element.current?.open) { event.preventDefault(); element.current.open = false; element.current.querySelector("summary")?.focus(); } };
window.addEventListener("pointerdown", outside);
window.addEventListener("keydown", escape);
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape); };
}, []);
// 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 ref={element} className="axis-menu-item" data-axis-feature="AXIS-UI-02" onClick={(event) => { if ((event.target as HTMLElement).closest("button") && element.current) element.current.open = false; }}><summary data-axis-control={`menu-${label.toLowerCase()}`}>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
}
function MenuAction({ controlId, children, onClick, shortcut, disabled = false, disabledReason }: { controlId: string; children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean; disabledReason?: string }) { return <button type="button" data-axis-control={controlId} onClick={onClick} disabled={disabled} title={disabled && disabledReason ? disabledReason : undefined}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>; }
function AxisRadio({ axis, selected, onChange, disabled, reason }: { axis: string; selected: string; onChange: (axis: string) => void; disabled: boolean; reason: string }) {
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" data-axis-control={`manual-axis-${axis}`} name="axis" value={axis} checked={selected === axis} disabled={disabled} title={disabled ? reason : undefined} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
}
function MachineSequence({ wasmReady, allHomed, canHome, homeReason, onHomeAll, children }: { wasmReady: boolean; allHomed: boolean; canHome: boolean; homeReason: string; onHomeAll: () => void; children?: React.ReactNode }) {
return <div className="machine-sequence-actions" data-axis-feature="AXIS-MACHINE-SEQUENCE" role="group" aria-label="Homing controls">
{children}
<TextButton controlId="manual-home-all" disabled={!canHome} disabledReason={homeReason} onClick={onHomeAll} ariaLabel={allHomed ? "Re-home all axes" : "Home all axes"}>{allHomed ? "RE-HOME ALL" : "HOME ALL"}</TextButton>
</div>;
}
function ManualControl({ action, axes = DEFAULT_MACHINE.axes, canJog, canHome, canSpindle, canCoolant, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, active, metric, reasons, devices }: { action: Action; axes?: readonly string[]; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: 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; active: boolean; metric: boolean; reasons: Record<string, string>; devices: Record<string, any> }) {
const [increment, setIncrement] = useState("Continuous");
const lastIncrement = useRef(1);
useEffect(() => {
const options = ["Continuous", "0.1000", "0.0100", "0.0010", "0.0001"];
const keydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (!active || event.ctrlKey || event.altKey || event.metaKey || event.repeat || target && (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.closest('dialog, [role="dialog"]'))) return;
if (event.key === "c") { event.preventDefault(); setIncrement("Continuous"); }
if (event.key === "i" || event.key === "I") {
event.preventDefault();
const index = options.indexOf(increment);
const next = index === 0 ? lastIncrement.current : Math.max(1, Math.min(4, index + (event.key === "i" ? 1 : -1)));
lastIncrement.current = next;
setIncrement(options[next]);
}
};
window.addEventListener("keydown", keydown);
return () => window.removeEventListener("keydown", keydown);
}, [active, increment]);
const displayedAxes = axes;
const jogging = useRef(false);
const spindleAdjusting = useRef(false);
const canSpindleAdjust = canSpindle && spindleDirection !== 0;
const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment), continuous: increment === "Continuous", units: metric ? "mm" : "inch" });
const jogRelease = increment === "Continuous" ? () => action("JOG_STOP") : undefined;
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">{displayedAxes.map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} disabled={!canJog} reason={reasons.jog} />)}</div></div>
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row">
<HoldButton controlId="manual-jog-negative" disabled={!canJog} disabledReason={reasons.jog} active={active} group={jogging} onPress={() => jog(-1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} negative`}>-</HoldButton>
<HoldButton controlId="manual-jog-positive" disabled={!canJog} disabledReason={reasons.jog} active={active} group={jogging} onPress={() => jog(1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} positive`}>+</HoldButton>
<select data-axis-control="manual-jog-increment" value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option>{WEB_SETTINGS.ui.jog_increments.map((value) => <option key={value} value={value}>{value}</option>)}</select></div>
<div className="manual-action-row"><TextButton controlId="manual-touch-off" disabled={!canTouchOff} disabledReason={reasons.touchOff} onClick={onTouchOff}>Touch Off</TextButton><TextButton controlId="manual-tool-touch-off" disabled={!canToolTouchOff} disabledReason="blocked_by_abi_tool_offsets" onClick={onToolTouchOff}>Tool Touch Off</TextButton></div><label className="check-line"><input data-axis-control="manual-override-limits" type="checkbox" disabled title="blocked_by_abi_override_limits: LinuxCNC WASM override-limits command unavailable" /> Override Limits</label></div>
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton controlId="manual-spindle-ccw" label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!canSpindle} disabledReason={reasons.spindle} pressed={spindleDirection === -1} onClick={() => onSpindle(-1)} /><TextButton controlId="manual-spindle-stop" disabled={!canSpindle} disabledReason={reasons.spindle} onClick={() => onSpindle(0)} ariaLabel="Stop spindle">Stop Spindle</TextButton><IconButton controlId="manual-spindle-cw" label="Spindle clockwise" asset="spindle_cw.gif" disabled={!canSpindle} disabledReason={reasons.spindle} pressed={spindleDirection === 1} onClick={() => onSpindle(1)} /></div><div className="spindle-row">
<HoldButton controlId="manual-spindle-decrease" disabled={!canSpindleAdjust} disabledReason={!canSpindle ? reasons.spindle : "axis_spindle_stopped"} active={active} group={spindleAdjusting} onPress={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })} onRelease={() => action("SPINDLE_CONSTANT", { spindle: 0 })} label="Decrease spindle speed">-</HoldButton>
<HoldButton controlId="manual-spindle-increase" disabled={!canSpindleAdjust} disabledReason={!canSpindle ? reasons.spindle : "axis_spindle_stopped"} active={active} group={spindleAdjusting} onPress={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })} onRelease={() => action("SPINDLE_CONSTANT", { spindle: 0 })} label="Increase spindle speed">+</HoldButton>
<label className="check-line"><input data-axis-control="manual-spindle-brake" type="checkbox" checked={spindleBrake} disabled={!canSpindle} title={!canSpindle ? reasons.spindle : undefined} onChange={(event) => void Promise.resolve(action("SPINDLE_BRAKE", { spindle: 0, engaged: event.target.checked })).catch(() => undefined)} /> Brake</label></div></div>
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input data-axis-control="manual-coolant-mist" type="checkbox" checked={coolant.mist} disabled={!canCoolant} title={!canCoolant ? reasons.coolant : undefined} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input data-axis-control="manual-coolant-flood" type="checkbox" checked={coolant.flood} disabled={!canCoolant} title={!canCoolant ? reasons.coolant : undefined} onChange={() => onCoolant("flood")} /> Flood</label></div>
</div><div className="manual-device-block"><h3>Tool / Probe:</h3><span className="device-status">{devices.tool !== undefined || devices.tool_state !== undefined ? `Tool ${devices.tool?.current ?? devices.current_tool ?? 0} / state ${devices.tool?.state ?? devices.tool_state ?? 0}; Probe ${devices.probe?.state ?? 0}` : "WASM state pending"}</span></div></section>;
}
function MdiControl({ action, ready, canSubmit, reason }: { action: Action; ready: boolean; canSubmit: boolean; reason: string }) {
const [command, setCommand] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(0);
const submit = (text = command) => {
const block = text.trim();
if (!block || !ready || !canSubmit) return;
const next = history.at(-1) === block ? history : [...history, block].slice(-1000);
setCommand(""); setHistory(next); setHistoryIndex(next.length);
void action("MDI_EXECUTE", { block });
};
return <section className="mdi-control" data-axis-feature="AXIS-CTRL-03" role="tabpanel" aria-label="MDI Control">
<label className="mdi-label">MDI Command<input data-axis-control="mdi-command" value={command} disabled={!ready} title={!ready ? reason : undefined}
onChange={(event) => setCommand(event.target.value)} onKeyDown={(event) => {
if (event.key === "Enter") { event.preventDefault(); if (!event.repeat) submit(); }
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const next = (historyIndex + (event.key === "ArrowUp" ? -1 : 1) + history.length + 1) % (history.length + 1);
setHistoryIndex(next); setCommand(history[next] ?? "");
requestAnimationFrame(() => event.target instanceof HTMLInputElement && event.target.select());
}
}} /></label>
<TextButton controlId="mdi-submit" feature="AXIS-CTRL-03" disabled={!ready} disabledReason={reason} onClick={() => submit()}>Go</TextButton>
<h3>MDI History</h3><ol>{history.length ? history.map((line, index) => <li key={`${line}-${index}`}>
<button type="button" data-axis-control="mdi-history-select" data-axis-feature="AXIS-CTRL-03" aria-pressed={index === historyIndex} disabled={!ready} title={!ready ? reason : undefined}
onClick={() => { setHistoryIndex(index); setCommand(line); }} onDoubleClick={() => submit(line)}>{line}</button>
</li>) : <li className="empty-history">No commands</li>}</ol>
</section>;
}
function DroView({ axes, state, coordType, displayType, metric, setCoordType, setDisplayType, setMetric }: { axes: number[]; state: typeof INITIAL_STATE; coordType: "machine" | "relative"; displayType: "actual" | "commanded"; metric: boolean; setCoordType: (value: "machine" | "relative") => void; setDisplayType: (value: "actual" | "commanded") => void; setMetric: (value: boolean) => void }) {
const machine = machineDefinition((state.snapshot?.kinematics as { coordinates?: string } | undefined)?.coordinates) ?? DEFAULT_MACHINE;
const labels = machine.axes.map((axis) => axis.toUpperCase());
const scale = metric ? 1 : 1 / 25.4;
const format = (value: number, index: number) => Number.isFinite(value) ? (value * (index < 3 ? scale : 1)).toFixed(4) : "-";
const sourceReady = state.snapshot?.motion_authority === "wasm" && Boolean(state.snapshot?.task_authority);
const pose = (state.snapshot?.visual?.pose ?? state.snapshot?.authoritative?.kinematics?.pose) as Record<string, number> | undefined;
const commanded = machine.axes.map((axis, index) => { const value = pose?.[axis]; return sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN; });
const actualPose = (state.snapshot?.visual?.pose ?? state.snapshot?.kinematics?.pose ?? {}) as Record<string, number>;
const actualAxes = machine.axes.map((axis, index) => { const value = actualPose[axis]; return sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN; });
const relative = sourceReady ? state.snapshot?.coordinates?.relative ?? [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN] : [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN];
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-control="dro-coordinate" data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-control="dro-display" data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-control="dro-units" data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = actualAxes[index]; const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
const PROGRAM_ROW_HEIGHT = 17.4;
const TRACE_ROW_HEIGHT = 23;
const TRACE_OVERSCAN = 12;
function VirtualizedExecutionTrace({ entries, executing }: { entries: Array<{ line: number; text: string; program_status?: string | null }>; executing: boolean }) {
const traceRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const [windowRange, setWindowRange] = useState({ start: 0, end: Math.min(entries.length, TRACE_OVERSCAN * 2 + 24) });
const updateWindow = () => {
const node = traceRef.current;
if (!node) return;
const start = Math.max(0, Math.floor(node.scrollTop / TRACE_ROW_HEIGHT) - TRACE_OVERSCAN);
const end = Math.min(entries.length, Math.ceil((node.scrollTop + node.clientHeight) / TRACE_ROW_HEIGHT) + TRACE_OVERSCAN);
setWindowRange((previous) => previous.start === start && previous.end === end ? previous : { start, end });
};
useEffect(() => {
const node = traceRef.current;
if (!node) return;
if (followRef.current) node.scrollTop = node.scrollHeight;
const frame = requestAnimationFrame(updateWindow);
return () => cancelAnimationFrame(frame);
}, [entries.length]);
const visible = entries.slice(windowRange.start, windowRange.end);
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, 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;
const executedLines = new Set(executionTrace.map((entry) => entry.line));
const editorRef = useRef<HTMLTextAreaElement>(null);
const gutterRef = useRef<HTMLDivElement>(null);
const highlightsRef = useRef<HTMLDivElement>(null);
const [editorWindow, setEditorWindow] = useState({ start: 0, end: Math.min(lines.length, 48) });
const updateEditorWindow = () => {
const node = editorRef.current;
if (!node) return;
const start = Math.max(0, Math.floor(node.scrollTop / PROGRAM_ROW_HEIGHT) - 12);
const end = Math.min(lines.length, Math.ceil((node.scrollTop + node.clientHeight) / PROGRAM_ROW_HEIGHT) + 12);
setEditorWindow((previous) => previous.start === start && previous.end === end ? previous : { start, end });
};
useEffect(() => {
const frame = requestAnimationFrame(updateEditorWindow);
return () => cancelAnimationFrame(frame);
}, [lines.length]);
useEffect(() => {
const node = editorRef.current;
if (!node || !mainLine) return;
const lineTop = (mainLine - 1) * PROGRAM_ROW_HEIGHT;
if (lineTop < node.scrollTop || lineTop + PROGRAM_ROW_HEIGHT > node.scrollTop + node.clientHeight) node.scrollTop = Math.max(0, lineTop - node.clientHeight * .35);
}, [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-control="program-editor" 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"><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 }> }) {
const configuration = state.snapshot?.configuration as Record<string, any> | null | undefined;
const diagnostics = Array.isArray(configuration?.diagnostics) ? configuration.diagnostics : [];
const gateRows = Object.entries(gates).filter(([, value]) => !value.enabled);
return <section className="axis-diagnostics" data-axis-feature="AXIS-UI-07" aria-label="Diagnostics">
<div className="diagnostic-summary"><strong>{configuration?.state ?? "NOT_LOADED"}</strong><span>Tick {state.tick}</span><span>Hash {state.snapshot?.state_hash ?? "-"}</span></div>
{state.error && <div className="diagnostic-error" role="alert"><b>{state.error.code}</b><span>{state.error.message ?? "Worker rejected the operation"}</span></div>}
<div className="diagnostic-section"><h3>Startup resources</h3>{configuration ? <><span>Entrypoint: {configuration.entrypoint ?? "-"}</span><span>Resources: {configuration.resources?.resolved_count ?? 0} resolved / {configuration.resources?.blocked_count ?? 0} blocked</span><span>HAL: {configuration.hal?.record_count ?? 0} records / {configuration.hal?.blocked_count ?? 0} blocked</span></> : <span>No configuration snapshot</span>}</div>
<div className="diagnostic-section"><h3>Disabled operations</h3>{gateRows.length ? gateRows.map(([name, value]) => <div className="diagnostic-row" key={name}><code>{name}</code><span>{value.reason}</span></div>) : <span>All panel operations are enabled</span>}</div>
<div className="diagnostic-section"><h3>Source diagnostics</h3>{diagnostics.length ? diagnostics.map((item: any, index: number) => <div className="diagnostic-row" key={`${item.code ?? "diagnostic"}-${index}`}><code>{item.code ?? "diagnostic"}</code><span>{item.path ?? item.source ?? "-"}{item.line ? `:${item.line}` : ""}</span></div>) : <span>No startup diagnostics</span>}</div>
</section>;
}
export function App() {
const client = useMemo(() => new ReactWorkerClient(), []);
const operationController = useMemo(() => new AxisOperationController(client), [client]);
const [state, setState] = useState(INITIAL_STATE);
const [casePath, setCasePath] = useState<string>(DEFAULT_CASE);
const [caseStatus, setCaseStatus] = useState("loading configured program...");
const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramTextState] = useState("");
const [programPath, setProgramPath] = useState(DEFAULT_MACHINE.program);
const [programLoading, setProgramLoading] = useState(true);
const [geometryBundle, setGeometryBundle] = useState<any>(null);
const [selectedAxis, setSelectedAxis] = useState("x");
const [selectedHomeJoint, setSelectedHomeJoint] = useState(0);
const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">(WEB_SETTINGS.ui.initial_view as "p");
const [zoom, setZoom] = useState(WEB_SETTINGS.ui.zoom);
const [rotateMode, setRotateMode] = useState(false);
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 [aboutOpen, setAboutOpen] = useState(false);
const aboutDialog = useRef<HTMLDivElement>(null);
const [layers, setLayers] = useState({ ...WEB_SETTINGS.ui.layers });
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const homeTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const runStepPending = useRef(false);
const homeStepPending = useRef(false);
const jogStepPending = useRef(false);
const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
const programFileInput = useRef<HTMLInputElement>(null);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
const feedOverridePending = useRef<number | null>(null);
const feedOverrideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uiCacheHydrated = useRef(false);
const selectedModeTab = useRef<"MANUAL" | "MDI">("MANUAL");
const lastSourceState = useRef({ on: false, idle: true });
const modeRestorePending = useRef(false);
const uiCacheSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [importedMachine, setImportedMachine] = useState<ImportedMachine | null>(null);
const configurationPending = useRef(false);
const importedScene = importedMachine?.scene;
useEffect(() => () => { if (importedScene) disposeMachineScene(importedScene); }, [importedScene]);
const selectedMachine = machineDefinition(casePath) ?? DEFAULT_MACHINE;
const UI_CACHE_KEY = selectedMachine.id;
useEffect(() => {
setSelectedAxis(selectedMachine.axes[3] ?? "a");
}, [selectedMachine.id]);
const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null);
const previewProgressTimer = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const unsubscribe = client.subscribe((message) => {
const payload = message.payload as Record<string, any> | undefined;
const panel = payload?.panel_snapshot;
if (panel) {
const on = panel.axis_state?.source_task_state === "STATE_ON";
const idle = panel.axis_state?.source_interp_state === "INTERP_IDLE";
const idleEdge = on && idle && (!lastSourceState.current.on || !lastSourceState.current.idle);
const fileOpened = payload?.command === "PROGRAM_LOAD" && message.kind !== "ERROR";
lastSourceState.current = { on, idle };
if (on && idle && !modeRestorePending.current && ((idleEdge && panel.task?.mode === "AUTO") || fileOpened && panel.task?.mode !== selectedModeTab.current)) {
// Queue the source idle/tab callback before a later user Run can
// enter AUTO. A React effect would send it after that Run.
modeRestorePending.current = true;
void operationController.execute("SET_MODE", { mode: selectedModeTab.current }, { gate: false })
.catch(() => undefined).finally(() => { modeRestorePending.current = false; });
}
}
setState((previous) => reduceFrontendState(previous, message));
});
client.ensureSession().then(async () => {
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;
if (cached) {
selectedModeTab.current = cached.leftTab === "mdi" ? "MDI" : "MANUAL";
if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
if (["x", "y", "z", "a", "b", "c"].includes(String(cached.selectedAxis)) && selectedMachine.axes.includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
if (["manual", "mdi", "diagnostics"].includes(String(cached.leftTab))) setLeftTab(cached.leftTab as "manual" | "mdi" | "diagnostics");
if (["preview", "dro"].includes(String(cached.rightTab))) setRightTab(cached.rightTab as "preview" | "dro");
if (["z", "z2", "x", "y", "y2", "p"].includes(String(cached.view))) setView(cached.view as "z" | "z2" | "x" | "y" | "y2" | "p");
if (typeof cached.zoom === "number" && Number.isFinite(cached.zoom)) setZoom(Math.max(WEB_SETTINGS.ui.zoom_min, Math.min(WEB_SETTINGS.ui.zoom_max, cached.zoom)));
if (typeof cached.rotateMode === "boolean") setRotateMode(cached.rotateMode);
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]|G(?:5[4-8]|59(?:\.[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) => {
const next = { ...current };
for (const key of Object.keys(current) as Array<keyof typeof current>) if (typeof savedLayers[key] === "boolean") next[key] = savedLayers[key] as boolean;
return next;
});
}
}
const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." });
if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`);
const source = bundle.files?.[DEFAULT_MACHINE.program];
if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
const normalizedSource = normalizeProgramSource(source);
setProgramTextState(normalizedSource); setProgramPath(DEFAULT_MACHINE.program);
const configuration = toWorkerConfiguration(bundle);
const workspace = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
setGeometryBundle(bundle);
const payload = (workspace.response as { payload?: { power_off_recovery?: { program?: { source: string; path: string | null } }; recovery_persistence?: { status?: string } } }).payload;
const restoredProgram = payload?.power_off_recovery?.program;
if (restoredProgram?.source) {
setProgramTextState(restoredProgram.source); setProgramPath(restoredProgram.path ?? DEFAULT_MACHINE.program);
} else await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: DEFAULT_MACHINE.program }, { state: {}, gate: false });
uiCacheHydrated.current = true;
setOperationTrace(operationController.exportTrace());
setCommandStatus(restoredProgram ? "已恢复关机前的程序和机床位置" : payload?.recovery_persistence?.status === "ignored" ? "恢复记录与当前版本或配置不匹配,已重新加载" : "PROGRAM_LOAD acknowledged");
setCaseStatus(`${DEFAULT_CASE} + ${restoredProgram?.path ?? DEFAULT_MACHINE.program} 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 (homeTimer.current) clearInterval(homeTimer.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 ?? [];
const taskRecord = state.task ?? {};
const axisState = { task: taskRecord, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates };
const xyzacView = useMemo(() => projectXyzacPanel(state.snapshot), [state.snapshot]);
// Runtime state is displayed only after a Worker panel snapshot is
// available. React may render before WASM boot, but it must not invent a
// machine/task/operation state that can enable motion.
const wasmSnapshotReady = Boolean(state.snapshot?.motion_authority === "wasm" && state.snapshot?.task_authority);
const machineState = wasmSnapshotReady ? state.axisState?.machine_state ?? "OFF" : "PENDING";
const operationState = wasmSnapshotReady ? state.axisState?.operation_state ?? "IDLE" : "PENDING";
const taskState = wasmSnapshotReady ? state.axisState?.state ?? "OFF" : "PENDING";
const isEstop = wasmSnapshotReady && machineState === "ESTOP";
const machineOn = wasmSnapshotReady && machineState === "ON";
const commandRefused = state.error?.code === "axis_gate";
const ready = wasmSnapshotReady && machineOn && taskRecord.state !== "FAULT"
&& state.status !== "recovering" && (state.status !== "error" || commandRefused);
const programRunning = operationState === "RUNNING";
const programPaused = operationState === "PAUSED";
const idle = operationState === "IDLE";
const wasmGate = (action: string) => {
const ui = state.snapshot?.ui_gates?.[action];
if (["load", "reload"].includes(action) && state.gates.load?.enabled === false) return state.gates.load;
return ui ?? state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" };
};
const gates = { load: wasmGate("load"), reload: wasmGate("reload"), 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 latestLoadGate = useRef(gates.load);
latestLoadGate.current = gates.load;
const controlReason = (name: keyof typeof gates) => programLoading ? "axis_program_load_in_progress" : gates[name].reason;
const relief = state.snapshot?.ui_relief ?? {};
const canLoad = !programLoading && gates.load.enabled;
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
const stopHomeTimer = () => { if (homeTimer.current) { clearInterval(homeTimer.current); homeTimer.current = null; } };
const startHomeTimer = () => {
if (homeTimer.current) return;
homeTimer.current = setInterval(() => {
if (homeStepPending.current) return;
homeStepPending.current = true;
void operationController.execute("SIM_STEP", { cycles: 5 }, { state: axisState, gate: false })
.then((operation) => {
const panel = (operation.response as { payload?: { panel_snapshot?: { motion?: { homing?: boolean; homed?: boolean } } } }).payload?.panel_snapshot;
if (panel?.motion?.homing === false) { stopHomeTimer(); setCommandStatus(panel.motion.homed ? "HOME complete" : "HOME finished; check joint status"); }
})
.catch(() => stopHomeTimer())
.finally(() => { homeStepPending.current = false; });
}, WEB_SETTINGS.ui.poll_ms);
};
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("SIM_STEP", { cycles: WEB_SETTINGS.ui.jog_cycles, response: "motion_delta" }, { state: axisState, gate: false })
.catch((error: Error) => { stopJogTimer(); setCommandStatus(`Jog stopped: ${error.message}`); })
.finally(() => { jogStepPending.current = false; });
}, WEB_SETTINGS.ui.poll_ms);
};
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: WEB_SETTINGS.ui.run_cycles, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, WEB_SETTINGS.ui.poll_ms); };
const dispatch = async (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => {
setCommandStatus(`${kind} pending`);
// Serialize browser samples around control edges in the Worker transport.
// Native AXIS continues its independent status polling while paused.
if (kind === "JOG_STOP") stopJogTimer();
if (["PROGRAM_PAUSE", "PROGRAM_ABORT"].includes(kind)) stopRunTimer();
if (["PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT"].includes(kind)) {
while (runStepPending.current) await new Promise((resolve) => setTimeout(resolve, 0));
}
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
setOperationTrace(operationController.exportTrace());
return pending.then((operation) => {
setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`);
if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer();
if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer();
const result = (operation.response as { payload?: { homing?: { phase?: string }; step_waiting_for_motion?: boolean; panel_snapshot?: { motion?: { homing?: boolean } } } }).payload;
if (kind === "HOME" && result?.homing?.phase === "accepted") startHomeTimer();
if (kind === "PROGRAM_STEP" && result?.step_waiting_for_motion === true) startRunTimer();
// Ordinary abort keeps sampling the native free_tp deceleration.
if (kind === "PROGRAM_ABORT") { if (result?.panel_snapshot?.motion?.homing) startHomeTimer(); else stopHomeTimer(); }
if (["ESTOP", "POWER"].includes(kind)) stopHomeTimer();
if (kind === "JOG_START") 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}`);
throw error;
});
};
const execute: Action = (kind, payload = {}) => dispatch(kind, payload).catch(() => undefined);
const runProgram = () => { if (programLoading) return; void dispatch("PROGRAM_RUN").catch(() => undefined); };
const stopProgram = (keyboard = false) => { if (!keyboard && !gates.abort.enabled) return; stopRunTimer(); stopJogTimer(); 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 (programLoading || !gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); };
const homeAll = async () => {
if (programLoading || !gates.home.enabled) return;
if (axisState.task.mode !== "MANUAL") {
try { await dispatch("SET_MODE", { mode: "MANUAL" }); } catch { return; }
}
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 = () => {
if (programLoading || !gates.home.enabled) return;
const joint = selectedHomeJoint;
const confirm = globalThis.confirm;
if (Boolean(homedAxes[joint]) && typeof confirm === "function" && !confirm(`Joint ${joint} is already homed. Re-home it?`)) return;
void dispatch("HOME", { joint }).catch(() => undefined);
};
const stopPreviewProgress = () => {
if (previewProgressTimer.current) {
clearInterval(previewProgressTimer.current);
previewProgressTimer.current = null;
}
};
const startPreviewProgress = (label: string) => {
stopPreviewProgress();
setPreviewProgress({ percent: 8, phase: "reading", detail: `${label}: 读取 G-code` });
previewProgressTimer.current = setInterval(() => {
setPreviewProgress((previous) => {
if (!previous || previous.percent >= 88) return previous;
return { percent: Math.min(88, previous.percent + 4), phase: "wasm", detail: `${label}: LinuxCNC WASM 解析刀具路径` };
});
}, WEB_SETTINGS.ui.preview_progress_ms);
};
const markPreviewProgress = (percent: number, phase: "reading" | "wasm", detail: string) => {
setPreviewProgress({ percent, phase, detail });
};
const finishPreviewProgress = (path: string, response: unknown) => {
stopPreviewProgress();
const payload = (response && typeof response === "object" ? (response as { payload?: unknown }).payload : null) as { preview?: { segments?: unknown[] } } | null;
const segments = Array.isArray(payload?.preview?.segments) ? payload.preview.segments : [];
const samples = segments.reduce<number>((count, segment) => count + (segment && typeof segment === "object" && Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
const detail = segments.length ? `WASM 刀具路径完成: ${segments.length} 段 / ${samples} 点` : "WASM 刀具路径完成";
setPreviewProgress({ percent: 100, phase: "ready", detail: `${path}: ${detail}` });
};
const failPreviewProgress = (message: string) => {
stopPreviewProgress();
setPreviewProgress({ percent: 100, phase: "error", detail: `刀具路径加载失败: ${message}` });
};
const loadCase = async (requestedCasePath = casePath) => {
if (!canLoad) return;
const requestedMachine = machineDefinition(requestedCasePath) ?? DEFAULT_MACHINE;
setProgramLoading(true);
startPreviewProgress(`${requestedMachine.coordinates} 程序`);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setCaseStatus("loading session...");
try {
await client.ensureSession();
setCaseStatus(`loading exact ${requestedMachine.coordinates} case...`);
const bundle = await loadSimulationCase({ path: requestedCasePath, baseUrl: "." });
if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`);
const programPathForCase = requestedMachine.program;
const source = bundle.files?.[programPathForCase];
if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
const normalizedSource = normalizeProgramSource(source);
setCaseStatus("starting exact case...");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
if (machineOn) await operationController.execute("POWER", { active: false }, { gate: false });
const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false });
setGeometryBundle(bundle);
setCasePath(requestedCasePath);
setImportedMachine(null);
const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload;
markPreviewProgress(28, "wasm", "LinuxCNC WASM 配置已加载,准备解析刀具路径");
setProgramText(normalizedSource); setProgramPath(programPathForCase);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: programPathForCase }, { state: axisState, gate: false });
finishPreviewProgress(programPathForCase, programLoad.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${bundle.entrypoint} + ${programPathForCase} -> ${resultPayload?.configuration?.state ?? "READY"}`);
} catch (error) {
setOperationTrace(operationController.exportTrace());
failPreviewProgress(error instanceof Error ? error.message : "case_load_failed");
setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`);
} finally { setProgramLoading(false); }
};
const setProgramText = (value: string) => { setProgramTextState(value); };
const applyGeometry = async (source: any, model?: { current: ImportedMachine | null; committed: boolean }) => {
if (configurationPending.current || programLoading || machineOn || programRunning || programPaused) throw new Error("关机且程序空闲后可修改");
configurationPending.current = true;
setProgramLoading(true);
let workspaceRequested = false;
let workspaceCommitted = false;
try {
const bundle = typeof source === "function" ? await source() : source;
readTrtGeometry(bundle);
const nextModel = model ? model.current : importedMachine ? updateImportedMachineGeometry(importedMachine, bundle) : null;
const targetMachine = machineDefinition(bundle.entrypoint);
if (!targetMachine) throw new Error("目标机床类型无法识别");
const machineChanged = targetMachine.coordinates !== selectedMachine.coordinates;
const nextProgramPath = machineChanged ? targetMachine.program : programPath;
const nextProgramSource = machineChanged ? normalizeProgramSource(bundle.files?.[nextProgramPath] ?? "") : programText;
if (!nextProgramSource.trim()) throw new Error("目标机型程序资源缺失");
assertProgramMachine(nextProgramSource, targetMachine.coordinates, nextProgramPath);
const configuration = toWorkerConfiguration(bundle);
startPreviewProgress("机床参数");
workspaceRequested = true;
await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { gate: false });
workspaceCommitted = true;
setGeometryBundle(bundle);
setCasePath(bundle.entrypoint);
setImportedMachine(nextModel);
if (model) model.committed = true;
setProgramText(nextProgramSource); setProgramPath(nextProgramPath);
const loaded = await operationController.execute("PROGRAM_LOAD", { source: nextProgramSource, path: nextProgramPath }, { gate: false });
finishPreviewProgress(nextProgramPath, loaded.response);
setCaseStatus(`${targetMachine.coordinates}: ${nextProgramPath} loaded through linuxcnc-wasm`);
setCommandStatus(machineChanged ? `已切换为 ${targetMachine.coordinates} 并加载对应程序,请重新回零` : "机床参数已应用,程序已重新解析,待重新回零");
} catch (error) {
if (workspaceRequested) failPreviewProgress(error instanceof Error ? error.message : String(error));
else setCommandStatus(`机床配置未应用:${error instanceof Error ? error.message : String(error)}`);
if (workspaceCommitted) throw new Error(`机床已应用,但当前程序无法重新解析:${error instanceof Error ? error.message : String(error)}`);
throw error;
} finally {
configurationPending.current = false;
setOperationTrace(operationController.exportTrace());
setProgramLoading(false);
}
};
const importMachine = async (source: File | string) => {
const model: { current: ImportedMachine | null; committed: boolean } = { current: null, committed: false };
try {
await applyGeometry(async () => {
let bytes: ArrayBuffer;
if (typeof source === "string") {
const response = await fetch(source);
if (!response.ok) throw new Error("示例 GLB 读取失败");
bytes = await response.arrayBuffer();
} else {
if (source.size > MAX_MACHINE_BYTES) throw new Error("GLB 最大 64 MiB");
bytes = await source.arrayBuffer();
}
model.current = await loadImportedMachine(bytes, typeof source === "string" ? source.split("/").at(-1)! : source.name);
const definition = machineDefinition(model.current.structure.coordinates)!;
const bundle = geometryBundle?.entrypoint === definition.entrypoint ? geometryBundle : await loadSimulationCase({ path: definition.entrypoint, baseUrl: "." });
if (!bundle || ("status" in bundle && bundle.status !== "ready")) throw new Error("机床运行配置未就绪");
return configureImportedMachine(bundle, model.current.structure);
}, model);
} finally { if (model.current && !model.committed) disposeMachineScene(model.current.scene); }
};
const applyManagedMachine = async (candidate: ImportedMachine) => {
const model: { current: ImportedMachine | null; committed: boolean } = { current: candidate, committed: false };
try {
await applyGeometry(async () => {
const definition = machineDefinition(candidate.structure.coordinates)!;
const bundle = geometryBundle?.entrypoint === definition.entrypoint ? geometryBundle : await loadSimulationCase({ path: definition.entrypoint, baseUrl: "." });
if (!bundle || ("status" in bundle && bundle.status !== "ready")) throw new Error("机床运行配置未就绪");
return configureImportedMachine(bundle, candidate.structure);
}, model);
} finally { if (!model.committed) disposeMachineScene(candidate.scene); }
};
const reloadGeometry = () => applyGeometry(async () => {
const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." });
if (bundle.status !== "ready") throw new Error(`INI blocked: ${bundle.missing.join(",")}`);
return bundle;
});
const loadPackagedProgram = async () => {
if (!canLoad) return;
const path = selectedMachine.program;
setProgramLoading(true);
startPreviewProgress(`${selectedMachine.coordinates} demo`);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setCaseStatus(`${path} loading from published workspace...`);
try {
const source = normalizeProgramSource((await loadSimulationCase({ path: casePath, baseUrl: "." })).files?.[path] ?? "");
if (!source.trim()) throw new Error("program_asset_empty");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
setProgramText(source); setProgramPath(path);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false });
finishPreviewProgress(path, programLoad.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${selectedMachine.coordinates}: ${path} loaded through linuxcnc-wasm`);
} catch (error) {
setOperationTrace(operationController.exportTrace());
failPreviewProgress(error instanceof Error ? error.message : "program_asset_load_failed");
setCaseStatus(`blocked: ${error instanceof Error ? error.message : "program_asset_load_failed"}`);
} finally { setProgramLoading(false); }
};
const loadRtcpProgram = async (program: RtcpProgram, source: string) => {
if (!canLoad) throw new Error(controlReason("load"));
if (program.role !== "main" || !program.coordinates.includes(selectedMachine.coordinates)) throw new Error(`rtcp_program_incompatible:${program.coordinates.join("/")}`);
const normalizedSource = normalizeProgramSource(source);
setProgramLoading(true);
startPreviewProgress(program.label);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setCaseStatus(`${program.path} loading from RTCP library...`);
try {
if (!normalizedSource.trim()) throw new Error("program_asset_empty");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
setProgramText(normalizedSource); setProgramPath(program.path);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析 RTCP 刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: program.path }, { state: axisState, gate: false });
finishPreviewProgress(program.path, programLoad.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${selectedMachine.coordinates}: ${program.label} loaded through linuxcnc-wasm`);
} catch (error) {
setOperationTrace(operationController.exportTrace());
const message = error instanceof Error ? error.message : "rtcp_program_load_failed";
failPreviewProgress(message);
setCommandStatus(`PROGRAM_LOAD blocked: ${message}`);
setCaseStatus(`blocked: ${message}`);
throw error;
} finally { setProgramLoading(false); }
};
const loadProgram = () => {
if (programLoading || !gates.reload.enabled) return;
setProgramLoading(true);
startPreviewProgress(programPath || "当前程序");
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
void Promise.resolve()
.then(() => {
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
assertProgramMachine(programText, selectedMachine.coordinates, programPath);
return operationController.execute("PROGRAM_LOAD", { source: programText, path: programPath }, { state: axisState, gate: false });
})
.then((operation) => {
finishPreviewProgress(programPath, operation.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${programPath} loaded through linuxcnc-wasm`);
})
.catch((error) => {
setOperationTrace(operationController.exportTrace());
failPreviewProgress(error instanceof Error ? error.message : "program_load_failed");
setCommandStatus(`PROGRAM_LOAD blocked: ${error instanceof Error ? error.message : "program_load_failed"}`);
})
.finally(() => setProgramLoading(false));
};
const openProgramFile = () => { if (canLoad) programFileInput.current?.click(); };
const onProgramFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !canLoad) return;
setProgramLoading(true);
startPreviewProgress(file.name);
try {
const source = normalizeProgramSource(await file.text());
if (!source.trim()) throw new Error("program_file_empty");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
const path = file.name === "3D_Chips.ngc" ? "linuxcnc/nc_files/3D_Chips.ngc" : `browser/${file.name}`;
assertProgramMachine(source, selectedMachine.coordinates, path);
setProgramText(source);
setProgramPath(path);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setCaseStatus(`${file.name} loading through linuxcnc-wasm...`);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false });
finishPreviewProgress(path, programLoad.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${path} loaded through linuxcnc-wasm`);
} catch (error) {
setOperationTrace(operationController.exportTrace());
failPreviewProgress(error instanceof Error ? error.message : "program_file_load_failed");
setCommandStatus(`PROGRAM_LOAD blocked: ${error instanceof Error ? error.message : "program_file_load_failed"}`);
setCaseStatus(`blocked: ${error instanceof Error ? error.message : "program_file_load_failed"}`);
} finally {
setProgramLoading(false);
}
};
const enterManualMode = () => {
selectedModeTab.current = "MANUAL";
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 = () => {
selectedModeTab.current = "MDI";
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 (event.repeat || event.isComposing) return;
if (event.key === "F1") { event.preventDefault(); toggleEstop(); return; }
if (event.defaultPrevented) return;
if (aboutOpen) {
if (event.key === "Escape") { event.preventDefault(); setAboutOpen(false); }
if (event.key === "Tab") { event.preventDefault(); aboutDialog.current?.querySelector("button")?.focus(); }
return;
}
if (document.querySelector(".machine-dialog[open]")) return;
if (event.key === "Escape") { event.preventDefault(); stopProgram(true); return; }
if (target && (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable)) return;
if (event.altKey || event.metaKey) 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, source_keyboard: true }); return; }
if (!event.ctrlKey && event.key === "b") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: false, source_keyboard: true }); return; }
const key = event.key;
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").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 (event.ctrlKey) return;
if (event.shiftKey && event.key === "Home") { event.preventDefault(); setCommandStatus("ZERO_G54 blocked: blocked_by_abi_clear_offset"); return; }
if (key === "F3") { event.preventDefault(); enterManualMode(); return; }
if (key === "F5") { event.preventDefault(); enterMdiMode(); 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 !== 0 ? 0 : 1, true); return; }
if (key === "F10") { event.preventDefault(); onSpindle(spindleDirection !== 0 ? 0 : -1, true); 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 (["x", "y", "z", "a", "b", "c"].includes(key)) { event.preventDefault(); if (gates.jog.enabled && selectedMachine.axes.includes(key)) setSelectedAxis(key); return; }
if (key === "o") { event.preventDefault(); openProgramFile(); 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(); if (state.task?.mode === "AUTO") execute("PROGRAM_PAUSE"); return; }
if (key === "s") { event.preventDefault(); execute("PROGRAM_RESUME"); return; }
if (key === "Escape") { event.preventDefault(); stopProgram(); }
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [axisState, enterManualMode, powerOn, runProgram, stopProgram, toggleEstop, aboutOpen, selectedMachine.id]);
useEffect(() => {
if (!aboutOpen) return;
const previous = document.activeElement;
aboutDialog.current?.querySelector("button")?.focus();
return () => { if (previous instanceof HTMLElement) (previous.closest("details")?.querySelector("summary") ?? previous).focus(); };
}, [aboutOpen]);
useEffect(() => {
const wasmQueueDepth = Number((state.snapshot?.wasm_motion as { state?: { queue_depth?: number } } | undefined)?.state?.queue_depth ?? 0);
const programComplete = String(state.program?.program_status ?? "") === "completed" && wasmQueueDepth === 0;
const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0;
if ((programRunning && !programComplete) || mdiMotionActive || Boolean(state.snapshot?.motion?.stepping)) startRunTimer(); else stopRunTimer();
if (wasmQueueDepth === 0 && !jogStepPending.current) stopJogTimer();
if (!state.snapshot?.motion?.homing && !homeStepPending.current) stopHomeTimer();
if (!ready) { stopRunTimer(); stopHomeTimer(); 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 spindleDirection = Number(spindleState?.direction ?? 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, sourceKeyboard = false) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 1, enabled: direction !== 0, source_keyboard: sourceKeyboard }); };
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; }
if (programLoading || !gates.touchOff.enabled) 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 (!entered.trim() || !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]|G(?:5[4-8]|59(?:\.[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 maxFeedPercent = Number((state.snapshot?.motion as Record<string, unknown> | undefined)?.max_feed_override ?? WEB_SETTINGS.runtime.planner.max_feed_override) * 100;
const sourceFeedOverride = Number(state.snapshot?.overrides?.feed ?? wasmMotion.feed_override);
const feedOverridePercent = Number.isFinite(sourceFeedOverride) ? Math.max(0, Math.min(maxFeedPercent, Math.round(sourceFeedOverride * 100))) : null;
const sourceJogScale = Number((state.snapshot?.motion as Record<string, unknown> | undefined)?.jog_scale ?? wasmMotion.jog_scale);
const sourceVelocityScale = Number((state.snapshot?.motion as Record<string, unknown> | undefined)?.velocity_scale ?? wasmMotion.velocity_scale);
const jogScalePercent = Number.isFinite(sourceJogScale) ? Math.max(WEB_SETTINGS.ui.motion_scale_min * 100, Math.min(100, Math.round(sourceJogScale * 100))) : null;
const maxVelocityPercent = Number.isFinite(sourceVelocityScale) ? Math.max(WEB_SETTINGS.ui.motion_scale_min * 100, Math.min(100, Math.round(sourceVelocityScale * 100))) : null;
const spindleOverrideSource = Number(state.snapshot?.overrides?.spindle?.[0] ?? (state.snapshot?.motion as Record<string, unknown> | undefined)?.spindle_override ?? spindleState?.override);
const spindleOverridePercent = Number.isFinite(spindleOverrideSource) ? Math.max(0, Math.min(200, Math.round(spindleOverrideSource * 100))) : null;
const requestFeedOverride = (event: React.ChangeEvent<HTMLInputElement>) => {
const percent = Math.max(0, Math.min(maxFeedPercent, Number(event.target.value)));
if (!Number.isFinite(percent)) return;
feedOverridePending.current = percent;
if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current);
feedOverrideTimer.current = setTimeout(() => {
feedOverrideTimer.current = null;
const target = feedOverridePending.current;
if (target === null) return;
void dispatch("SET_OVERRIDE", { feed: target / 100 }).then(() => { if (feedOverridePending.current === target) feedOverridePending.current = null; }).catch(() => { if (feedOverridePending.current === target) feedOverridePending.current = null; });
}, WEB_SETTINGS.ui.override_debounce_ms);
};
const activeGcodes = programLoading ? "" : state.program?.active_gcodes ? String(state.program.active_gcodes) : "-";
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
const currentLine = Number(state.program?.line ?? state.task?.current_line ?? 0) || null;
const executionLine = Number(state.program?.execution_line ?? currentLine ?? 0) || null;
const executionLineText = String(state.program?.execution_line_text ?? "");
const visibleExecutionTrace = programLoading ? [] : state.executionTrace;
const visibleCurrentLine = programLoading ? null : currentLine;
const visibleExecutionLine = programLoading ? null : executionLine;
const visibleExecutionLineText = programLoading ? "" : executionLineText;
const visibleProgramStatus = programLoading ? "loading" : String(state.program?.program_status ?? "unknown");
const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : [];
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
const xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).catch(() => undefined); };
const loadedProgramPath = String(state.program?.path ?? programPath);
const loadedProgramName = loadedProgramPath.split("/").at(-1) ?? loadedProgramPath;
useEffect(() => {
if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
const cacheState = {
version: 2,
selectedAxis,
leftTab,
rightTab,
view,
zoom,
rotateMode,
coordType,
displayType,
metric,
layers,
casePath,
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, touchOffSystem, layers, casePath]);
return <main data-axis-state-machine={machineState} data-axis-state-operation={operationState} data-axis-state={taskState} 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">Connection: {state.status.toUpperCase()}</span></header>
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction controlId="menu-file-edit-tool-data" disabled disabledReason="host_boundary_tool_editor">Edit tool data...</MenuAction><MenuAction controlId="menu-file-quit" disabled disabledReason="host_boundary_process_quit">Quit</MenuAction></Menu><Menu label="Help"><MenuAction controlId="menu-help-about" onClick={() => setAboutOpen(true)}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton controlId="toolbar-estop" feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={relief.estop ?? isEstop} /><IconButton controlId="toolbar-power" 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} pressed={relief.power} /><Separator /><IconButton controlId="toolbar-open" feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={!canLoad} disabledReason={controlReason("load")} onClick={openProgramFile} /><IconButton controlId="toolbar-load-case" feature="AXIS-FILE-02" label={`Load exact ${selectedMachine.label} case`} asset="tool_verify.gif" disabled={!gates.load.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.load.reason} onClick={loadCase} /><IconButton controlId="toolbar-reload" feature="AXIS-FILE-04" label="Reload current G-code [Ctrl-R]" asset="tool_reload.gif" disabled={!gates.reload.enabled || programLoading} disabledReason={controlReason("reload")} onClick={loadProgram} /><Separator /><IconButton controlId="toolbar-run" 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} pressed={relief.run} /><IconButton controlId="toolbar-step" 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 controlId="toolbar-pause-resume" 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")} pressed={relief.pauseResume} /><IconButton controlId="toolbar-stop" feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} disabledReason={gates.abort.reason} onClick={() => stopProgram()} pressed={relief.abort} /><Separator /><IconButton controlId="toolbar-block-delete" 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 controlId="toolbar-optional-pause" 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 controlId="toolbar-zoom-in" feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(WEB_SETTINGS.ui.zoom_max, value + WEB_SETTINGS.ui.zoom_step))} /><IconButton controlId="toolbar-zoom-out" feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(WEB_SETTINGS.ui.zoom_min, value - WEB_SETTINGS.ui.zoom_step))} /><IconButton controlId="toolbar-view-z" feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton controlId="toolbar-view-z2" feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton controlId="toolbar-view-x" feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton controlId="toolbar-view-y" feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton controlId="toolbar-view-y2" feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton controlId="toolbar-view-p" feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton controlId="toolbar-rotate" feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton controlId="toolbar-clear-live-plot" 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-control="workspace-case-select" data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => { const path = event.target.value; void loadCase(path); }}>{MACHINE_DEFINITIONS.map((machine) => <option key={machine.entrypoint} value={machine.entrypoint}>{machine.label}</option>)}</select></label><TextButton controlId="workspace-load-boat" feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!canLoad} disabledReason={controlReason("load")}>Load {selectedMachine.coordinates} boat program</TextButton><RtcpProgramLibrary coordinates={selectedMachine.coordinates} disabled={!canLoad} disabledReason={controlReason("load")} onLoad={loadRtcpProgram} /><span className={`case-status${programLoading ? " loading" : ""}`} title={caseStatus.startsWith("blocked:") ? caseStatus : loadedProgramPath} role="status" aria-live="polite" aria-busy={programLoading}>{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}</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>}</section>
<MachineStatus status={state.snapshot?.source_status} commandStatus={commandStatus} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} loading={programLoading} machineOptions={MACHINE_DEFINITIONS} selectedMachine={casePath} onMachineChange={(path) => { void loadCase(path); }} />
<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" data-axis-control="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" data-axis-control="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" data-axis-control="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"}><MachineSequence wasmReady={wasmSnapshotReady} allHomed={allHomed} canHome={gates.home.enabled && !programLoading} homeReason={controlReason("home")} onHomeAll={homeAll}><label>Joint <select data-axis-control="manual-home-joint-select" aria-label="Homing joint" value={selectedHomeJoint} disabled={programLoading || !gates.home.enabled} title={controlReason("home")} onChange={(event) => setSelectedHomeJoint(Number(event.target.value))}>{selectedMachine.axes.map((_, j) => <option key={j} value={j}>{j}</option>)}</select></label><TextButton controlId="manual-home-axis" disabled={programLoading || !gates.home.enabled} disabledReason={controlReason("home")} onClick={homeAxis}>Home Joint</TextButton></MachineSequence><ManualControl axes={selectedMachine.axes} action={dispatch} canJog={!programLoading && gates.jog.enabled} canHome={!programLoading && gates.home.enabled} canSpindle={!programLoading && gates.spindle.enabled} canCoolant={!programLoading && gates.coolant.enabled} canTouchOff={!programLoading && gates.touchOff.enabled} canToolTouchOff={!programLoading && 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)} active={leftTab === "manual"} metric={metric} reasons={Object.fromEntries(Object.keys(gates).map((name) => [name, controlReason(name as keyof typeof gates)]))} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} canSubmit={Boolean(state.gates.mdi?.enabled)} ready={!programLoading && gates.mdi.enabled} reason={controlReason("mdi")} /></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" data-axis-control="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" data-axis-control="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"}><div className="axis-preview-split" data-xyzac-preview-layout="split"><section className="axis-preview-pane axis-preview-toolpath" aria-label="Toolpath and live tool simulation"><XyzacToolPreviewFromSnapshot snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></section><section className="axis-preview-pane axis-preview-machine" aria-label={`${selectedMachine.coordinates} machine model motion`}><div className="machine-preview-actions"><MachineManagerPanel currentMachine={importedMachine} snapshot={state.snapshot} coordinates={selectedMachine.coordinates} disabled={programLoading || machineOn || programRunning || programPaused || !wasmSnapshotReady} onApply={applyManagedMachine} /><MachineImportPanel model={importedMachine} disabled={programLoading || machineOn || programRunning || programPaused || !wasmSnapshotReady} onImport={importMachine} /><TrtGeometryPanel bundle={geometryBundle} disabled={machineOn || programRunning || programPaused || !wasmSnapshotReady} busy={programLoading} onApply={applyGeometry} onReload={reloadGeometry} /></div><XyzacMachineViewFromSnapshot importedMachine={importedMachine} snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></section></div></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" data-axis-control="override-jog-speed" min={WEB_SETTINGS.ui.motion_scale_min * 100} max="100" value={jogScalePercent ?? WEB_SETTINGS.ui.motion_scale_min * 100} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={programLoading || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" data-axis-control="override-feed" min="0" max={maxFeedPercent} step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={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" data-axis-control="override-spindle" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={programLoading || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" data-axis-control="override-max-velocity" min={WEB_SETTINGS.ui.motion_scale_min * 100} max="100" value={maxVelocityPercent ?? WEB_SETTINGS.ui.motion_scale_min * 100} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={programLoading || 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" data-axis-control="override-rapid" 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-control={`preview-layer-${layer}`} 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} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" data-axis-control="program-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
{aboutOpen && <section className="axis-about-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setAboutOpen(false); }}><div ref={aboutDialog} className="axis-about-dialog" role="dialog" aria-modal="true" aria-labelledby="axis-about-title"><img src={axisIcon("axis-24x24.png")} alt="" /><h2 id="axis-about-title">LinuxCNC AXIS Web/WASM</h2><p>{selectedMachine.label}</p><TextButton controlId="about-close" onClick={() => setAboutOpen(false)}>OK</TextButton></div></section>}
</main>;
}