Lock AXIS panel contract to source behavior

This commit is contained in:
2026-09-07 18:35:05 +08:00
parent f94f9b357e
commit 845075f4a3
7 changed files with 361 additions and 175 deletions

View File

@@ -185,7 +185,6 @@ export function App() {
const jogStepPending = useRef(false);
const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
const programFileInput = useRef<HTMLInputElement>(null);
const [plotCleared, setPlotCleared] = useState(false);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
const feedOverridePending = useRef<number | null>(null);
const feedOverrideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -314,9 +313,6 @@ export function App() {
if (index >= 0 && homedAxes[index] && typeof confirm === "function" && !confirm(`${selectedAxis.toUpperCase()} is already homed. Re-home this joint?`)) return;
void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined);
};
const clearLoadedPreview = async () => {
await operationController.execute("VISUAL_CLEAR", {}, { state: axisState, gate: false });
};
const stopPreviewProgress = () => {
if (previewProgressTimer.current) {
clearInterval(previewProgressTimer.current);
@@ -366,8 +362,7 @@ export function App() {
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();
setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH);
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);
@@ -393,8 +388,7 @@ export function App() {
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 clearLoadedPreview();
setProgramText(source); setProgramPath(path);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false });
finishPreviewProgress(path, programLoad.response);
@@ -412,8 +406,7 @@ export function App() {
setProgramLoading(true);
startPreviewProgress(programPath || "当前程序");
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setPlotCleared(false);
void clearLoadedPreview()
void Promise.resolve()
.then(() => {
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
return operationController.execute("PROGRAM_LOAD", { source: programText, path: programPath }, { state: axisState, gate: false });
@@ -444,10 +437,8 @@ export function App() {
const path = file.name === "3D_Chips.ngc" ? "linuxcnc/nc_files/3D_Chips.ngc" : `browser/${file.name}`;
setProgramText(source);
setProgramPath(path);
setPlotCleared(false);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");
setCaseStatus(`${file.name} loading through linuxcnc-wasm...`);
await clearLoadedPreview();
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source, path }, { state: axisState, gate: false });
finishPreviewProgress(path, programLoad.response);
@@ -487,7 +478,7 @@ export function App() {
if (!event.ctrlKey && event.key === "B") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: true }); return; }
if (!event.ctrlKey && event.key === "b") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: false }); return; }
const key = event.key.toLowerCase();
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").then(() => setPlotCleared(true)).catch(() => undefined); return; }
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").catch(() => undefined); return; }
if (event.ctrlKey && key === "r") { event.preventDefault(); loadProgram(); return; }
if (event.ctrlKey && event.key === "Home") { event.preventDefault(); homeAll(); return; }
if (event.ctrlKey && event.key === "End") { event.preventDefault(); promptTouchOff(true); return; }
@@ -583,7 +574,7 @@ export function App() {
const visibleProgramStatus = programLoading ? "loading" : String(state.program?.program_status ?? "unknown");
const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : [];
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 xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).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";
useEffect(() => {
if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
@@ -618,7 +609,7 @@ export function App() {
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} loading={programLoading} />
<XyzacLiveParameters snapshot={state.snapshot} />
<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" data-axis-control="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" data-axis-control="tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={enterMdiMode}>MDI [F5]</button><button id="axis-tab-diagnostics" data-axis-control="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} canJog={!programLoading && manualReady && gates.jog.enabled} canHome={!programLoading && gates.home.enabled} canSpindle={!programLoading && manualReady && Boolean(gates.spindle?.enabled)} canCoolant={!programLoading && idle && manualReady && Boolean(gates.coolant?.enabled)} canTouchOff={!programLoading && manualReady && Boolean(gates.touchOff?.enabled)} canToolTouchOff={!programLoading && manualReady && Boolean(gates.toolTouchOff?.enabled)} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleDirection={spindleDirection} spindleBrake={spindleBrake} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} onHomeAxis={homeAxis} onTouchOff={() => promptTouchOff(false)} onToolTouchOff={() => promptTouchOff(true)} jogScale={Number.isFinite(sourceJogScale) ? sourceJogScale : null} onJogScale={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={!programLoading && manualReady && 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" data-axis-control="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" data-axis-control="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={programLoading ? null : 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>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" data-axis-control="override-jog-speed" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={programLoading || !gates.jog.enabled || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" data-axis-control="override-feed" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" data-axis-control="override-spindle" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={programLoading || !gates.override.enabled || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" data-axis-control="override-max-velocity" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={programLoading || !gates.override.enabled || maxVelocityPercent === null} aria-label="Max velocity" /></label><label title="blocked_by_abi: LinuxCNC rapidrate is not exposed by the selected WASM TP"><span>Rapid Override</span><output>Unavailable</output><input type="range" data-axis-control="override-rapid" min="0" max="120" value="0" disabled aria-label="Rapid override" /></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-control={`preview-layer-${layer}`} data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" data-axis-control="override-jog-speed" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={programLoading || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" data-axis-control="override-feed" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" data-axis-control="override-spindle" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={programLoading || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" data-axis-control="override-max-velocity" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={programLoading || maxVelocityPercent === null} aria-label="Max velocity" /></label><label title="blocked_by_abi: LinuxCNC rapidrate is not exposed by the selected WASM TP"><span>Rapid Override</span><output>Unavailable</output><input type="range" data-axis-control="override-rapid" min="0" max="120" value="0" disabled aria-label="Rapid override" /></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-control={`preview-layer-${layer}`} data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<XyzacProgramPane status={visibleProgramStatus} currentLine={visibleCurrentLine} sourceHash={programLoading ? "" : String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={visibleExecutionTrace} programStatus={visibleProgramStatus} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" data-axis-control="program-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
{aboutOpen && <section className="axis-about-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setAboutOpen(false); }}><div className="axis-about-dialog" role="dialog" aria-modal="true" aria-labelledby="axis-about-title"><img src={axisIcon("axis-24x24.png")} alt="" /><h2 id="axis-about-title">LinuxCNC AXIS Web/WASM</h2><p>Source-compatible control panel for the selected XYZAC TRT simulation.</p><TextButton controlId="about-close" onClick={() => setAboutOpen(false)}>OK</TextButton></div></section>}

View File

@@ -175,7 +175,7 @@ export class SimSession {
const task = statePayload.task ?? this.task.export();
// The loaded toolpath is immutable during execution. Avoid cloning all
// WASM pose samples into every 10 ms step response; the frontend retains
// the canonical path from PROGRAM_LOAD/PROGRAM_RESTART.
// the canonical path published by the latest program transaction.
const xyzacProgramActive = Boolean(this.xyzacProgram);
const lightweightXyzacStep = input.kind === "SIM_STEP" && xyzacProgramActive;
const traceOnlyStep = lightweightXyzacStep && input.payload?.trace_only === true;
@@ -1997,7 +1997,6 @@ export class SimSession {
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
this.livePathEpoch += 1;
const savedMode = this.task.state.mode;
const restoreMode = () => {
if (savedMode !== "MANUAL" && this.task.state.mode !== savedMode) this.task.setMode(savedMode);
@@ -2077,12 +2076,11 @@ export class SimSession {
if (this.xyzacProgram) {
const previousProgramStatus = this.xyzacProgram.snapshot().program_status;
const result = this.xyzacProgram.start();
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
const result = this.program.start();
if (result.status === "verified") {
this.livePathEpoch += 1;
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
@@ -2182,12 +2180,11 @@ export class SimSession {
this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false;
const result = this.resetXyzacProgramToLoaded();
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.abort(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
if (result.status === "verified") { this.wasmMotion.abort(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
const result = this.program.restart();
if (result.status === "verified") {
this.livePathEpoch += 1;
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover();

View File

@@ -30,13 +30,14 @@ const loaded = send("PROGRAM_LOAD", { source, path: "live-path.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
const loadEpoch = epoch(loaded);
const previewCount = loaded.payload.panel_snapshot.visual.toolpath.length;
assert.ok(loadEpoch > 0 && previewCount > 0);
assert.equal(loadEpoch, 0);
assert.ok(previewCount > 0);
send("POWER", { active: true });
send("HOME", { all: true });
const run = send("PROGRAM_RUN");
const runEpoch = epoch(run);
assert.equal(runEpoch, loadEpoch + 1);
assert.equal(runEpoch, loadEpoch);
send("SIM_STEP", { cycles: 5, response: "motion_delta" });
const pause = send("PROGRAM_PAUSE");
assert.equal(epoch(pause), runEpoch);
@@ -55,15 +56,17 @@ assert.equal(epoch(completed), runEpoch);
assert.equal(Object.hasOwn(completed.payload.panel_snapshot.visual, "toolpath"), false);
const rerun = send("PROGRAM_RUN");
assert.equal(epoch(rerun), runEpoch + 1);
assert.equal(epoch(rerun), runEpoch);
const abort = send("PROGRAM_ABORT");
assert.equal(epoch(abort), runEpoch + 1);
assert.equal(epoch(abort), runEpoch);
const replacement = send("PROGRAM_LOAD", { source: "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n", path: "replacement-live-path.ngc" });
assert.equal(epoch(replacement), runEpoch + 2);
assert.equal(epoch(replacement), runEpoch);
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const restart = send("PROGRAM_RESTART");
assert.equal(epoch(restart), runEpoch);
const clear = send("VISUAL_CLEAR");
assert.equal(epoch(clear), runEpoch + 3);
assert.equal(epoch(clear), runEpoch + 1);
assert.equal(clear.payload.panel_snapshot.visual.toolpath.length, 1);
assert.equal(session.previewState.segments.length, 1);
@@ -71,6 +74,6 @@ console.log(JSON.stringify({
schema: "xyzac-live-path-lifecycle.v1",
status: "verified",
source: "axis.py:LivePlotter/clear_live_plot/task_pause/task_resume/task_stop",
epochs: { load: loadEpoch, run: runEpoch, pause: epoch(pause), resume: epoch(resume), completed: epoch(completed), rerun: epoch(rerun), abort: epoch(abort), replacement: epoch(replacement), clear: epoch(clear) },
epochs: { load: loadEpoch, run: runEpoch, pause: epoch(pause), resume: epoch(resume), completed: epoch(completed), rerun: epoch(rerun), abort: epoch(abort), replacement: epoch(replacement), restart: epoch(restart), clear: epoch(clear) },
static_preview: { initial_segments: previewCount, clear_plot_segments: clear.payload.panel_snapshot.visual.toolpath.length },
}));