Implement AXIS parity UI and execution baseline

This commit is contained in:
2026-08-23 07:28:37 +08:00
parent 1325504544
commit 06f96f85e5
463 changed files with 84893 additions and 692 deletions

110
web/react/src/App.tsx Normal file
View 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>;
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
type ViewMode = "z" | "z2" | "x" | "y" | "y2" | "p";
type Props = { axes: number[]; view?: ViewMode; zoom?: number; rotateMode?: boolean };
export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = false }: Props) {
const host = useRef<HTMLDivElement>(null);
const axesRef = useRef(axes);
const viewRef = useRef(view);
const zoomRef = useRef(zoom);
const rotateRef = useRef(rotateMode);
axesRef.current = axes;
viewRef.current = view;
zoomRef.current = zoom;
rotateRef.current = rotateMode;
useEffect(() => {
const element = host.current;
if (!element) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080e13);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(6, 5, 7);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
element.appendChild(renderer.domElement);
const grid = new THREE.GridHelper(8, 16, 0x405765, 0x1e303b);
scene.add(grid);
scene.add(new THREE.AxesHelper(2.2));
const table = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.18, 3.6), new THREE.MeshStandardMaterial({ color: 0x263740, roughness: 0.8 }));
table.position.y = -0.1;
scene.add(table);
const gantry = new THREE.Mesh(new THREE.BoxGeometry(0.18, 2.2, 0.18), new THREE.MeshStandardMaterial({ color: 0x647c88 }));
gantry.position.set(0, 1, 0);
scene.add(gantry);
const tool = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.55, 16), new THREE.MeshStandardMaterial({ color: 0xc9e86d, emissive: 0x243200 }));
scene.add(tool);
const ambient = new THREE.HemisphereLight(0xc8e4ec, 0x111820, 1.8);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(3, 6, 4);
scene.add(key);
const resize = () => {
const width = Math.max(element.clientWidth, 320);
const height = Math.max(element.clientHeight, 280);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize);
observer.observe(element);
resize();
let frame = 0;
const render = () => {
const [x = 0, y = 0, z = 0] = axesRef.current;
tool.position.set(THREE.MathUtils.clamp(Number(x) || 0, -2.3, 2.3), 0.35 + THREE.MathUtils.clamp(Number(z) || 0, -1, 2), THREE.MathUtils.clamp(Number(y) || 0, -1.4, 1.4));
const distance = 10 / Math.max(0.5, zoomRef.current);
const selectedView = viewRef.current;
if (selectedView === "z") camera.position.set(0, distance, 0.01);
else if (selectedView === "z2") camera.position.set(distance * 0.3, distance * 0.9, distance * 0.3);
else if (selectedView === "x") camera.position.set(distance, 2, 0.01);
else if (selectedView === "y") camera.position.set(0.01, 2, distance);
else if (selectedView === "y2") camera.position.set(0.01, 2, -distance);
else camera.position.set(6 / Math.max(0.5, zoomRef.current), 5 / Math.max(0.5, zoomRef.current), 7 / Math.max(0.5, zoomRef.current));
camera.lookAt(0, 1, 0);
scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0;
renderer.render(scene, camera);
frame = requestAnimationFrame(render);
};
render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); renderer.dispose(); element.replaceChildren(); };
}, []);
return <div className="three-viewer" ref={host} aria-label="Three.js LinuxCNC machine visualization"><span className="three-viewer-label">Three.js / authoritative pose</span></div>;
}

View File

