Keep G-code matched to imported AC or BC machine type

This commit is contained in:
2026-09-21 19:52:31 +08:00
parent 86cfa2b7f0
commit 0de96bbc72
20 changed files with 315 additions and 4 deletions

View File

@@ -18,6 +18,7 @@ import { XyzacDiagnostics, XyzacLiveParameters, XyzacProgramPane, XyzacTrtPanel
import { projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract";
import { DEFAULT_MACHINE, MACHINE_DEFINITIONS, machineDefinition } from "../../workspace/machine-definitions.mjs";
import { RtcpProgramLibrary, type RtcpProgram } from "./RtcpProgramLibrary";
import { assertProgramMachine } from "../../workspace/program-machine-compatibility.mjs";
import { MachineManagerPanel } from "./MachineManagerPanel";
export const SELECTED_XYZAC_CASE = DEFAULT_MACHINE.entrypoint;
@@ -536,6 +537,13 @@ export function App() {
const bundle = typeof source === "function" ? await source() : source;
readTrtGeometry(bundle);
const nextModel = model ? model.current : importedMachine ? updateImportedMachineGeometry(importedMachine, bundle) : null;
const targetMachine = machineDefinition(bundle.entrypoint);
if (!targetMachine) throw new Error("目标机床类型无法识别");
const machineChanged = targetMachine.coordinates !== selectedMachine.coordinates;
const nextProgramPath = machineChanged ? targetMachine.program : programPath;
const nextProgramSource = machineChanged ? normalizeProgramSource(bundle.files?.[nextProgramPath] ?? "") : programText;
if (!nextProgramSource.trim()) throw new Error("目标机型程序资源缺失");
assertProgramMachine(nextProgramSource, targetMachine.coordinates, nextProgramPath);
const configuration = toWorkerConfiguration(bundle);
startPreviewProgress("机床参数");
workspaceRequested = true;
@@ -545,9 +553,11 @@ export function App() {
setCasePath(bundle.entrypoint);
setImportedMachine(nextModel);
if (model) model.committed = true;
const loaded = await operationController.execute("PROGRAM_LOAD", { source: programText, path: programPath }, { gate: false });
finishPreviewProgress(programPath, loaded.response);
setCommandStatus("机床参数已应用,程序已重新解析,待重新回零");
setProgramText(nextProgramSource); setProgramPath(nextProgramPath);
const loaded = await operationController.execute("PROGRAM_LOAD", { source: nextProgramSource, path: nextProgramPath }, { gate: false });
finishPreviewProgress(nextProgramPath, loaded.response);
setCaseStatus(`${targetMachine.coordinates}: ${nextProgramPath} loaded through linuxcnc-wasm`);
setCommandStatus(machineChanged ? `已切换为 ${targetMachine.coordinates} 并加载对应程序,请重新回零` : "机床参数已应用,程序已重新解析,待重新回零");
} catch (error) {
if (workspaceRequested) failPreviewProgress(error instanceof Error ? error.message : String(error));
else setCommandStatus(`机床配置未应用:${error instanceof Error ? error.message : String(error)}`);
@@ -656,6 +666,7 @@ export function App() {
.then(() => {
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径");
assertProgramMachine(programText, selectedMachine.coordinates, programPath);
return operationController.execute("PROGRAM_LOAD", { source: programText, path: programPath }, { state: axisState, gate: false });
})
.then((operation) => {
@@ -683,6 +694,7 @@ export function App() {
if (!source.trim()) throw new Error("program_file_empty");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
const path = file.name === "3D_Chips.ngc" ? "linuxcnc/nc_files/3D_Chips.ngc" : `browser/${file.name}`;
assertProgramMachine(source, selectedMachine.coordinates, path);
setProgramText(source);
setProgramPath(path);
setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview");

View File

@@ -1,4 +1,5 @@
declare module "*.mjs" {
export function assertProgramMachine(source: string, coordinates: string, path?: string): void;
export const WEB_SETTINGS: typeof import("../../workspace/web-settings.json");
export function axisImagePath(name: string, base?: string): string;
export function readTrtConfiguration(configuration: { entrypoint: string; files: Record<string, string> }): { kinematics: { coordinates: string; axis_limits: Record<string, number[]>; axis_velocity_limits: Record<string, number> }; home_limits: Record<string, any> };

View File

@@ -1,3 +1,4 @@
import { programMachineMismatch } from "../workspace/program-machine-compatibility.mjs";
import { exportNativeCheckpoint, restoreNativeCheckpoint, validateNativeCheckpoint } from "./native-checkpoint.mjs";
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
import { capabilities, validateLimit } from "./capabilities.mjs";
@@ -373,6 +374,8 @@ export class SimSession {
const path = `${basePath.replace(/\/demos$/, "")}/${suffix}`;
const source = this.workspaceFiles[path];
if (typeof source !== "string") return { status: "error", code: "program_subroutine_missing", path };
const mismatch = programMachineMismatch(source, coordinates, path);
if (mismatch) return { status: "error", code: "program_machine_mismatch", reason: mismatch, path };
const added = this.xyzacProgram.addSubroutine(name, source, path);
if (added.status !== "verified") return added;
}
@@ -604,6 +607,18 @@ export class SimSession {
}
xyzacCommandGate(input) {
const coordinates = this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates;
let mismatch = "";
if (input.kind === "PROGRAM_LOAD") {
mismatch = programMachineMismatch(input.payload?.source ?? input.payload?.text ?? input.payload?.program_source, coordinates, input.payload?.path);
} else if (["PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_RESUME"].includes(input.kind)) {
const source = this.xyzacProgram.executionSource || this.xyzacProgram.source;
const path = this.xyzacProgram.path;
const previous = this.programCompatibility;
if (!previous || previous.source !== source || previous.path !== path || previous.coordinates !== coordinates) this.programCompatibility = { source, path, coordinates, mismatch: programMachineMismatch(source, coordinates, path) };
mismatch = this.programCompatibility.mismatch;
}
if (mismatch) return { enabled: false, reason: mismatch, action: PANEL_GATE_ACTIONS[input.kind] ?? null };
const task = this.task.export();
const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {};

View File

@@ -0,0 +1,32 @@
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 { SimSession, CONTEXT_ID } from './sim-session.mjs';
import { programMachineMismatch } from '../workspace/program-machine-compatibility.mjs';
for (const [coordinates, forbidden] of [['XYZAC','B'], ['XYZBC','A']]) {
for (const value of ['0','-12','[1+2]','#1',' #<angle>']) assert.match(programMachineMismatch(`G1X2${forbidden}${value}\nM2`,coordinates,'renamed.ngc'),/不匹配/);
for (const source of ['(A10 B20)\nG1X1 ; A30 B40\nM2','#<A1>=2\nG1X#<A1>\nM2','G1X[ABS[-1]+ATAN[1]/[2]]\nM2','O<ABC1> sub\nG1X1\nO<ABC1> endsub']) assert.equal(programMachineMismatch(source,coordinates,'generic.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()};}catch{return{ok:false,text:async()=>''};}};
const core=await createModule({wasmBinary:await readFile(new URL('../../build-wasm/linuxcnc-sim-core.wasm',import.meta.url))});
const session=new SimSession(core,{sessionId:'compatibility'});let seq=0;
const send=(kind,payload={})=>session.accept({schema_version:1,context_id:CONTEXT_ID,session_id:session.sessionId,kind,seq:++seq,operation_id:`compat-${seq}`,payload});
send('HELLO');send('SESSION_ATTACH');const rows=[];
for(const coordinates of ['XYZAC','XYZBC']){
const bundle=await loadSimulationCase({path:`linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/${coordinates.toLowerCase()}-trt.ini`,fetchImpl});
assert.equal(send('LOAD_WORKSPACE',{root_path:'/workspace',configuration:toWorkerConfiguration(bundle)}).kind,'COMMAND_RESULT');
const own=coordinates[3],other=own==='A'?'B':'A';
assert.equal(send('PROGRAM_LOAD',{source:`G21 G90\nG1 X1 ${own}5 C3 F120\nM2\n`,path:'compatible.ngc'}).kind,'COMMAND_RESULT');
const previous=session.xyzacProgram.source;
for(const source of [`G1${other}0 F120\nM2\n`,`G1${other}#1 F120\nM2\n`]){
const rejected=send('PROGRAM_LOAD',{source,path:'renamed.ngc'});assert.equal(rejected.kind,'ERROR');assert.match(rejected.payload.gate_reason,/不匹配/);assert.equal(session.xyzacProgram.source,previous);
}
// Simulate a legacy/recovered taskfile bypassing the loading gate.
session.xyzacProgram.source=`G1 ${other}10 F120\nM2\n`;session.xyzacProgram.executionSource=session.xyzacProgram.source;
for(const kind of ['PROGRAM_RUN','PROGRAM_STEP','PROGRAM_RESUME']){const rejected=send(kind);assert.equal(rejected.kind,'ERROR');assert.match(rejected.payload.gate_reason,/不匹配/);}
session.xyzacProgram.source=previous;session.xyzacProgram.executionSource=previous;
assert.equal(send('PROGRAM_LOAD',{source:'(A and B mentioned in comments)\nG21 G90\nG1 X1 F120\nM2\n',path:'generic.ngc'}).kind,'COMMAND_RESULT');
rows.push({coordinates,load_rejected:true,legacy_execution_rejected:true,generic_allowed:true});
}
console.log(JSON.stringify({report_id:'TRT-PROGRAM-COMPAT-WORKER',status:'verified',rows}));

View File

@@ -0,0 +1,30 @@
// This is a machine-family guard, not a replacement for the LinuxCNC parser.
// Ignore comments, named parameters, O labels and bracket expression contents;
// inspect address words at block level, including zero and parameter values.
export function programMachineMismatch(source, coordinates, path = "") {
if (!["XYZAC", "XYZBC"].includes(coordinates) || typeof source !== "string") return "";
const family = String(path).match(/xyz[ab]c/ig)?.map((s) => s.toUpperCase()) ?? [];
if (family.some((value) => value !== coordinates)) return `程序机型不匹配:${path} 不适用于 ${coordinates}`;
let comment = 0, bracket = 0, semicolon = false, named = false, code = "";
for (const char of source) {
if (char === '\n' || char === '\r') { semicolon = false; code += '\n'; continue; }
if (semicolon) continue;
if (char === '(') { comment++; continue; }
if (comment) { if (char === ')') comment--; continue; }
if (char === ';') { semicolon = true; continue; }
if (char === '<') { named = true; code += ' '; continue; }
if (named) { if (char === '>') named = false; continue; }
if (char === '[') { if (!bracket) code += '['; bracket++; continue; }
if (bracket) { if (char === ']') bracket--; continue; }
code += char;
}
const forbidden = coordinates === "XYZBC" ? "A" : "B";
const pattern = new RegExp(`${forbidden}\\s*(?=[+\\-\\d.#\\[])`, "i");
const lines = code.split('\n');
const index = lines.findIndex((line) => pattern.test(line));
return index < 0 ? "" : `程序机型不匹配:${coordinates} 不支持 ${forbidden} 轴指令(第 ${index + 1} 行),请选择对应机型的 G 代码`;
}
export function assertProgramMachine(source, coordinates, path = "") {
const error = programMachineMismatch(source, coordinates, path);
if (error) throw new Error(error);
}