Support long G-code preview and virtualized execution display

This commit is contained in:
2026-08-29 22:27:01 +08:00
parent 819015924b
commit d87366eb75
7 changed files with 179 additions and 35 deletions

View File

@@ -13,6 +13,7 @@ export const SELECTED_XYZAC_CASE = XYZAC_CASE;
const DEFAULT_CASE = SELECTED_XYZAC_CASE;
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const DEFAULT_PROGRAM = "";
const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void;
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean; feature?: string };
@@ -87,6 +88,32 @@ function DroView({ axes, state, coordType, displayType, metric, setCoordType, se
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-column-headings"><span /><span>Displayed</span><span>Actual</span><span>Relative</span></div><div className="dro-table">{labels.map((label, index) => { const actual = Number(axes[index] ?? 0); const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(coordType === "relative" ? relativeValue : displayed, index)}</output><output>{format(actual, index)}</output><output>{format(relativeValue, index)}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b> / deg</span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
const PROGRAM_ROW_HEIGHT = 17.4;
const TRACE_ROW_HEIGHT = 23;
const TRACE_OVERSCAN = 12;
function VirtualizedExecutionTrace({ entries, executing }: { entries: Array<{ line: number; text: string; program_status?: string | null }>; executing: boolean }) {
const traceRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const [windowRange, setWindowRange] = useState({ start: 0, end: Math.min(entries.length, TRACE_OVERSCAN * 2 + 24) });
const updateWindow = () => {
const node = traceRef.current;
if (!node) return;
const start = Math.max(0, Math.floor(node.scrollTop / TRACE_ROW_HEIGHT) - TRACE_OVERSCAN);
const end = Math.min(entries.length, Math.ceil((node.scrollTop + node.clientHeight) / TRACE_ROW_HEIGHT) + TRACE_OVERSCAN);
setWindowRange((previous) => previous.start === start && previous.end === end ? previous : { start, end });
};
useEffect(() => {
const node = traceRef.current;
if (!node) return;
if (followRef.current) node.scrollTop = node.scrollHeight;
const frame = requestAnimationFrame(updateWindow);
return () => cancelAnimationFrame(frame);
}, [entries.length]);
const visible = entries.slice(windowRange.start, windowRange.end);
return <div ref={traceRef} className="program-trace-scroll" onScroll={(event) => { const node = event.currentTarget; followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < TRACE_ROW_HEIGHT * 1.5; updateWindow(); }}><ol style={{ height: `${Math.max(1, entries.length * TRACE_ROW_HEIGHT)}px` }}>{visible.map((entry, offset) => { const index = windowRange.start + offset; const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} style={{ position: "absolute", top: `${index * TRACE_ROW_HEIGHT}px`, left: 0, height: `${TRACE_ROW_HEIGHT}px` }} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div>;
}
function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatus, onLoad, onRun, onStep, onOpenFile, canLoad, canRun, canStep, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; programStatus: string; onLoad: () => void; onRun: () => void; onStep: () => void; onOpenFile: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) {
const lines = text.split("\n");
const executing = ["running", "paused"].includes(programStatus.toLowerCase());
@@ -95,13 +122,27 @@ function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatu
const editorRef = useRef<HTMLTextAreaElement>(null);
const gutterRef = useRef<HTMLDivElement>(null);
const highlightsRef = useRef<HTMLDivElement>(null);
const traceRef = useRef<HTMLOListElement>(null);
const followTraceRef = useRef(true);
const [editorWindow, setEditorWindow] = useState({ start: 0, end: Math.min(lines.length, 48) });
const updateEditorWindow = () => {
const node = editorRef.current;
if (!node) return;
const start = Math.max(0, Math.floor(node.scrollTop / PROGRAM_ROW_HEIGHT) - 12);
const end = Math.min(lines.length, Math.ceil((node.scrollTop + node.clientHeight) / PROGRAM_ROW_HEIGHT) + 12);
setEditorWindow((previous) => previous.start === start && previous.end === end ? previous : { start, end });
};
useEffect(() => {
const node = traceRef.current;
if (node && followTraceRef.current) node.scrollTop = node.scrollHeight;
}, [executionTrace]);
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" aria-hidden="true">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><ol ref={traceRef} onScroll={(event) => { const node = event.currentTarget; followTraceRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 28; }}>{executionTrace.map((entry, index, entries) => { const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
const frame = requestAnimationFrame(updateEditorWindow);
return () => cancelAnimationFrame(frame);
}, [lines.length]);
useEffect(() => {
const node = editorRef.current;
if (!node || !mainLine) return;
const lineTop = (mainLine - 1) * PROGRAM_ROW_HEIGHT;
if (lineTop < node.scrollTop || lineTop + PROGRAM_ROW_HEIGHT > node.scrollTop + node.clientHeight) node.scrollTop = Math.max(0, lineTop - node.clientHeight * .35);
}, [mainLine]);
const visibleLines = lines.slice(editorWindow.start, editorWindow.end);
const lineClass = (line: number) => line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : "";
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers"><div className="program-gutter-content" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }}>{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; const line = index + 1; return <span key={index} className={lineClass(line)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div></div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }} aria-hidden="true"><span className="program-highlights-spacer" />{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; return <span key={index} className={lineClass(index + 1)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } updateEditorWindow(); } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><VirtualizedExecutionTrace entries={executionTrace} executing={executing} /></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
}
function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) {
@@ -125,6 +166,7 @@ export function App() {
const [caseStatus, setCaseStatus] = useState("ready to load");
const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramText] = useState(DEFAULT_PROGRAM);
const [programPath, setProgramPath] = useState(PROGRAM_PATH);
const [selectedAxis, setSelectedAxis] = useState("x");
const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
@@ -144,7 +186,7 @@ export function App() {
const programFileInput = useRef<HTMLInputElement>(null);
const [plotCleared, setPlotCleared] = useState(false);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
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"); setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source, 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); client.close(); }; }, [client, operationController]);
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); client.close(); }; }, [client, operationController]);
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
const taskRecord = state.task ?? {};
const axisState = { task: { ...taskRecord, state: taskRecord.state ?? "OFF", mode: taskRecord.mode ?? "MANUAL", interp_state: taskRecord.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates };
@@ -195,9 +237,10 @@ export function App() {
.then(() => dispatch("PROGRAM_RUN", {}, false))
.catch(() => undefined);
};
const loadCase = async () => { setCaseStatus("loading session..."); try { await client.ensureSession(); setCaseStatus("loading exact XYZAC case..."); const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); setCaseStatus("starting exact case..."); const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false }); const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload; setProgramText(source.replace(/\r\n/g, "\n").trimEnd()); setPlotCleared(false); await operationController.execute("PROGRAM_LOAD", { source, path: PROGRAM_PATH }, { state: axisState, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setOperationTrace(operationController.exportTrace()); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
const loadCase = async () => { setCaseStatus("loading session..."); try { await client.ensureSession(); setCaseStatus("loading exact XYZAC case..."); const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setCaseStatus("starting exact case..."); const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false }); const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload; setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); setPlotCleared(false); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: axisState, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setOperationTrace(operationController.exportTrace()); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
const loadPackagedProgram = async () => { const path = "linuxcnc/nc_files/3D_Chips.ngc"; setCaseStatus(`${path} loading from published workspace...`); try { const response = await fetch(`./${path}`); if (!response.ok) throw new Error(`program_asset_http_${response.status}`); const source = normalizeProgramSource(await response.text()); if (!source.trim()) throw new Error("program_asset_empty"); setProgramText(source); setProgramPath(path); setPlotCleared(false); await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${path} loaded through linuxcnc-wasm`); } catch (error) { setOperationTrace(operationController.exportTrace()); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "program_asset_load_failed"}`); } };
const openProgramFile = () => programFileInput.current?.click();
const onProgramFile = async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const source = await file.text(); setProgramText(source); setPlotCleared(false); setCommandStatus(`${file.name} loaded in editor; press Load to send it to LinuxCNC`); };
const onProgramFile = async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const source = normalizeProgramSource(await file.text()); const path = file.name === "3D_Chips.ngc" ? "linuxcnc/nc_files/3D_Chips.ngc" : `browser/${file.name}`; setProgramText(source); setProgramPath(path); setPlotCleared(false); setCommandStatus(`${file.name} loaded in editor; press Load to send it to LinuxCNC`); };
const enterManualMode = () => { setLeftTab("manual"); if (String(axisState.task.mode).toUpperCase() !== "MANUAL") void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined); };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -239,14 +282,14 @@ export function App() {
const position = `X${Number(xyzacView.pose?.x ?? axes[0] ?? 0).toFixed(3)} Y${Number(xyzacView.pose?.y ?? axes[1] ?? 0).toFixed(3)} Z${Number(xyzacView.pose?.z ?? axes[2] ?? 0).toFixed(3)} A${Number(xyzacView.pose?.a ?? 0).toFixed(3)} C${Number(xyzacView.pose?.c ?? 0).toFixed(3)}`;
return <main className="axis-app" data-axis-feature="AXIS-UI-01" 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={openProgramFile} disabled={programRunning}>Open G-code...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)} disabled={programRunning}>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}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled}>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}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")} disabled={programRunning}>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" 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); 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" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={openProgramFile} disabled={programRunning}>Open G-code...</MenuAction><MenuAction onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); }} disabled={programRunning}>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}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled}>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}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")} disabled={programRunning}>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" 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" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled}>Load case</TextButton><TextButton feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!gates.load.enabled}>Load 3D_Chips.ngc</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
<MachineSequence isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} canPower={gates.power.enabled} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>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><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<XyzacProgramPane status={String(state.program?.program_status ?? "idle")} currentLine={executionLine} sourceHash={String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={state.executionTrace} programStatus={String(state.program?.program_status ?? "idle")} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: PROGRAM_PATH }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={currentLine} executionLine={executionLine} executionLineText={executionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<XyzacProgramPane status={String(state.program?.program_status ?? "idle")} currentLine={executionLine} sourceHash={String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={state.executionTrace} programStatus={String(state.program?.program_status ?? "idle")} onOpenFile={openProgramFile} onLoad={() => { setPlotCleared(false); execute("PROGRAM_LOAD", { source: programText, path: programPath }); }} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled} canRun={gates.run.enabled} canStep={gates.step.enabled} currentLine={currentLine} executionLine={executionLine} executionLineText={executionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
</main>;

View File

@@ -179,14 +179,36 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
if (nextPathKey !== pathKey) {
clearLines(pathGroup);
bounds = fit(segments);
// Long programs can contain thousands of linear blocks. Batch their
// vertices so preview loading does not block the UI on one WebGL
// object and geometry allocation per G-code line.
const batched = { rapid: [] as THREE.Vector3[], completed: [] as THREE.Vector3[] };
const sampledLines: Array<{ points: THREE.Vector3[]; rapid: boolean }> = [];
for (const segment of segments) {
const start = segment.start ?? {}; const end = segment.end ?? {};
const points = Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), segment.kind === "rapid" ? rapid : completed);
if (segment.kind === "rapid") line.computeLineDistances();
const sampled = Array.isArray(segment.samples) && segment.samples.length > 1;
const points = sampled ? segment.samples!.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
const rapidPath = segment.kind === "rapid";
if (sampled) sampledLines.push({ points, rapid: rapidPath });
else (rapidPath ? batched.rapid : batched.completed).push(...points);
}
const addLine = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
if (points.length < 2) return;
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
if (dashed) line.computeLineDistances();
line.renderOrder = 10;
pathGroup.add(line);
}
};
const addSegments = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
if (points.length < 2) return;
const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material);
if (dashed) line.computeLineDistances();
line.renderOrder = 10;
pathGroup.add(line);
};
addSegments(batched.rapid, rapid, true);
addSegments(batched.completed, completed);
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
pathKey = nextPathKey;
liveKey = "";
}