@@ -0,0 +1,43 @@
import type { FrontendState } from "./store";
export const GUI_FAMILIES = ["axis", "gmoccapy", "touchy", "gscreen", "qtvcp", "qtdragon", "pyvcp", "gladevcp", "vismach", "ngcgui", "qtplasmac", "plasma"] as const;
export type GuiFamily = typeof GUI_FAMILIES[number];
export const GUI_COMMANDS = Object.freeze({
run: "PROGRAM_RUN", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME", step: "PROGRAM_STEP", restart: "PROGRAM_RESTART", abort: "PROGRAM_ABORT", load: "PROGRAM_LOAD", mdi: "MDI_EXECUTE", jogStart: "JOG_START", jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER", spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE", toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG", snapshot: "SNAPSHOT_REQUEST", checkpoint: "CHECKPOINT", recoverProject: "RECOVER",
} as const);
export type GuiAction = keyof typeof GUI_COMMANDS;
const GUI_PANEL_DEFINITIONS = {
axis: ["program", "preview", "dro", "manual", "mdi", "overrides", "spindle", "tool", "alarm"],
gmoccapy: ["program", "dro", "manual", "mdi", "tool", "spindle", "alarm"],
touchy: ["dro", "manual", "mdi", "program", "alarm"],
gscreen: ["program", "dro", "manual", "mdi", "hal", "alarm"],
qtvcp: ["program", "dro", "manual", "mdi", "vcp", "hal", "tool", "alarm"],
qtdragon: ["program", "dro", "manual", "mdi", "vcp", "tool", "alarm"],
pyvcp: ["dro", "manual", "vcp", "alarm"],
gladevcp: ["dro", "manual", "vcp", "hal", "alarm"],
vismach: ["preview", "dro", "manual", "program", "alarm"],
ngcgui: ["program", "mdi", "preview"],
qtplasmac: ["program", "dro", "manual", "material", "torch", "alarm"],
plasma: ["program", "dro", "manual", "material", "torch", "alarm"],
} as const satisfies Record<GuiFamily, readonly string[]>;
export const GUI_PANELS = Object.freeze(GUI_PANEL_DEFINITIONS);
export const GUI_BOUNDARIES = Object.freeze({
desktop_window: "WEB-EQUIV",
python_callbacks: "PY-WASM",
virtual_devices: "SIM-DEVICE",
source_icons: "WEB-EQUIV/SOURCE-ASSET",
host_processes: "REAL-EXCLUDED",
physical_safety: "REAL-EXCLUDED",
});
export function commandFor(action: GuiAction): string { return GUI_COMMANDS[action]; }
export function panelsForFamily(family: string): readonly string[] { return GUI_PANELS[family as GuiFamily] ?? GUI_PANELS.axis; }
export function guiStatus(state: FrontendState) {
return { session: state.status, task: state.task?.state ?? null, program: state.program?.program_status ?? null, tick: state.tick, alarms: state.alarms.length, error: state.error?.code ?? null, snapshot_hash: state.snapshot?.state_hash ?? null };
}

View File

@@ -0,0 +1,37 @@
import { GUI_BOUNDARIES, GUI_FAMILIES, panelsForFamily, type GuiFamily } from "./gui-domain";
export type GuiFamilyDescriptor = {
family: GuiFamily;
panels: readonly string[];
source_kind: "desktop-equivalent" | "web-component" | "threejs";
capabilities: readonly string[];
boundaries: readonly string[];
};
const FAMILY_CAPABILITIES: Record<GuiFamily, readonly string[]> = {
axis: ["program", "mdi", "jog", "home", "override", "spindle", "tool", "alarm"],
gmoccapy: ["touch", "program", "mdi", "jog", "tool", "alarm"],
touchy: ["touch", "jog", "mdi", "program", "alarm"],
gscreen: ["program", "mdi", "jog", "hal", "alarm"],
qtvcp: ["vcp_pins", "python_callbacks", "program", "hal", "tool"],
qtdragon: ["vcp_pins", "python_callbacks", "program", "tool", "alarm"],
pyvcp: ["vcp_pins", "hal", "python_callbacks"],
gladevcp: ["vcp_pins", "hal", "python_callbacks"],
vismach: ["threejs_model", "pose", "joints", "toolpath"],
ngcgui: ["subroutine_forms", "program", "mdi"],
qtplasmac: ["material_table", "torch", "program", "python_callbacks"],
plasma: ["material_table", "torch", "program", "python_callbacks"],
};
export function descriptorForFamily(family: GuiFamily): GuiFamilyDescriptor {
const source_kind = family === "vismach" ? "threejs" : ["qtvcp", "qtdragon", "pyvcp", "gladevcp"].includes(family) ? "web-component" : "desktop-equivalent";
return { family, panels: panelsForFamily(family), source_kind, capabilities: FAMILY_CAPABILITIES[family], boundaries: [GUI_BOUNDARIES.desktop_window, ...(FAMILY_CAPABILITIES[family].includes("python_callbacks") ? [GUI_BOUNDARIES.python_callbacks] : []), ...(source_kind === "threejs" ? [GUI_BOUNDARIES.virtual_devices] : [])] };
}
export const GUI_FAMILY_DESCRIPTORS = Object.freeze(Object.fromEntries(GUI_FAMILIES.map((family) => [family, descriptorForFamily(family)])) as Record<GuiFamily, GuiFamilyDescriptor>);
export function descriptorForDisplay(display: string | null | undefined): GuiFamilyDescriptor {
const lower = String(display ?? "axis").toLowerCase();
const family = GUI_FAMILIES.find((candidate) => lower.includes(candidate)) ?? "axis";
return GUI_FAMILY_DESCRIPTORS[family];
}

