From 2cda55e7247ddb7bc5002f8b5eafafae10d55326 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 30 Aug 2026 20:54:07 +0800 Subject: [PATCH] Show toolpath preview loading progress --- web/react/src/App.tsx | 80 +++++++++++++++++++++++++++++++++++----- web/react/src/styles.css | 11 ++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index 21e4bda..d0ffdf9 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -189,7 +189,9 @@ export function App() { const programFileInput = useRef(null); const [plotCleared, setPlotCleared] = useState(false); const [operationTrace, setOperationTrace] = useState([]); - 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 [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null); + const previewProgressTimer = useRef | 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); 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 }; @@ -244,30 +246,66 @@ export function App() { const clearLoadedPreview = async () => { await operationController.execute("VISUAL_CLEAR", {}, { state: axisState, gate: false }); }; + const stopPreviewProgress = () => { + if (previewProgressTimer.current) { + clearInterval(previewProgressTimer.current); + previewProgressTimer.current = null; + } + }; + const startPreviewProgress = (label: string) => { + stopPreviewProgress(); + setPreviewProgress({ percent: 8, phase: "reading", detail: `${label}: 读取 G-code` }); + previewProgressTimer.current = setInterval(() => { + setPreviewProgress((previous) => { + if (!previous || previous.percent >= 88) return previous; + return { percent: Math.min(88, previous.percent + 4), phase: "wasm", detail: `${label}: LinuxCNC WASM 解析刀具路径` }; + }); + }, 100); + }; + const markPreviewProgress = (percent: number, phase: "reading" | "wasm", detail: string) => { + setPreviewProgress({ percent, phase, detail }); + }; + const finishPreviewProgress = (path: string, response: unknown) => { + stopPreviewProgress(); + const payload = (response && typeof response === "object" ? (response as { payload?: unknown }).payload : null) as { preview?: { segments?: unknown[] } } | null; + const segments = Array.isArray(payload?.preview?.segments) ? payload.preview.segments : []; + const samples = segments.reduce((count, segment) => count + (segment && typeof segment === "object" && Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0); + const detail = segments.length ? `WASM 刀具路径完成: ${segments.length} 段 / ${samples} 点` : "WASM 刀具路径完成"; + setPreviewProgress({ percent: 100, phase: "ready", detail: `${path}: ${detail}` }); + }; + const failPreviewProgress = (message: string) => { + stopPreviewProgress(); + setPreviewProgress({ percent: 100, phase: "error", detail: `刀具路径加载失败: ${message}` }); + }; const loadCase = async () => { if (programLoading || programRunning) return; setProgramLoading(true); + startPreviewProgress("XYZAC 程序"); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); 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; } + 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); 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; + markPreviewProgress(28, "wasm", "LinuxCNC WASM 配置已加载,准备解析刀具路径"); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); setPlotCleared(false); await clearLoadedPreview(); - await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: axisState, gate: false }); + markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径"); + const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: axisState, gate: false }); + finishPreviewProgress(PROGRAM_PATH, programLoad.response); setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setOperationTrace(operationController.exportTrace()); + failPreviewProgress(error instanceof Error ? error.message : "case_load_failed"); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } finally { setProgramLoading(false); } }; @@ -275,6 +313,7 @@ export function App() { if (programLoading || programRunning) return; const path = "linuxcnc/nc_files/3D_Chips.ngc"; setProgramLoading(true); + startPreviewProgress("3D_Chips.ngc"); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); setCaseStatus(`${path} loading from published workspace...`); try { @@ -284,23 +323,40 @@ export function App() { if (!source.trim()) throw new Error("program_asset_empty"); setProgramText(source); setProgramPath(path); setPlotCleared(false); await clearLoadedPreview(); - await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false }); + markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径"); + const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false }); + finishPreviewProgress(path, programLoad.response); setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); setCaseStatus(`${path} loaded through linuxcnc-wasm`); } catch (error) { setOperationTrace(operationController.exportTrace()); + failPreviewProgress(error instanceof Error ? error.message : "program_asset_load_failed"); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "program_asset_load_failed"}`); } finally { setProgramLoading(false); } }; const loadProgram = () => { if (programLoading || programRunning) return; setProgramLoading(true); + startPreviewProgress(programPath || "当前程序"); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); setPlotCleared(false); - void clearLoadedPreview().then(() => dispatch("PROGRAM_LOAD", { source: programText, path: programPath })) - .then(() => setCaseStatus(`${programPath} loaded through linuxcnc-wasm`)) - .catch(() => undefined) + void clearLoadedPreview() + .then(() => { + markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径"); + return operationController.execute("PROGRAM_LOAD", { source: programText, path: programPath }, { state: axisState, gate: false }); + }) + .then((operation) => { + finishPreviewProgress(programPath, operation.response); + setOperationTrace(operationController.exportTrace()); + setCommandStatus("PROGRAM_LOAD acknowledged"); + setCaseStatus(`${programPath} loaded through linuxcnc-wasm`); + }) + .catch((error) => { + setOperationTrace(operationController.exportTrace()); + failPreviewProgress(error instanceof Error ? error.message : "program_load_failed"); + setCommandStatus(`PROGRAM_LOAD blocked: ${error instanceof Error ? error.message : "program_load_failed"}`); + }) .finally(() => setProgramLoading(false)); }; const openProgramFile = () => programFileInput.current?.click(); @@ -308,23 +364,27 @@ export function App() { const file = event.target.files?.[0]; event.target.value = ""; if (!file || programLoading || programRunning) return; + setProgramLoading(true); + startPreviewProgress(file.name); try { const source = normalizeProgramSource(await file.text()); if (!source.trim()) throw new Error("program_file_empty"); const path = file.name === "3D_Chips.ngc" ? "linuxcnc/nc_files/3D_Chips.ngc" : `browser/${file.name}`; setProgramText(source); setProgramPath(path); - setProgramLoading(true); setPlotCleared(false); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); setCaseStatus(`${file.name} loading through linuxcnc-wasm...`); await clearLoadedPreview(); - await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false }); + markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径"); + const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false }); + finishPreviewProgress(path, programLoad.response); setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); setCaseStatus(`${path} loaded through linuxcnc-wasm`); } catch (error) { setOperationTrace(operationController.exportTrace()); + failPreviewProgress(error instanceof Error ? error.message : "program_file_load_failed"); setCommandStatus(`PROGRAM_LOAD blocked: ${error instanceof Error ? error.message : "program_file_load_failed"}`); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "program_file_load_failed"}`); } finally { @@ -379,7 +439,7 @@ export function App() {
AXIS AXISLinuxCNC Web/WASM
{state.status.toUpperCase()}{taskState}
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setBlockDelete((value) => !value)} /> setOptionalStop((value) => !value)} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
-
Load caseLoad 3D_Chips.ngc{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}{commandStatus}
+
Load caseLoad 3D_Chips.ngc{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}{commandStatus}
{ void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
diff --git a/web/react/src/styles.css b/web/react/src/styles.css index 6b90ff1..2d41e3b 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -58,6 +58,16 @@ button { color: inherit; } .case-status { min-width: 0; flex: 1 1 auto; } .case-status.loading { color: #805400; font-weight: 700; } .command-status { margin-left: auto; } +.preview-load-indicator { display: flex; align-items: center; min-width: min(360px, 30vw); max-width: 420px; flex: 1 1 260px; gap: 6px; color: #805400; font: 10px ui-monospace, monospace; } +.preview-load-indicator span { min-width: 0; overflow: hidden; flex: 1 1 auto; text-overflow: ellipsis; white-space: nowrap; } +.preview-load-indicator progress { width: 124px; height: 12px; flex: 0 0 124px; accent-color: #9a6a13; } +.preview-load-indicator output { min-width: 4ch; color: #5a430f; text-align: right; } +.preview-load-indicator.ready { color: #185c1d; } +.preview-load-indicator.ready progress { accent-color: #3d7d45; } +.preview-load-indicator.ready output { color: #185c1d; } +.preview-load-indicator.error { color: #8b1010; } +.preview-load-indicator.error progress { accent-color: #a83232; } +.preview-load-indicator.error output { color: #8b1010; } .machine-sequence { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 6px 9px; border-bottom: 1px solid #858585; background: #cfcfcf; } .machine-sequence-heading { display: grid; gap: 2px; min-width: 148px; } @@ -245,6 +255,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; } .axis-brand span { display: none; } .axis-case-strip { flex-wrap: wrap; } .axis-case-strip select { max-width: 62vw; } + .preview-load-indicator { min-width: min(100%, 360px); flex-basis: 100%; } .command-status { margin-left: 0; width: 100%; } .machine-sequence { align-items: flex-start; flex-direction: column; gap: 5px; } .machine-sequence-heading { min-width: 0; }