|
|
|
|
@@ -186,9 +186,58 @@ export function App() {
|
|
|
|
|
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
|
|
|
|
|
const feedOverridePending = useRef<number | null>(null);
|
|
|
|
|
const feedOverrideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const uiCacheHydrated = useRef(false);
|
|
|
|
|
const uiCacheSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const UI_CACHE_KEY = "xyzac-trt";
|
|
|
|
|
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) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { 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?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); client.close(); }; }, [client, operationController]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const unsubscribe = client.subscribe((message) => 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;
|
|
|
|
|
const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record<string, unknown> : null;
|
|
|
|
|
if (cached) {
|
|
|
|
|
if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
|
|
|
|
|
if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
|
|
|
|
|
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(.5, Math.min(2, 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 (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?.[PROGRAM_PATH];
|
|
|
|
|
if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
|
|
|
|
|
const normalizedSource = normalizeProgramSource(source);
|
|
|
|
|
setProgramTextState(normalizedSource); setProgramPath(PROGRAM_PATH);
|
|
|
|
|
const configuration = toWorkerConfiguration(bundle);
|
|
|
|
|
await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
|
|
|
|
|
await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false });
|
|
|
|
|
if (typeof cachedProgram?.source === "string" && cachedProgram.source.trim() && cachedProgram.source.length <= 900000 && typeof cachedProgram.path === "string" && cachedProgram.path.length <= 512 && (cachedProgram.source !== normalizedSource || cachedProgram.path !== PROGRAM_PATH)) {
|
|
|
|
|
const cachedSource = normalizeProgramSource(cachedProgram.source);
|
|
|
|
|
setProgramTextState(cachedSource); setProgramPath(cachedProgram.path);
|
|
|
|
|
await operationController.execute("PROGRAM_LOAD", { source: cachedSource, path: cachedProgram.path }, { state: {}, gate: false });
|
|
|
|
|
}
|
|
|
|
|
uiCacheHydrated.current = true;
|
|
|
|
|
setOperationTrace(operationController.exportTrace());
|
|
|
|
|
setCaseStatus(`${DEFAULT_CASE} + ${cachedProgram?.path ?? PROGRAM_PATH} loaded`);
|
|
|
|
|
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); });
|
|
|
|
|
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
|
|
|
|
|
}, [client, operationController]);
|
|
|
|
|
const axes = state.snapshot?.motion?.axes ?? [];
|
|
|
|
|
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 };
|
|
|
|
|
@@ -493,7 +542,31 @@ export function App() {
|
|
|
|
|
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
|
|
|
|
|
const xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); };
|
|
|
|
|
const position = xyzacView.pose ? `X${Number(xyzacView.pose.x).toFixed(3)} Y${Number(xyzacView.pose.y).toFixed(3)} Z${Number(xyzacView.pose.z).toFixed(3)} A${Number(xyzacView.pose.a).toFixed(3)} C${Number(xyzacView.pose.c).toFixed(3)}` : "WASM state pending";
|
|
|
|
|
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
|
|
|
|
|
if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
|
|
|
|
|
const cacheState = {
|
|
|
|
|
version: 1,
|
|
|
|
|
selectedAxis,
|
|
|
|
|
leftTab,
|
|
|
|
|
rightTab,
|
|
|
|
|
view,
|
|
|
|
|
zoom,
|
|
|
|
|
rotateMode,
|
|
|
|
|
coordType,
|
|
|
|
|
displayType,
|
|
|
|
|
metric,
|
|
|
|
|
layers,
|
|
|
|
|
casePath,
|
|
|
|
|
program: { path: programPath, source: programText },
|
|
|
|
|
};
|
|
|
|
|
uiCacheSaveTimer.current = setTimeout(() => {
|
|
|
|
|
uiCacheSaveTimer.current = null;
|
|
|
|
|
void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined);
|
|
|
|
|
}, 500);
|
|
|
|
|
return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
|
|
|
|
|
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, layers, casePath, programPath, programText]);
|
|
|
|
|
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
|
|
|
|
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
|
|
|
|
|
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); }} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction onClick={resetMachine} disabled={!gates.reset.enabled}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!gates.abort.enabled}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
|
|
|
|
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled} onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" disabled={!gates.reset.enabled} onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
|
|
|
|
|
|