8
web/react/src/legacy.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare module "*.mjs" {
export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>;
export function loadSimulationCase(options: { path: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
}
declare module "*.js" { const createModule: (options?: unknown) => Promise<unknown>; export default createModule; }

5
web/react/src/main.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);

65
web/react/src/protocol.ts Normal file
View File

@@ -0,0 +1,65 @@
export const PROTOCOL_SCHEMA = 1 as const;
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1" as const;
export type MessageKind =
| "HELLO" | "CAPABILITIES" | "SESSION_ATTACH" | "SESSION_READY"
| "LOAD_WORKSPACE" | "COMMAND_RESULT" | "STATE_SNAPSHOT" | "EVENT"
| "ALARM" | "RECOVERY_STATUS" | "ERROR";
export type MessageEnvelope<TPayload = unknown> = {
schema_version: typeof PROTOCOL_SCHEMA;
kind: MessageKind | string;
context_id: string;
session_id: string;
seq: number;
operation_id?: string;
simulation_tick?: number;
payload: TPayload;
};
export type MotionSnapshot = {
axes?: number[];
joints?: number[];
velocity?: number[];
homed?: boolean[];
limits?: number;
};
export type SimSnapshotV1 = {
snapshot_id?: string;
base_snapshot_id?: string;
full?: boolean;
simulation_tick: number;
state_hash: string;
task?: { state: string; mode: string; interp_state?: string; current_line?: number | null };
motion?: MotionSnapshot;
coordinates?: { machine?: number[]; relative?: number[]; g5x?: number; g92?: number[]; tool_offset?: number[] };
spindle?: Array<{ enabled?: boolean; direction?: number; commanded?: number; actual?: number; at_speed?: boolean }>;
io?: { coolant?: number; lube?: boolean; digital?: number[]; analog?: number[]; probe?: boolean };
tool?: { current?: number; prepared?: number; pocket?: number; change_state?: string };
overrides?: { feed?: number; rapid?: number; spindle?: number[]; adaptive?: number; feed_hold?: boolean };
hal?: { graph_revision?: number; changed_values?: ArrayBuffer };
alarms?: Array<{ id?: string; severity?: string; code?: string; acknowledged?: boolean }>;
recovery?: { status?: string; last_verified_seq?: number; checkpoint_tick?: number };
};
export type Capabilities = {
context_id?: string;
abi?: Record<string, unknown>;
snapshot?: Record<string, unknown>;
commands?: string[];
streams?: string[];
limits?: Record<string, number>;
exclusions?: string[];
};
export function isEnvelope(value: unknown): value is MessageEnvelope {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MessageEnvelope>;
return candidate.schema_version === PROTOCOL_SCHEMA
&& typeof candidate.kind === "string"
&& typeof candidate.context_id === "string"
&& typeof candidate.session_id === "string"
&& Number.isSafeInteger(candidate.seq)
&& "payload" in candidate;
}

View File

@@ -0,0 +1,22 @@
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
import { SimSession } from "../../worker/sim-session.mjs";
// The boot event is a transport diagnostic; it carries no simulation state.
self.postMessage({ schema_version: 1, kind: "WORKER_BOOT", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "booting", runtime: "react-vite-worker" } });
let session: SimSession | null = null;
const queue: MessageEvent[] = [];
const handle = (event: MessageEvent) => {
try {
if (!session) { queue.push(event); return; }
self.postMessage(session.accept(event.data));
} catch (error) {
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: session?.contextId ?? "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: session?.sessionId ?? "boot", seq: event.data?.seq ?? 0, payload: { status: "error", code: "worker_exception", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
}
};
self.onmessage = handle;
createModule().then((core) => {
session = new SimSession(core);
for (const event of queue.splice(0)) handle(event);
}).catch((error) => {
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "error", code: "worker_wasm_init", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
});

