Implement AXIS parity UI and execution baseline
This commit is contained in:
110
web/react/src/App.tsx
Normal file
110
web/react/src/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { INITIAL_STATE, reduceFrontendState } from "./store";
|
||||
import { ReactWorkerClient } from "./worker-client";
|
||||
import { ThreeMachineView } from "./ThreeMachineView";
|
||||
import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
|
||||
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/minimal_xyz.ini";
|
||||
const DEFAULT_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
|
||||
|
||||
type Action = (kind: string, payload?: Record<string, unknown>) => void;
|
||||
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean };
|
||||
|
||||
function IconButton({ label, asset, onClick, disabled = false, danger = false, pressed = false }: IconButtonProps) {
|
||||
return <button type="button" className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
|
||||
}
|
||||
|
||||
function TextButton({ children, onClick, disabled = false, danger = false }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; danger?: boolean }) {
|
||||
return <button type="button" className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} disabled={disabled}>{children}</button>;
|
||||
}
|
||||
|
||||
function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; }
|
||||
|
||||
function Menu({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <details className="axis-menu-item"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
|
||||
}
|
||||
|
||||
function MenuAction({ children, onClick, shortcut }: { children: React.ReactNode; onClick?: () => void; shortcut?: string }) {
|
||||
return <button type="button" onClick={onClick}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
|
||||
}
|
||||
|
||||
function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) {
|
||||
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
|
||||
}
|
||||
|
||||
function ManualControl({ action, ready, selectedAxis, setSelectedAxis, spindleOn, coolant, onSpindle, onCoolant }: { action: Action; ready: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void }) {
|
||||
const [increment, setIncrement] = useState("Continuous");
|
||||
const [jogSpeed, setJogSpeed] = useState(50);
|
||||
const [maxVelocity, setMaxVelocity] = useState(100);
|
||||
const [overrideLimits, setOverrideLimits] = useState(false);
|
||||
const [brake, setBrake] = useState(false);
|
||||
const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, speed: increment === "Continuous" ? 1 : Number(increment) });
|
||||
return <section className="manual-control" aria-label="Manual Control"><div className="manual-grid">
|
||||
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
|
||||
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!ready} onClick={() => jog(-1)}>-</TextButton><TextButton disabled={!ready} onClick={() => jog(1)}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!ready} onClick={() => action("JOG_STOP")}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!ready} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" checked={overrideLimits} onChange={(event) => setOverrideLimits(event.target.checked)} /> Override Limits</label></div>
|
||||
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!ready} pressed={spindleOn === false} onClick={() => onSpindle(-1)} /><TextButton disabled={!ready} onClick={() => onSpindle(0)}>Stop</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!ready} pressed={spindleOn} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>-</TextButton><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+</TextButton><label className="check-line"><input type="checkbox" checked={brake} onChange={(event) => setBrake(event.target.checked)} /> Brake</label></div></div>
|
||||
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} onChange={() => onCoolant("flood")} /> Flood</label></div>
|
||||
</div><div className="manual-sliders"><label>Jog Speed:<output>{jogSpeed}%</output><input type="range" min="6" max="100" value={jogSpeed} onChange={(event) => setJogSpeed(Number(event.target.value))} /></label><label>Max Velocity:<output>{maxVelocity}%</output><input type="range" min="6" max="100" value={maxVelocity} onChange={(event) => setMaxVelocity(Number(event.target.value))} /></label></div></section>;
|
||||
}
|
||||
|
||||
function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
|
||||
const [command, setCommand] = useState("G0 X0");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const submit = () => { if (!command.trim()) return; action("MDI_EXECUTE", { block: command }); setHistory((items) => [command, ...items].slice(0, 20)); };
|
||||
return <section className="mdi-control"><label className="mdi-label">MDI Command<input value={command} onChange={(event) => setCommand(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} /></label><TextButton disabled={!ready} onClick={submit}>Go</TextButton><h3>MDI History</h3><ol>{history.length ? history.map((line, index) => <li key={`${line}-${index}`}><button type="button" onClick={() => setCommand(line)}>{line}</button></li>) : <li className="empty-history">No commands</li>}</ol></section>;
|
||||
}
|
||||
|
||||
function DroView({ axes, state }: { axes: number[]; state: typeof INITIAL_STATE }) {
|
||||
const labels = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"];
|
||||
return <section className="dro-view" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><span>Machine</span><span>Relative</span><span>Distance to Go</span></div><div className="dro-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return <div className="dro-row" key={label}><strong>{label}</strong><output>{value.toFixed(4)}</output><output>{value.toFixed(4)}</output><output>{index < 3 ? (10 - value).toFixed(4) : "0.0000"}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>mm</b></span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
|
||||
}
|
||||
|
||||
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; ready: boolean }) {
|
||||
const lines = text.split("\n");
|
||||
return <section className="axis-program-pane"><div className="program-editor-wrap"><div className="program-gutter" aria-hidden="true">{lines.map((_, index) => <span key={index}>{index + 1}</span>)}</div><textarea id="axis-program" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton onClick={onLoad} disabled={!ready}>Load</TextButton><TextButton onClick={onRun} disabled={!ready}>Run</TextButton><TextButton onClick={onStep} disabled={!ready}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea readOnly value={activeGcodes} aria-label="Active G-Codes" /></label></aside></section>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const client = useMemo(() => new ReactWorkerClient(), []);
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const [casePath, setCasePath] = useState(DEFAULT_CASE);
|
||||
const [caseStatus, setCaseStatus] = useState("ready to load");
|
||||
const [commandStatus, setCommandStatus] = useState("Idle");
|
||||
const [programText, setProgramText] = useState(DEFAULT_PROGRAM);
|
||||
const [selectedAxis, setSelectedAxis] = useState("x");
|
||||
const [leftTab, setLeftTab] = useState<"manual" | "mdi">("manual");
|
||||
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
|
||||
const [spindleOn, setSpindleOn] = useState(false);
|
||||
const [coolant, setCoolant] = useState({ mist: false, flood: false });
|
||||
const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">("p");
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotateMode, setRotateMode] = useState(false);
|
||||
const [blockDelete, setBlockDelete] = useState(false);
|
||||
const [optionalStop, setOptionalStop] = useState(false);
|
||||
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]);
|
||||
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
|
||||
const ready = ["ready", "running", "paused"].includes(state.status);
|
||||
const execute: Action = (kind, payload = {}) => { setCommandStatus(`${kind}...`); client.send(kind, payload, `axis-${kind.toLowerCase()}-${Date.now()}`).then(() => setCommandStatus("Acknowledged")).catch((error: Error) => setCommandStatus(`Blocked: ${error.message}`)); };
|
||||
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { client.send("SIM_STEP", { cycles: 1 }, `axis-tick-${Date.now()}`).catch(() => undefined); }, 50); };
|
||||
const stopProgram = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } execute("PROGRAM_ABORT"); };
|
||||
const loadCase = async () => { setCaseStatus("loading..."); try { await client.ensureSession(); const bundle = await loadSimulationCase({ path: casePath }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const result = await client.send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, "axis-load-case"); const resultPayload = result.payload as { configuration?: { state?: string } }; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
|
||||
const onSpindle = (direction: number) => { setSpindleOn(direction !== 0); execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
|
||||
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; setCoolant(next); execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
|
||||
const activeGcodes = state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9";
|
||||
const taskState = String(state.task?.state ?? state.status).toUpperCase();
|
||||
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
|
||||
const position = `X${Number(axes[0] ?? 0).toFixed(3)} Y${Number(axes[1] ?? 0).toFixed(3)} Z${Number(axes[2] ?? 0).toFixed(3)}`;
|
||||
return <main className="axis-app">
|
||||
<header className="axis-window-header"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`}>{state.status.toUpperCase()}</span></header>
|
||||
<nav className="axis-menubar" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => document.getElementById("axis-program")?.focus()}>Open...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={() => execute("ESTOP", { active: true })}>Toggle Emergency Stop</MenuAction><MenuAction shortcut="F2" onClick={() => execute("IO_RECOVER")}>Toggle Machine Power</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={() => setLeftTab("manual")}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" aria-label="AXIS toolbar"><IconButton label="Toggle Emergency Stop [F1]" asset="tool_estop.gif" danger onClick={() => execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /><IconButton label="Toggle Machine power [F2]" asset="tool_power.gif" onClick={() => execute("IO_RECOVER")} /><Separator /><IconButton label="Open G-Code file [O]" asset="tool_open.gif" onClick={loadCase} /><IconButton label="Reopen current file [Ctrl-R]" asset="tool_reload.gif" onClick={() => setProgramText(DEFAULT_PROGRAM)} /><Separator /><IconButton label="Begin executing current file [R]" asset="tool_run.gif" disabled={!ready} onClick={runProgram} /><IconButton label="Execute next line [T]" asset="tool_step.gif" disabled={!ready} onClick={() => execute("PROGRAM_STEP")} /><IconButton label="Pause / resume execution [P/S]" asset={state.status === "paused" ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={!ready} onClick={() => execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!ready} onClick={stopProgram} /><Separator /><IconButton label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => setCommandStatus("Live plot cleared")} /></section>
|
||||
<section className="axis-case-strip"><label>Simulation case<select value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path.includes("minimal_xyz") || entry.path.includes("corexy")).slice(0, 8).map((entry) => <option key={entry.path} value={entry.path}>{entry.label}</option>)}</select></label><TextButton onClick={loadCase}>Load case</TextButton><span className="case-status">{caseStatus}</span><span className="command-status">{commandStatus}</span></section>
|
||||
<section className="axis-top-pane"><div className="axis-notebook left-notebook"><div className="notebook-tabs" role="tablist" aria-label="Manual and MDI controls"><button type="button" role="tab" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>Manual Control [F3]</button><button type="button" role="tab" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button></div>{leftTab === "manual" ? <ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /> : <MdiControl action={execute} ready={ready} />}</div><div className="axis-notebook right-notebook"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button type="button" role="tab" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button type="button" role="tab" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div>{rightTab === "preview" ? <div className="axis-preview"><ThreeMachineView axes={axes} view={view} zoom={zoom} rotateMode={rotateMode} /></div> : <DroView axes={axes} state={state} />}</div></section>
|
||||
<section className="axis-metrics"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div></section>
|
||||
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onLoad={() => execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} />
|
||||
<footer className="axis-info-bar"><span className={`task-state ${state.status}`}>{taskState}</span><span className="tool-state">Tool {toolNumber}</span><span className="position-state">{position}</span><span className="footer-status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
|
||||
</main>;
|
||||
}
|
||||
Reference in New Issue
Block a user