View File

@@ -190,7 +190,7 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id));
const nextToolpath = resetPath
? previewSegments.map((segment, index) => ({ id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, ...(segment.center ? { center: segment.center as Record<string, number> } : {}), ...(segment.normal ? { normal: segment.normal as Record<string, number> } : {}), ...(typeof segment.motion_type === "number" ? { motion_type: segment.motion_type } : {}), ...(typeof segment.turn === "number" ? { turn: segment.turn } : {}), active: false }))
? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
for (const segment of plannerSegments) {
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
@@ -200,7 +200,16 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
} else {
for (const segment of nextToolpath) segment.active = false;
}
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, authoritative } satisfies SimSnapshotV1;
const visual = resetPath
? {
...(previous.snapshot?.visual ?? { schema: "axis-visual.v1", pose: {}, joints: [], source: "wasm_canonical_motion" as const }),
toolpath: nextToolpath,
active_segment_id: null,
active_progress: 0,
motion_trace: [],
}
: previous.snapshot?.visual;
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1;
return { snapshot, toolpath: nextToolpath, task: (result.task as Record<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace };
}

View File

@@ -180,13 +180,16 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.diagnostic-row code { min-width: 160px; color: #26384a; }
.program-main-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); grid-column: 1; grid-row: 1; min-width: 0; min-height: 0; }
.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-gutter { min-width: 0; padding: 6px 6px 6px 0; overflow-x: hidden; overflow-y: auto; border-right: 1px solid #c2c2c2; color: #888; background: #e4e4e4; font: 12px/1.45 ui-monospace, monospace; user-select: none; scrollbar-width: none; }
.program-gutter::-webkit-scrollbar { width: 0; height: 0; }
.program-gutter-content { position: relative; width: 100%; }
.program-gutter span { position: absolute; right: 0; left: 0; height: 17.4px; text-align: right; }
.program-gutter span.current-line { color: #5a4100; background: #ffe066; font-weight: 700; }
.program-gutter span.completed-line { color: #666; background: #e6e6e6; }
.program-editor-input { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #fff; }
.program-line-highlights { position: absolute; inset: 0; z-index: 0; padding: 6px 8px; overflow: hidden; pointer-events: none; font: 12px/1.45 ui-monospace, monospace; }
.program-line-highlights span { display: block; height: 17px; margin: 0 -8px; }
.program-line-highlights { position: absolute; top: 0; right: 0; left: 0; z-index: 0; padding: 6px 8px; overflow: hidden; pointer-events: none; font: 12px/1.45 ui-monospace, monospace; }
.program-line-highlights span { position: absolute; right: 0; left: 0; display: block; height: 17.4px; margin: 0 -8px; }
.program-line-highlights span.program-highlights-spacer { position: static; height: 100%; margin: 0; }
.program-line-highlights span.completed-line { background: #eeeeee; }
.program-line-highlights span.current-line { background: #ffe066; }
.program-editor-input textarea { position: relative; z-index: 1; width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; overflow: auto; resize: none; border: 0; outline: none; background: transparent; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; white-space: pre; scrollbar-gutter: stable both-edges; }
@@ -196,17 +199,18 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.program-execution-line code { flex: 0 0 auto; min-width: max-content; overflow: visible; color: #111; white-space: pre; }
.program-execution-trace { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-column: 1; grid-row: 2; min-width: 0; min-height: 0; padding: 7px 8px; overflow: hidden; border-bottom: 1px solid #9aa8b4; background: #f4f7fa; color: #253746; font: 11px/1.35 ui-monospace, monospace; }
.program-execution-trace strong { display: block; margin-bottom: 2px; font-size: 11px; }
.program-execution-trace ol { display: grid; align-content: start; gap: 2px; width: 100%; max-width: 100%; max-height: none; margin: 0; padding: 0; overflow: auto; list-style: none; scrollbar-gutter: stable both-edges; }
.program-execution-trace li { display: grid; grid-template-columns: 42px max-content; gap: 8px; width: max-content; min-width: max-content; }
.program-trace-scroll { min-width: 0; min-height: 0; overflow: auto; scrollbar-gutter: stable both-edges; }
.program-execution-trace ol { position: relative; display: block; width: max-content; min-width: 100%; max-width: none; max-height: none; margin: 0; padding: 0; overflow: visible; list-style: none; }
.program-execution-trace li { display: grid; grid-template-columns: 42px max-content; gap: 8px; width: max-content; min-width: max-content; box-sizing: border-box; }
.program-execution-trace li span { color: #174d84; font-weight: 700; }
.program-execution-trace li code { min-width: max-content; overflow: visible; white-space: pre; }
.program-execution-trace li.completed-line { padding: 3px 6px; border-left: 3px solid #b8b8b8; background: #e8e8e8; color: #666; }
.program-execution-trace li.executing-line { padding: 3px 6px; border-left: 3px solid #d09b00; background: #ffe066; color: #111; font-weight: 700; }
.program-execution-trace li.executing-line span { color: #6b4b00; }
.program-execution-trace strong span { color: #60717e; font-weight: 400; }
.program-editor-input textarea::-webkit-scrollbar, .program-execution-line::-webkit-scrollbar, .program-execution-trace ol::-webkit-scrollbar { width: 11px; height: 11px; }
.program-editor-input textarea::-webkit-scrollbar-track, .program-execution-line::-webkit-scrollbar-track, .program-execution-trace ol::-webkit-scrollbar-track { background: #d7dde2; }
.program-editor-input textarea::-webkit-scrollbar-thumb, .program-execution-line::-webkit-scrollbar-thumb, .program-execution-trace ol::-webkit-scrollbar-thumb { border: 2px solid #d7dde2; background: #7f8d98; }
.program-editor-input textarea::-webkit-scrollbar, .program-execution-line::-webkit-scrollbar, .program-trace-scroll::-webkit-scrollbar { width: 11px; height: 11px; }
.program-editor-input textarea::-webkit-scrollbar-track, .program-execution-line::-webkit-scrollbar-track, .program-trace-scroll::-webkit-scrollbar-track { background: #d7dde2; }
.program-editor-input textarea::-webkit-scrollbar-thumb, .program-execution-line::-webkit-scrollbar-thumb, .program-trace-scroll::-webkit-scrollbar-thumb { border: 2px solid #d7dde2; background: #7f8d98; }
.program-side { display: flex; grid-column: 2; grid-row: 1; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; 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; }

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const PROGRAM_PATH = "linuxcnc/nc_files/3D_Chips.ngc";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new XyzacSession(core, { sessionId: "xyzac-3d-chips" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `chips-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
const source = await readFile(new URL(`../../${PROGRAM_PATH}`, import.meta.url), "utf8");
const loaded = send("PROGRAM_LOAD", { source, path: PROGRAM_PATH });
assert.equal(loaded.kind, "COMMAND_RESULT");
assert.equal(loaded.payload.status, "verified");
assert.equal(loaded.payload.program.path, PROGRAM_PATH);
assert.equal(loaded.payload.program.source_length, 200509);
assert.equal(loaded.payload.program.line_count, 4711);
assert.equal(loaded.payload.program.source_hash, fnv1a64(source));
assert.equal(loaded.payload.preview.status, "verified");
assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
assert.equal(loaded.payload.preview.line_count, 4711);
assert.equal(loaded.payload.preview.segments.length, 4684);
assert.equal(send("POWER", { active: true }).kind, "COMMAND_RESULT");
assert.equal(send("HOME", { all: true }).kind, "COMMAND_RESULT");
const run = send("PROGRAM_RUN");
assert.equal(run.kind, "COMMAND_RESULT");
const stepped = send("SIM_STEP", { cycles: 16, response: "motion_delta" });
assert.equal(stepped.kind, "COMMAND_RESULT");
assert.equal(stepped.payload.program.program_status, "running");
assert.ok(stepped.payload.program.current_line > 0);
assert.ok(stepped.payload.execution_trace.length > 0);
assert.ok(stepped.payload.program.execution_line_text.length > 0);
assert.equal(stepped.payload.motion_authority, "wasm");
console.log(JSON.stringify({
schema: "xyzac-3d-chips-program.v1",
status: "verified",
program_path: PROGRAM_PATH,
source_length: source.length,
source_hash: loaded.payload.program.source_hash,
line_count: loaded.payload.program.line_count,
preview_segments: loaded.payload.preview.segments.length,
execution_line: stepped.payload.program.current_line,
execution_trace: stepped.payload.execution_trace.length,
motion_authority: stepped.payload.motion_authority,
}));

View File

@@ -145,7 +145,7 @@ export class WasmProgramRuntime {
if (step.status !== "verified") return { status: "error", code: step.code, line: temporary.snapshot().current_line, segments };
if (step.has_control && [428, 429, 430].includes(step.control_code)) kinematicsMode = { 428: 1, 429: 0, 430: 2 }[step.control_code];
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
if ([4, 5, 6].includes(temporary.snapshot().status)) break;
if ([4, 5, 6].includes(step.program.status)) break;
}
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
} finally { temporary.destroy(); }
@@ -220,12 +220,12 @@ export class WasmProgramRuntime {
try {
const result = call(this.core, "sim_program_execute_block", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]);
if (result !== 0) return programError("program_execute", result);
return { status: "verified", program: this.snapshot(), motion: this.readMotion() };
const program = this.snapshot();
return { status: "verified", program, motion: this.readMotion(program) };
} finally { this.core._free(pointer); }
}
readMotion() {
const state = this.snapshot();
readMotion(state = this.snapshot()) {
if (!state.has_motion) return null;
const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]);
if (result !== 0) return null;
@@ -254,7 +254,7 @@ export class WasmProgramRuntime {
const result = this.invoke("sim_program_step", [], "program_step");
if (result.status !== "verified") return result;
const state = this.snapshot();
return { status: "verified", program_status: state.status, line: state.current_line, control_code: state.control_code ?? 0, has_control: state.has_control === 1, program: state, motion: this.readMotion() };
return { status: "verified", program_status: state.status, line: state.current_line, control_code: state.control_code ?? 0, has_control: state.has_control === 1, program: state, motion: this.readMotion(state) };
}
snapshot() {