View File

@@ -0,0 +1,6 @@
// AXIS ships these exact 21/22/24px bitmap assets in the source tree.
export const LINUXCNC_AXIS_ASSETS = "/linuxcnc/share/axis/images";
export function axisIcon(name: string): string {
return `${LINUXCNC_AXIS_ASSETS}/${name}`;
}

88
web/react/src/store.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { Capabilities, MessageEnvelope, SimSnapshotV1 } from "./protocol";
export type SessionStatus = "disconnected" | "handshaking" | "attaching" | "loading" | "ready" | "running" | "paused" | "recovering" | "blocked" | "error";
export type FrontendState = {
status: SessionStatus;
contextId: string;
sessionId: string;
tick: number;
lastSeq: number;
capabilities: Capabilities | null;
snapshot: SimSnapshotV1 | null;
pending: string[];
error: { code: string; message?: string } | null;
alarms: Array<Record<string, unknown>>;
task: Record<string, unknown> | null;
program: Record<string, unknown> | null;
devices: Record<string, unknown> | null;
deviceIo: Record<string, unknown> | null;
hal: Record<string, unknown> | null;
plc: Record<string, unknown> | null;
python: Record<string, unknown> | null;
ioAck: Record<string, unknown> | null;
};
export const INITIAL_STATE: FrontendState = {
status: "disconnected",
contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
sessionId: "",
tick: 0,
lastSeq: 0,
capabilities: null,
snapshot: null,
pending: [],
error: null,
alarms: [],
task: null,
program: null,
devices: null,
deviceIo: null,
hal: null,
plc: null,
python: null,
ioAck: null,
};
function payload<T>(message: MessageEnvelope): T {
return message.payload as T;
}
function projection(result: Record<string, unknown>, message: MessageEnvelope, previous: FrontendState) {
const rawMotion = (result.motion ?? previous.snapshot?.motion ?? {}) as Record<string, unknown>;
const motionState = (rawMotion.state ?? rawMotion) as Record<string, unknown>;
const axesObject = motionState.axes as Record<string, unknown> | undefined;
const axes = Array.isArray(motionState.axes)
? motionState.axes as number[]
: [axesObject?.x, axesObject?.y, axesObject?.z].map((value) => typeof value === "number" ? value : 0);
const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : [];
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] } } satisfies SimSnapshotV1;
return { snapshot, task: (result.task as Record<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck };
}
export function reduceFrontendState(state: FrontendState, message: MessageEnvelope): FrontendState {
const tick = message.simulation_tick ?? state.tick;
const base = { ...state, contextId: message.context_id, sessionId: message.session_id, lastSeq: Math.max(state.lastSeq, message.seq), tick, error: null };
switch (message.kind) {
case "CAPABILITIES": return { ...base, status: "handshaking", capabilities: payload<Capabilities>(message) };
case "SESSION_READY": return { ...base, status: "ready" };
case "RECOVERY_STATUS": return { ...base, status: payload<{ status?: string }>(message).status === "verified" ? "ready" : "recovering" };
case "STATE_SNAPSHOT": {
const value = payload<Record<string, unknown>>(message);
const projected = projection(value, message, state);
return { ...base, status: "ready", ...projected };
}
case "COMMAND_RESULT": {
const result = payload<Record<string, unknown>>(message);
const task = result.task as { state?: string } | undefined;
const status = result.status === "blocked" ? "blocked" : task?.state === "RUNNING" ? "running" : state.status === "running" ? "running" : "ready";
return { ...base, status, ...projection(result, message, state) };
}
case "ALARM": return { ...base, status: "blocked", alarms: [...state.alarms, payload<Record<string, unknown>>(message)] };
case "ERROR": {
const value = payload<{ code?: string; message_key?: string }>(message);
return { ...base, status: "error", error: { code: value.code ?? "worker_error", message: JSON.stringify(value) } };
}
default: return base;
}
}

158
web/react/src/styles.css Normal file
View File

@@ -0,0 +1,158 @@
@import "tailwindcss";
:root {
font-family: Arial, Helvetica, sans-serif;
color: #202020;
background: #cfcfcf;
font-size: 13px;
--face: #d8d8d8;
--face-light: #ededed;
--border: #8b8b8b;
--border-dark: #666;
--ink: #202020;
--muted: #5a5a5a;
--blue: #c8d7e8;
--focus: #356ca8;
}
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; margin: 0; }
body { min-width: 320px; background: #cfcfcf; color: var(--ink); }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.axis-app { min-height: 100vh; display: flex; flex-direction: column; padding: 0; background: var(--face); }
.axis-window-header { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 3px 8px; border-bottom: 1px solid #777; background: #bcbcbc; }
.axis-brand { display: flex; align-items: center; gap: 6px; }
.axis-brand img { width: 24px; height: 24px; image-rendering: auto; }
.axis-brand strong { font-size: 15px; letter-spacing: .04em; }
.axis-brand span { color: #4b4b4b; font-size: 12px; }
.connection-state { padding: 2px 6px; border: 1px solid #777; background: #d6d6d6; font: 11px ui-monospace, monospace; }
.connection-state.ready, .connection-state.running { color: #185c1d; }
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
.axis-menubar { display: flex; align-items: stretch; min-height: 27px; padding-left: 3px; border-bottom: 1px solid #888; background: #d3d3d3; }
.axis-menu-item { position: relative; }
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
.axis-menu-item summary::-webkit-details-marker { display: none; }
.axis-menu-item[open] summary, .axis-menu-item summary:hover { background: #b8cbe0; }
.axis-menu-popup { position: absolute; z-index: 20; top: 100%; left: 0; min-width: 220px; padding: 2px; border: 1px solid #777; box-shadow: 2px 2px 4px #777; background: #ededed; }
.axis-menu-popup button { display: flex; justify-content: space-between; width: 100%; padding: 5px 8px; border: 0; background: transparent; text-align: left; cursor: pointer; }
.axis-menu-popup button:hover, .axis-menu-popup button:focus-visible { outline: 1px solid var(--focus); background: #c8d7e8; }
.axis-menu-popup kbd { margin-left: 22px; color: #555; font: 11px ui-monospace, monospace; }
.axis-toolbar { display: flex; align-items: center; min-height: 35px; padding: 3px 5px; overflow-x: auto; border-bottom: 1px solid #8a8a8a; background: #d9d9d9; }
.axis-tool-button { display: inline-grid; width: 29px; height: 29px; flex: 0 0 29px; place-items: center; padding: 2px; border: 1px solid transparent; border-radius: 2px; background: transparent; cursor: pointer; }
.axis-tool-button img { width: 24px; height: 24px; object-fit: contain; pointer-events: none; }
.axis-tool-button:hover, .axis-tool-button:focus-visible { border-color: #6a8db6; background: #c8d7e8; outline: none; }
.axis-tool-button.pressed { border-color: #777; border-right-color: #eee; border-bottom-color: #eee; background: #c0c0c0; }
.axis-tool-button.danger { margin-right: 1px; }
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
.axis-case-strip { display: flex; align-items: center; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
.axis-case-strip label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
.axis-case-strip select { max-width: min(420px, 46vw); min-height: 24px; border: 1px solid var(--border-dark); background: white; }
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.command-status { margin-left: auto; }
.axis-top-pane { display: grid; grid-template-columns: minmax(360px, .48fr) minmax(430px, 1fr); height: clamp(360px, calc(100vh - 415px), 680px); min-height: 360px; flex: 0 0 auto; padding: 5px; gap: 5px; }
.axis-notebook { min-width: 0; display: flex; flex-direction: column; border: 1px solid var(--border-dark); background: var(--face-light); }
.notebook-tabs { display: flex; align-items: end; min-height: 29px; padding: 3px 3px 0; border-bottom: 1px solid #777; background: #c5c5c5; }
.notebook-tabs button { min-height: 26px; padding: 4px 12px; border: 1px solid transparent; border-bottom: 0; background: transparent; cursor: pointer; }
.notebook-tabs button.active { margin-bottom: -1px; border-color: #777; background: var(--face-light); font-weight: 700; }
.notebook-tabs button:hover { background: #dfe8f2; }
.manual-control, .mdi-control, .dro-view { min-height: 0; flex: 1; padding: 11px; overflow: auto; }
.manual-grid { display: grid; grid-template-columns: minmax(118px, .7fr) minmax(220px, 1.35fr); gap: 10px 14px; }
.manual-grid h3, .mdi-control h3 { margin: 0 0 6px; font-size: 13px; font-weight: 400; }
.manual-axis-block { grid-row: span 2; }
.axis-radio-grid { display: grid; grid-template-columns: repeat(3, 48px); gap: 5px; }
.axis-radio { display: inline-flex; align-items: center; gap: 3px; min-height: 26px; padding: 2px 3px; border: 1px solid transparent; cursor: pointer; }
.axis-radio input { margin: 0; }
.axis-radio.selected { border-color: #6685a5; background: var(--blue); }
.jog-row, .spindle-row, .manual-action-row { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.manual-action-row { margin-top: 8px; }
.manual-jog-block select { min-height: 25px; min-width: 104px; border: 1px solid var(--border-dark); background: white; }
.axis-text-button { min-height: 26px; padding: 3px 9px; border: 1px solid #858585; border-radius: 2px; background: linear-gradient(#f4f4f4, #d6d6d6); box-shadow: inset 1px 1px #fff; cursor: pointer; }
.axis-text-button:hover:not(:disabled), .axis-text-button:focus-visible { border-color: var(--focus); background: #d8e5f3; outline: none; }
.axis-text-button:active:not(:disabled), .axis-text-button.danger { background: #c3c3c3; box-shadow: inset 1px 1px #999; }
.axis-text-button:disabled { cursor: not-allowed; opacity: .5; }
.check-line { display: inline-flex; align-items: center; gap: 4px; min-height: 25px; margin-top: 6px; }
.check-line input { margin: 0; }
.manual-spindle-block, .manual-coolant-block { min-width: 0; }
.manual-coolant-block { grid-column: 1; }
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
.manual-sliders { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 6px 16px; margin-top: 13px; padding-top: 9px; border-top: 1px solid #b0b0b0; }
.manual-sliders label, .axis-slider label { display: grid; grid-template-columns: auto 46px; align-items: center; gap: 4px; }
.manual-sliders input, .axis-slider input { grid-column: 1 / -1; width: 100%; accent-color: #527ba2; }
output { font: 12px ui-monospace, monospace; text-align: right; }
.mdi-control { max-width: 540px; }
.mdi-label { display: grid; gap: 5px; }
.mdi-label input { min-height: 29px; padding: 4px 6px; border: 1px solid #777; background: white; font: 14px ui-monospace, monospace; }
.mdi-control > .axis-text-button { margin-top: 7px; min-width: 74px; }
.mdi-control h3 { margin-top: 18px; padding-bottom: 5px; border-bottom: 1px solid #aaa; }
.mdi-control ol { max-height: 220px; margin: 0; padding-left: 28px; overflow: auto; font: 12px ui-monospace, monospace; }
.mdi-control li button { border: 0; background: transparent; cursor: pointer; }
.mdi-control li button:hover { color: #245d91; text-decoration: underline; }
.empty-history { color: var(--muted); }
.axis-preview { display: flex; min-height: 0; flex: 1; padding: 4px; overflow: hidden; background: #101419; }
.three-viewer { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; border: 1px solid #303b43; background: #080e13; }
.three-viewer canvas { display: block; width: 100%; height: 100%; }
.three-viewer-label { position: absolute; z-index: 1; top: 6px; left: 7px; padding: 2px 5px; color: #c9e86d; background: rgb(8 14 19 / 75%); font: 10px ui-monospace, monospace; pointer-events: none; }
.dro-view { background: #ececec; }
.dro-mode-row, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
.dro-mode-row { padding: 5px 6px; border-bottom: 1px solid #888; color: #555; font-size: 11px; }
.dro-mode-row span { text-align: right; }
.dro-table { margin-top: 9px; border: 1px solid #888; background: #fafafa; }
.dro-row { min-height: 38px; padding: 4px 7px; border-bottom: 1px solid #c6c6c6; }
.dro-row:last-child { border-bottom: 0; }
.dro-row strong { font-size: 15px; }
.dro-row output { color: #111; font-size: 17px; }
.dro-footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 12px; color: #555; font: 11px ui-monospace, monospace; }
.axis-metrics { padding: 4px 9px; border-top: 1px solid #999; background: #cecece; }
.axis-slider { display: grid; grid-template-columns: repeat(3, minmax(170px, 1fr)); gap: 12px; }
.axis-slider label { grid-template-columns: auto 48px; }
.axis-program-pane { display: grid; grid-template-columns: minmax(0, 1fr) 230px; flex: 0 0 205px; height: 205px; min-height: 205px; margin: 0 5px 5px; border: 2px solid #777; background: #f7f7f7; }
.program-editor-wrap { display: grid; grid-template-columns: 34px minmax(0, 1fr); min-width: 0; min-height: 0; }
.program-gutter { display: flex; flex-direction: column; align-items: end; padding: 6px 6px 6px 0; overflow: hidden; border-right: 1px solid #c2c2c2; color: #888; background: #e4e4e4; font: 12px/1.45 ui-monospace, monospace; user-select: none; }
.program-gutter span { height: 17px; }
.program-editor-wrap textarea { width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; resize: none; border: 0; outline: none; background: #fff; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; }
.program-side { display: flex; flex-direction: column; gap: 12px; padding: 8px; border-left: 1px solid #aaa; background: #d9d9d9; }
.program-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.active-gcodes-label { display: grid; gap: 5px; color: #333; }
.active-gcodes-label textarea { width: 100%; min-height: 84px; resize: vertical; border: 1px solid #999; background: white; color: #222; font: 11px/1.35 ui-monospace, monospace; }
.axis-info-bar { display: flex; align-items: stretch; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; }
.axis-info-bar > span { display: flex; align-items: center; min-height: 25px; padding: 2px 9px; border-right: 1px solid #999; font: 12px ui-monospace, monospace; }
.task-state { min-width: 92px; background: #ececec; font-weight: 700 !important; }
.task-state.running { color: #145d1c; }
.task-state.blocked, .task-state.error { color: #9b111e; background: #f2d6d6; }
.tool-state { flex: 1; }
.position-state { min-width: 230px; }
.footer-status { min-width: 180px; color: #505050; }
@media (max-width: 900px) {
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
.axis-notebook { min-height: 330px; }
.axis-preview { min-height: 320px; }
.axis-program-pane { flex: 0 0 auto; height: auto; min-height: 205px; grid-template-columns: 1fr; }
.program-side { border-top: 1px solid #aaa; border-left: 0; }
.active-gcodes-label textarea { min-height: 58px; }
}
@media (max-width: 620px) {
.axis-brand span { display: none; }
.axis-case-strip { flex-wrap: wrap; }
.axis-case-strip select { max-width: 62vw; }
.command-status { margin-left: 0; width: 100%; }
.manual-grid, .manual-sliders, .axis-slider { grid-template-columns: 1fr; }
.manual-axis-block, .manual-coolant-block { grid-row: auto; grid-column: auto; }
.dro-mode-row, .dro-row { grid-template-columns: 27px repeat(3, minmax(0, 1fr)); gap: 4px; }
.dro-mode-row { font-size: 9px; }
.dro-row output { font-size: 13px; }
.dro-footer { flex-direction: column; gap: 3px; }
.axis-info-bar { flex-wrap: wrap; }
.position-state, .footer-status { min-width: 0; flex: 1; }
}

View File

@@ -0,0 +1,26 @@
import type { GuiFamily } from "./gui-domain";
export type VisualWorkflowDescriptor = {
family: GuiFamily;
model: "axis" | "vismach" | "plasma" | "preview";
snapshot_fields: readonly string[];
worker_capabilities: readonly string[];
host_boundary: "WEB-EQUIV" | "PY-WASM" | "REAL-EXCLUDED";
};
export const VISUAL_WORKFLOWS: Record<GuiFamily, VisualWorkflowDescriptor> = {
axis: { family: "axis", model: "axis", snapshot_fields: ["motion.axes", "motion.joints", "program", "toolpath"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "WEB-EQUIV" },
gmoccapy: { family: "gmoccapy", model: "axis", snapshot_fields: ["motion.axes", "program", "tool", "alarms"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "WEB-EQUIV" },
touchy: { family: "touchy", model: "axis", snapshot_fields: ["motion.axes", "task", "program"], worker_capabilities: ["snapshot"], host_boundary: "WEB-EQUIV" },
gscreen: { family: "gscreen", model: "axis", snapshot_fields: ["motion.axes", "hal", "program"], worker_capabilities: ["snapshot", "hal_sample"], host_boundary: "WEB-EQUIV" },
qtvcp: { family: "qtvcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "tool", "hal"], worker_capabilities: ["snapshot", "vcp_pins", "hal_sample"], host_boundary: "PY-WASM" },
qtdragon: { family: "qtdragon", model: "axis", snapshot_fields: ["motion.axes", "vcp", "tool"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
pyvcp: { family: "pyvcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "hal"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
gladevcp: { family: "gladevcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "hal"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
vismach: { family: "vismach", model: "vismach", snapshot_fields: ["motion.axes", "motion.joints", "pose", "toolpath", "stock"], worker_capabilities: ["snapshot", "trajectory", "model"], host_boundary: "WEB-EQUIV" },
ngcgui: { family: "ngcgui", model: "preview", snapshot_fields: ["program", "mdi", "toolpath"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "PY-WASM" },
qtplasmac: { family: "qtplasmac", model: "plasma", snapshot_fields: ["motion.axes", "program", "material", "torch", "alarms"], worker_capabilities: ["snapshot", "trajectory", "material_table"], host_boundary: "PY-WASM" },
plasma: { family: "plasma", model: "plasma", snapshot_fields: ["motion.axes", "program", "material", "torch", "alarms"], worker_capabilities: ["snapshot", "trajectory", "material_table"], host_boundary: "PY-WASM" },
};
export function visualWorkflowFor(family: GuiFamily): VisualWorkflowDescriptor { return VISUAL_WORKFLOWS[family]; }

View File

@@ -0,0 +1,59 @@
import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol";
type Listener = (message: MessageEnvelope) => void;
export class ReactWorkerClient {
private readonly worker: Worker;
private readonly contextId: string;
private readonly sessionId: string;
private sequence = 0;
private listeners = new Set<Listener>();
private pending = new Map<number, { resolve: (message: MessageEnvelope) => void; reject: (error: Error) => void }>();
private readyPromise: Promise<void> | null = null;
constructor({ worker, contextId = CONTEXT_ID, sessionId = `react-${Date.now()}` }: { worker?: Worker; contextId?: string; sessionId?: string } = {}) {
// The entry imports the source-compatible SimSession; keep it an ES module
// so Vite does not wrap it as a classic worker and drop its imports.
this.worker = worker ?? new Worker(new URL("./sim-worker-entry.ts", import.meta.url), { type: "module" });
this.contextId = contextId;
this.sessionId = sessionId;
this.worker.onmessage = (event: MessageEvent<unknown>) => {
if (!isEnvelope(event.data)) return;
for (const listener of this.listeners) listener(event.data);
const pending = this.pending.get(event.data.seq);
if (pending) {
this.pending.delete(event.data.seq);
if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(new Error(String((event.data.payload as { code?: string }).code ?? "worker_error")));
else pending.resolve(event.data);
}
};
this.worker.onerror = (event) => {
const message: MessageEnvelope = {
schema_version: PROTOCOL_SCHEMA,
kind: "ERROR",
context_id: this.contextId,
session_id: this.sessionId,
seq: ++this.sequence,
payload: { code: "worker_transport_error", message_key: event.message || "worker_failed_to_load" },
};
for (const listener of this.listeners) listener(message);
for (const pending of this.pending.values()) pending.reject(new Error("worker_transport_error"));
this.pending.clear();
};
}
subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
send(kind: string, payload: unknown = {}, operationId?: string): Promise<MessageEnvelope> {
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload };
return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); });
}
hello(): Promise<MessageEnvelope> { return this.send("HELLO", { supported_schema_versions: [PROTOCOL_SCHEMA], requested_features: ["snapshot", "trajectory"] }); }
attach(): Promise<MessageEnvelope> { return this.send("SESSION_ATTACH", {}, "react-session-attach"); }
ensureSession(): Promise<void> {
if (!this.readyPromise) this.readyPromise = this.hello().then(() => this.attach()).then(() => undefined);
return this.readyPromise;
}
close(): void { this.worker.terminate(); }
}