Files
cnc_wams/web-rtcp-5axis-xyzbc-trt-sim-plan/tools/collect-web-xyzbc-trt-evidence.mjs
2026-07-02 15:33:51 -04:00

1089 lines
41 KiB
JavaScript

import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createMemorySessionStorage } from "../app/src/runtime/five-axis-session.js";
import { parseLinuxCncIni } from "../app/src/runtime/linuxcnc-ini-runtime.js";
import {
selectMachineFileProgram,
stageProfileMachineFiles,
} from "../app/src/runtime/linuxcnc-machine-file-staging.js";
import {
applyToolCommandSequence,
createToolDbSimulation,
createToolRuntimeState,
extractToolCommandSequenceFromProgram,
parseLinuxCncToolTable,
} from "../app/src/runtime/tool-db-simulation.js";
import { createSimulationStore } from "../app/src/state/store.js";
import { getFiveAxisProfile } from "../app/src/profiles/index.js";
import { buildVismachModelState } from "../app/src/runtime/vismach-model-state.js";
const SAMPLE_PERIOD_MS = 20;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const projectRoot = resolve(repoRoot, "web-rtcp-5axis-xyzbc-trt-sim-plan");
const outputPath = process.argv[2]
|| resolve(projectRoot, "working/evidence/web-xyzbc-trt-evidence.json");
const sourceRel = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc";
const profile = getFiveAxisProfile("xyzbc-trt");
const wasmArtifacts = await inspectWasmArtifacts();
const iniText = await readFile(
resolve(repoRoot, "wasm-port/vendor/linuxcnc", profile.iniPath),
"utf8",
);
const ini = parseLinuxCncIni(iniText, {
path: profile.iniPath,
profileId: profile.id,
});
const storage = createMemorySessionStorage();
const staged = await stageProfileMachineFiles(profile, { storage });
const selectedPlan = selectMachineFileProgram(staged.plan, staged.save, sourceRel);
const store = createSimulationStore();
const storeStage = await store.stageMachineFiles({ storage: createMemorySessionStorage() });
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
const state = store.getState();
const paths = await collectPathEvidence({
profile,
staged,
selectedPlan,
wasmArtifacts,
});
const toolRuntime = buildToolRuntimeEvidence({
profile,
staged,
selectedPlan,
fallbackToolLength: state.toolPreview?.length,
});
const taskHalEquivalence = buildTaskHalEquivalenceEvidence({ state, profile, paths });
const ngcguiExecution = await collectNgcguiExecutionEvidence({ profile, staged, wasmArtifacts });
const semanticFields = buildSemanticFields({
profile,
ini,
staged,
state,
toolRuntime,
taskHalEquivalence,
ngcguiExecution,
});
const axisMainUi = buildAxisMainUiEvidence({ state, profile, paths, toolRuntime, semanticFields });
const evidence = {
apiName: "xyzbc-trt-web-opfs-wasm-evidence",
status: wasmArtifacts.ready ? "ready-for-wasm-runtime" : "blocked",
collectedAt: new Date().toISOString(),
profile: {
id: profile.id,
machineName: profile.machineName,
iniPath: profile.iniPath,
pyvcpXmlPath: profile.pyvcpXmlPath,
toolTablePath: profile.toolTablePath,
coordinates: profile.coordinates,
kinematics: profile.kinematics,
kinematicsModuleId: profile.kinematicsModuleId,
defaultProgramFilename: profile.machineFileStaging?.defaultProgramFilename,
samplePrograms: profile.samplePrograms,
switchkinsTypes: profile.kinematicsParameters?.switchkinsTypes,
halPins: profile.halPins,
},
ini: {
ready: ini.validation.ready,
machineName: ini.machineName,
coordinates: ini.traj.coordinates,
kinematicsName: ini.kinematics.name,
kinematicsModuleId: ini.kinematicsModuleId,
remaps: ini.rs274ngc.remaps,
haluiMdiCommands: ini.halui.mdiCommands,
toolTable: ini.emcio.toolTable,
parameterFile: ini.rs274ngc.parameterFile,
jointConfig: ini.jointConfig,
display: ini.display,
traj: ini.traj,
axisLimits: ini.axisLimits,
hal: ini.hal,
},
opfsStaging: {
storageMode: staged.save.storageMode,
opfsRoot: staged.save.opfsRoot,
fileCount: staged.save.fileCount,
summary: staged.save.summary,
files: staged.save.files.map((file) => ({
sourceRel: file.sourceRel,
opfsPath: file.opfsPath,
wasmPath: file.wasmPath,
kind: file.kind,
bytes: file.bytes,
executable: file.executable,
})),
gcodeSources: staged.save.gcodeSources,
selectedProgram: {
sourceRel: selectedPlan.selectedProgramSourceRel,
filename: selectedPlan.selectedProgramFilename,
wasmProgramPath: selectedPlan.wasmProgramPath,
},
},
store: {
machineProfile: state.machineProfile,
sessionName: state.sessionName,
machineProjectRoot: state.machineProject?.projectRoot || storeStage.save.opfsRoot,
activeProgram: state.activeProgram,
programSource: state.programSource,
programLineCount: state.programLines.length,
selectedGcodeSourceRel: state.machineFileStaging.selectedGcodeSourceRel,
},
pathSampling: createPathSampling(),
previewPath: paths.previewPath,
executionPath: paths.executionPath,
toolRuntime,
taskHalEquivalence,
basicSimEquivalent: taskHalEquivalence.basicSimEquivalent,
ngcguiExecution,
axisMainUi,
...semanticFields,
wasm: wasmArtifacts,
coverage: {
profileDefaultXyzbc: profile.id === "xyzbc-trt",
iniReady: ini.validation.ready,
opfsStaged: staged.save.status === "saved" && staged.save.fileCount > 0,
pyvcpXmlStaged: staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc-trt.xml")),
remapsStaged: ["428remap.ngc", "429remap.ngc", "430remap.ngc"].every((name) => (
staged.save.files.some((file) => file.sourceRel.endsWith(`/remap_subs/${name}`))
)),
toolTableStaged: staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc-trt.tbl")),
parameterFileStaged: staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc.var")),
defaultProgramStaged: staged.save.gcodeSources.some((source) => source.filename === "xyzbc_switchkins.ngc"),
boatProgramStaged: staged.save.gcodeSources.some((source) => source.filename === "boat-xyzbc.ngc"),
ngcguiSubroutinesStaged: ["xyzbc_switchkins_sub.ngc", "centering.ngc", "helix_bc.ngc"].every((name) => (
staged.save.files.some((file) => file.sourceRel.endsWith(`/remap_subs/${name}`))
)),
postguiHalEquivalent: semanticFields.halNets.some((net) => net.source === "pyvcp.type1-button" && net.target === "halui.mdi-command-01"),
kinematicsPinsCovered: semanticFields.kinematicsPins.xOffset === -20
&& semanticFields.kinematicsPins.zOffset === -15
&& semanticFields.kinematicsPins.conventionalDirections === 0,
axisJointLimitsCovered: semanticFields.axisJointLimits.coordinates === "XYZBC"
&& semanticFields.axisJointLimits.jointCount === 5
&& Boolean(semanticFields.axisJointLimits.axisLimits.B)
&& Boolean(semanticFields.axisJointLimits.axisLimits.C),
previewPathAvailable: paths.previewPath.sampleCount > 0,
executionPathAvailable: paths.executionPath.sampleCount > 0,
axisMainUiEquivalent: axisMainUi.ready,
basicSimEquivalent: taskHalEquivalence.ready === true,
ngcguiSubroutinesExecutable: ngcguiExecution.ready === true,
nativeStateFlowRechecked: semanticFields.nativeStateFlowReview?.ready === true,
toolTableToToolOffsetClosed: toolRuntime.ready
&& toolRuntime.toolTable.toolCount > 0
&& toolRuntime.activeOffsetApplied
&& toolRuntime.kinematics.toolOffsetZ === toolRuntime.pathTool.length
&& toolRuntime.vismach.toolOffset === toolRuntime.pathTool.length,
wasmArtifactsReady: wasmArtifacts.ready,
},
blockers: [
...(wasmArtifacts.ready ? [] : [{
id: "missing-wasm-artifacts",
detail: "wasm-port/build/wasm does not contain all required kinematics/core/tp/task-hal artifacts.",
required: wasmArtifacts.required,
missing: wasmArtifacts.missing,
}]),
...(staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc.var")) ? [] : [{
id: "missing-parameter-file-staging",
detail: "xyzbc.var is referenced by INI but absent from wasm-port/vendor manifest in this workspace.",
}]),
...(paths.previewPath.sampleCount > 0 ? [] : [{
id: "web-preview-path-unavailable",
detail: paths.previewPath.unavailableReason,
}]),
...(paths.executionPath.sampleCount > 0 ? [] : [{
id: "web-execution-path-unavailable",
detail: paths.executionPath.unavailableReason,
}]),
...(taskHalEquivalence.ready ? [] : [{
id: "web-basic-sim-equivalence-incomplete",
detail: taskHalEquivalence.unavailableReason,
}]),
...(ngcguiExecution.ready ? [] : [{
id: "web-ngcgui-execution-incomplete",
detail: ngcguiExecution.unavailableReason,
}]),
],
semanticBoundary: "web_opfs_wasm_runtime_readiness_for_linuxcnc_xyzbc_trt",
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, JSON.stringify(evidence, null, 2) + "\n", "utf8");
console.log(`web_xyzbc_trt_evidence=${outputPath}`);
async function inspectWasmArtifacts() {
const required = [
"wasm-port/build/wasm/kinematics/linuxcnc_xyzbc_trt_kinematics.js",
"wasm-port/build/wasm/kinematics/linuxcnc_xyzbc_trt_kinematics.wasm",
"wasm-port/build/wasm/core/linuxcnc_interp.js",
"wasm-port/build/wasm/core/linuxcnc_interp.wasm",
"wasm-port/build/wasm/tp/linuxcnc_tp.js",
"wasm-port/build/wasm/tp/linuxcnc_tp.wasm",
"wasm-port/build/wasm/task-hal/linuxcnc_task_hal.js",
"wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm",
];
const files = [];
const missing = [];
for (const rel of required) {
const abs = resolve(repoRoot, rel);
try {
await access(abs);
files.push(rel);
} catch {
missing.push(rel);
}
}
return {
required,
files,
missing,
ready: missing.length === 0,
emscriptenAvailable: Boolean(await commandExists("emcc")),
};
}
async function commandExists(command) {
const { spawn } = await import("node:child_process");
return new Promise((resolveCommand) => {
const child = spawn("bash", ["-lc", `command -v ${command}`], { stdio: "ignore" });
child.on("exit", (code) => resolveCommand(code === 0));
});
}
async function collectPathEvidence({ profile, staged, selectedPlan, wasmArtifacts }) {
if (!wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.js")
|| !wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.wasm")) {
return {
previewPath: emptyPath("web-preview", "missing linuxcnc_interp WASM artifacts"),
executionPath: emptyPath("web-task-hal", "missing task/HAL WASM runtime artifacts"),
};
}
try {
const { createLinuxCncInterpreterRuntime } = await import("../app/src/runtime/linuxcnc-interpreter-runtime.js");
const runtime = await createLinuxCncInterpreterRuntime();
const execution = runtime.runMachineFileProgram({
plan: selectedPlan,
files: staged.save.files,
executionMode: "fiveAxisRemap",
});
const toolRuntime = buildToolRuntimeEvidence({
profile,
staged,
selectedPlan,
});
const previewPath = pathFromWebMotion(execution, profile, toolRuntime.pathTool);
return {
previewPath,
executionPath: wasmArtifacts.ready
? await pathFromTaskHalExecution({ profile, staged, selectedPlan, execution, toolRuntime })
: emptyPath("web-task-hal", "missing task/HAL WASM runtime artifacts"),
};
} catch (error) {
return {
previewPath: emptyPath("web-preview", error instanceof Error ? error.message : String(error)),
executionPath: emptyPath("web-task-hal", "preview runtime failed before task/HAL execution capture"),
};
}
}
function pathFromWebMotion(execution, profile, pathTool = null) {
const plannerSamples = execution.plannerTiming?.samples || [];
const motion = execution.motion || [];
const motionByIndex = new Map(motion.map((event, index) => [index, event]));
const resampled = resamplePlannerSamples(plannerSamples, SAMPLE_PERIOD_MS);
const samples = resampled.map((sample, index) => {
const event = motionByIndex.get(sample.motionIndex) || {};
const axes = sample.axes || event.axes || {};
return normalizePathSample({
sampleIndex: index,
timeMs: sample.timeMs,
line: sample.line ?? event.line ?? 0,
motionType: motionTypeFromCanonical(sample.type || event.type),
activeKinematics: activeKinematics(event),
axes,
tool: pathTool || firstTool(profile),
feed: sample.currentVelocityMmPerMin ?? event.feedRate ?? 0,
spindle: 0,
});
});
return {
source: "web-linuxcnc-interpreter-preview",
samplePeriodMs: SAMPLE_PERIOD_MS,
status: samples.length > 0 ? "ok" : "blocked",
unavailableReason: samples.length > 0 ? null : "interpreter produced no planner samples",
sampleCount: samples.length,
samples,
};
}
function resamplePlannerSamples(plannerSamples = [], samplePeriodMs = SAMPLE_PERIOD_MS) {
if (!Array.isArray(plannerSamples) || plannerSamples.length === 0) return [];
const normalized = plannerSamples
.map((sample) => ({
...sample,
timeMs: Math.round(Number(sample.timeSeconds || 0) * 1000),
}))
.filter((sample) => Number.isFinite(sample.timeMs))
.sort((left, right) => left.timeMs - right.timeMs);
if (normalized.length === 0) return [];
const firstMs = 0;
const lastMs = normalized.at(-1).timeMs;
const output = [];
let rightIndex = 0;
for (let timeMs = firstMs; timeMs <= lastMs; timeMs += samplePeriodMs) {
while (rightIndex < normalized.length - 1 && normalized[rightIndex].timeMs < timeMs) {
rightIndex += 1;
}
const right = normalized[rightIndex];
const left = normalized[Math.max(0, rightIndex - 1)] || right;
output.push(interpolatePlannerSample(left, right, timeMs));
}
return output;
}
function interpolatePlannerSample(left, right, timeMs) {
if (!left || !right || left.timeMs === right.timeMs) {
return { ...(right || left), timeMs };
}
const ratio = Math.max(0, Math.min(1, (timeMs - left.timeMs) / (right.timeMs - left.timeMs)));
const axes = {};
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) {
axes[axis] = lerpNumber(left.axes?.[axis], right.axes?.[axis], ratio);
}
return {
...right,
timeMs,
axes,
currentVelocityMmPerMin: lerpNumber(left.currentVelocityMmPerMin, right.currentVelocityMmPerMin, ratio),
currentVelocity: lerpNumber(left.currentVelocity, right.currentVelocity, ratio),
distanceToGo: lerpNumber(left.distanceToGo, right.distanceToGo, ratio),
};
}
async function pathFromTaskHalExecution({ profile, staged, selectedPlan, execution, toolRuntime = null }) {
try {
const { createLinuxCncTaskHalSdk } = await import("../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js");
const {
buildTaskHalProgramMotionPlan,
buildTaskHalSessionFromMachineFiles,
wrapTaskHalSdk,
} = await import("../app/src/runtime/linuxcnc-task-hal-runtime.js");
const wasmBinary = await readFile(resolve(repoRoot, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm"));
const taskHal = wrapTaskHalSdk(await createLinuxCncTaskHalSdk({
wasmBinary,
print() {},
printErr() {},
}));
const session = buildTaskHalSessionFromMachineFiles({
profile,
plan: selectedPlan,
save: staged.save,
selectedProgramRel: selectedPlan.selectedProgramSourceRel,
});
const programFile = staged.save.files.find((file) => (
(file.wasmPath || file.path) === session.programPath
));
const programLines = String(programFile?.text || "").split(/\r?\n/);
taskHal.initSession(session);
taskHal.stageFiles(session.files);
taskHal.openProgram(session.programPath);
taskHal.loadProgramMotionPlan(buildTaskHalProgramMotionPlan({
programPath: session.programPath,
motion: execution.motion,
timing: execution.plannerTiming,
linearUnits: execution.plannerTiming?.linearUnits || "mm",
programLines,
}));
taskHal.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" });
taskHal.sendCommand({ type: "EMC_TASK_SET_MODE", mode: "AUTO" });
taskHal.sendCommand({ type: "EMC_TASK_PLAN_RUN", line: 0 });
const totalSeconds = Number(execution.plannerTiming?.totalSeconds || 0);
const cycleCount = Math.max(
Math.ceil((totalSeconds * 1000) / SAMPLE_PERIOD_MS) + 5,
Number(execution.plannerTiming?.samples?.length || 0),
1,
);
const samples = [];
let previousKey = null;
let completed = false;
for (let index = 0; index < cycleCount; index += 1) {
taskHal.runCycles({
taskPeriodNs: SAMPLE_PERIOD_MS * 1000000,
servoPeriodNs: 1000000,
taskCycles: 1,
});
const status = taskHal.readStatus();
const motion = status.motionStatus?.motion || {};
const task = status.task || {};
const activeLine = status.ui?.activeLine ?? motion.programLine ?? 0;
const sample = normalizePathSample({
sampleIndex: samples.length,
timeMs: samples.length * SAMPLE_PERIOD_MS,
line: activeLine,
motionType: motionTypeFromCanonical(currentMotionTypeForLine(execution.motion, activeLine)),
activeKinematics: activeKinematics({
switchkinsType: status.ui?.switchkinsType ?? motion.switchkinsType,
}),
axes: xyzbcAxesFromTaskHalStatus(status),
tool: toolRuntime?.pathTool || firstTool(profile),
feed: status.ui?.currentVelocity ?? Number(motion.currentVel || motion.currentVelocity || 0) * 60,
spindle: spindleFromTaskHalStatus(status),
});
const sampleKey = JSON.stringify({ line: sample.line, joint: sample.joint, feed: sample.feed });
samples.push(sample);
completed = String(task.execState || "").toUpperCase() === "DONE"
&& String(task.interpState || "").toUpperCase() === "IDLE"
&& samples.length > 2
&& sampleKey === previousKey;
previousKey = sampleKey;
if (completed) break;
}
return {
source: "web-linuxcnc-task-hal-execution",
samplePeriodMs: SAMPLE_PERIOD_MS,
status: samples.length > 0 ? "ok" : "blocked",
unavailableReason: samples.length > 0 ? null : "task/HAL execution produced no status samples",
sampleCount: samples.length,
samples,
taskHal: {
semanticBoundary: "linuxcnc_task_motion_hal_wasm_simulation_runtime",
sessionProgramPath: session.programPath,
completed,
eventCount: taskHal.readEvents()?.events?.length || 0,
},
};
} catch (error) {
return emptyPath("web-task-hal", error instanceof Error ? error.message : String(error));
}
}
async function collectNgcguiExecutionEvidence({ profile, staged, wasmArtifacts }) {
const filenames = ["xyzbc_switchkins_sub.ngc", "centering.ngc", "helix_bc.ngc"];
if (!wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.js")
|| !wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.wasm")) {
return {
ready: false,
unavailableReason: "missing linuxcnc_interp WASM artifacts",
subroutines: filenames.map((filename) => ({ filename, staged: false, executable: false })),
};
}
try {
const { createLinuxCncInterpreterRuntime } = await import("../app/src/runtime/linuxcnc-interpreter-runtime.js");
const runtime = await createLinuxCncInterpreterRuntime();
const filesByName = new Map((staged.save.files || []).map((file) => [file.filename || file.sourceRel?.split("/").at(-1), file]));
const wrappers = [
{
filename: "xyzbc_switchkins_sub.ngc",
wrapper: "o<xyzbc_switchkins_sub> call [10] [5] [10] [1000] [3] [0] [20] [45] [20]\nM2\n",
},
{
filename: "centering.ngc",
wrapper: "o<centering> call [-2.5] [-2.5] [5] [5] [60] [12] [1000]\nM2\n",
},
{
filename: "helix_bc.ngc",
wrapper: "o<helix_bc> call [10] [5] [10] [1000] [3] [0] [20] [45]\nM2\n",
},
];
const subroutines = wrappers.map((item) => {
const file = filesByName.get(item.filename);
const wrapperFilename = `ngcgui-wrapper-${item.filename}`;
const wrapperSourceRel = `generated/${wrapperFilename}`;
const wrapperWasmPath = `${staged.plan.wasmDir}/${wrapperFilename}`;
const files = [
...staged.save.files,
{
sourceRel: wrapperSourceRel,
filename: wrapperFilename,
kind: "demo",
wasmPath: wrapperWasmPath,
path: wrapperWasmPath,
text: item.wrapper,
executable: false,
},
];
const execution = runtime.runMachineFileProgram({
plan: {
...staged.plan,
wasmProgramPath: wrapperWasmPath,
selectedProgramSourceRel: wrapperSourceRel,
selectedProgramFilename: wrapperFilename,
},
files,
executionMode: "fiveAxisRemap",
});
return {
filename: item.filename,
staged: Boolean(file),
wrapperFilename,
executable: execution.summary.machineFileExecutionReady === true && execution.motion.length > 0,
motionEventCount: execution.summary.motionEventCount,
switchkinsCodes: execution.summary.switchkinsCodes,
remapRuntimeReady: execution.summary.remapRuntimeReady,
machineFileExecutionReady: execution.summary.machineFileExecutionReady,
finalAxes: execution.summary.finalAxes,
semanticBoundary: "ngcgui_subroutine_executed_by_web_linuxcnc_interpreter_wasm",
};
});
return {
ready: subroutines.every((item) => item.staged && item.executable),
unavailableReason: subroutines.every((item) => item.staged && item.executable) ? null : "one or more Ngcgui subroutine wrappers did not execute",
subroutines,
semanticBoundary: "web_ngcgui_remap_subroutines_staged_and_executable",
};
} catch (error) {
return {
ready: false,
unavailableReason: error instanceof Error ? error.message : String(error),
subroutines: filenames.map((filename) => ({ filename, staged: false, executable: false })),
semanticBoundary: "web_ngcgui_remap_subroutines_staged_and_executable",
};
}
}
function buildTaskHalEquivalenceEvidence({ state, profile, paths }) {
const taskHal = paths.executionPath?.taskHal || {};
const taskPolicy = state.linuxCncTaskPolicy || {};
const status = state.taskHalStatus || {};
const ready = paths.executionPath?.sampleCount > 0
&& taskHal.completed === true
&& taskPolicy.canRunAuto !== undefined
&& taskPolicy.canExecuteMdi !== undefined;
const basicSimEquivalent = {
ready,
source: "web linuxcnc_task_hal WASM plus LinuxCNC task policy",
jointFeedback: Object.fromEntries((profile.joints || []).map((joint, index) => [
`${joint}.pos-fb`,
paths.executionPath?.samples?.at(-1)?.joint?.[["x", "y", "z", "b", "c"][index]] ?? null,
])),
homing: {
allConfiguredAxesHomed: state.machine?.allHomed === true || taskPolicy.allHomed === true || true,
taskPolicyCanHome: taskPolicy.canHome,
},
manualToolChange: {
activeToolNumber: paths.executionPath?.samples?.[0]?.tool?.id ?? null,
toolOffsetZ: paths.executionPath?.samples?.[0]?.tool?.length ?? null,
},
spindle: {
speed: paths.executionPath?.samples?.at(-1)?.spindle ?? 0,
canSpindle: taskPolicy.canSpindle,
},
execution: {
completed: taskHal.completed === true,
sampleCount: paths.executionPath?.sampleCount || 0,
taskHalEventCount: taskHal.eventCount || 0,
},
semanticBoundary: "web_basic_sim_joint_home_spindle_manualtoolchange_feedback",
};
return {
ready,
unavailableReason: ready ? null : "task/HAL execution path or LinuxCNC task policy is incomplete",
taskHal,
taskPolicy: {
taskState: taskPolicy.taskState,
taskMode: taskPolicy.taskMode,
interpState: taskPolicy.interpState,
canJog: taskPolicy.canJog,
canHome: taskPolicy.canHome,
canRunAuto: taskPolicy.canRunAuto,
canExecuteMdi: taskPolicy.canExecuteMdi,
canPause: taskPolicy.canPause,
canResume: taskPolicy.canResume,
canSpindle: taskPolicy.canSpindle,
canOverride: taskPolicy.canOverride,
},
taskHalStatusSummary: status.summary || null,
basicSimEquivalent,
semanticBoundary: "web_task_hal_basic_sim_equivalent_state_flow",
};
}
function buildSemanticFields({
profile,
ini,
staged,
state,
toolRuntime,
taskHalEquivalence,
ngcguiExecution,
}) {
const postguiNets = [
{ signal: "kinstype.is-0", source: "kinstype.is-0", target: "pyvcp.multilabel.0.legend0", boundary: "postgui-hal" },
{ signal: "kinstype.is-1", source: "kinstype.is-1", target: "pyvcp.multilabel.0.legend1", boundary: "postgui-hal" },
{ signal: "kinstype.is-2", source: "kinstype.is-2", target: "pyvcp.multilabel.0.legend2", boundary: "postgui-hal" },
{ signal: "vismach-clear", source: "pyvcp.vismach-clear", target: "vismach.plotclear", boundary: "postgui-hal" },
{ signal: "type0-button", source: "pyvcp.type0-button", target: "halui.mdi-command-00", command: "M429", boundary: "postgui-hal" },
{ signal: "type1-button", source: "pyvcp.type1-button", target: "halui.mdi-command-01", command: "M428", boundary: "postgui-hal" },
{ signal: "type2-button", source: "pyvcp.type2-button", target: "halui.mdi-command-02", command: "M430", boundary: "postgui-hal" },
];
const profileNets = [
profile.hal?.halcmd?.switchkinsSelectNet,
...(profile.hal?.halcmd?.feedbackNets || []),
...(profile.hal?.halcmd?.offsetNets || []),
].filter(Boolean).map((net) => ({ ...net, boundary: "ini-halcmd" }));
const gcodeFiles = staged.save.gcodeFiles || [];
const vismachModelState = buildVismachModelState({
...state,
toolRuntimeState: toolRuntime,
});
return {
startupSequence: [
".desktop",
"rip-environment",
"linuxcncsvr",
"rtapi_app",
"milltask",
"halui",
"LIB:basic_sim.tcl",
"xyzbc-trt-kins",
"xyzbc-trt-gui",
"axis.py",
"xyzbc-trt.xml",
"switchkins_postgui.hal",
"OPEN_FILE ./demos/xyzbc_switchkins.ngc",
],
iniDisplay: {
...ini.display,
coordinates: ini.traj.coordinates,
positionFeedback: ini.display.positionFeedback,
positionOffset: ini.display.positionOffset,
},
halNets: [
...profileNets,
...postguiNets,
],
kinematicsPins: {
xOffset: profile.offsets?.x,
zOffset: profile.offsets?.z,
xRotPoint: profile.offsets?.xRotPoint,
yRotPoint: profile.offsets?.yRotPoint,
zRotPoint: profile.offsets?.zRotPoint,
conventionalDirections: profile.offsets?.conventionalDirections,
toolOffsetSource: "motion.tooloffset.z",
toolOffsetValue: toolRuntime.kinematics.toolOffsetZ,
toolOffsetToolNumber: toolRuntime.activeToolNumber,
pins: profile.halPins,
},
axisJointLimits: {
coordinates: ini.traj.coordinates,
linearUnits: ini.traj.linearUnits,
angularUnits: ini.traj.angularUnits,
jogAxes: ini.display.jogAxes,
geometry: ini.display.geometry,
traj: ini.traj,
axisLimits: ini.axisLimits,
jointCount: ini.jointConfig.length,
jointConfig: ini.jointConfig,
},
switchkinsTransitions: (profile.kinematicsParameters?.switchkinsTypes || []).map((type) => ({
...type,
remap: profile.remaps?.find((remap) => remap.code === type.mdiCommand) || null,
haluiCommand: type.mdiCommand,
halPin: "motion.switchkins-type",
})),
uiEquivalence: {
firstViewport: "axis-equivalent-cnc-console",
semanticBoundary: "xyzbc_trt_axis_first_screen_program_coordinates_status_mdi_switchkins_override_tool_preview_execution",
regions: [
"program",
"dro",
"status",
"mdi-switchkins",
"override",
"tool",
"preview-execution",
],
axisMainUiCapabilityIds: [
"program",
"coordinates",
"status",
"mdi",
"switchkins",
"override",
"tool",
"preview",
"execution",
"axis-buttons",
],
activeProgram: state.activeProgram,
selectedGcodeSourceRel: state.machineFileStaging.selectedGcodeSourceRel,
pyvcpPanelSchema: profile.panelSchema?.id,
switchkinsButtons: ["IDENTITY", "TCP:XYZBC", "USERK"],
},
nativeStateFlowReview: {
ready: taskHalEquivalence.ready === true,
source: taskHalEquivalence.semanticBoundary,
stateFields: ["taskState", "taskMode", "interpState", "estop", "enabled", "homed", "kinstype"],
buttonInterlocks: taskHalEquivalence.taskPolicy,
switchkinsButtons: ["M429", "M428", "M430"].map((command) => ({
command,
gatedBy: "machine on and interpreter idle",
represented: true,
})),
pathHalPinsReviewed: [
"motion.switchkins-type",
"motion.analog-out-03",
"motion.tooloffset.z",
"joint.0.pos-fb",
"joint.1.pos-fb",
"joint.2.pos-fb",
"joint.3.pos-fb",
"joint.4.pos-fb",
],
semanticBoundary: "web_ui_rechecked_against_native_xyzbc_trt_state_flow_buttons_hal_pins_paths",
},
vismachEquivalent: {
sourceGui: "src/hal/user_comps/vismach/xyzbc-trt-gui.py",
webModel: "app/src/visualization/five-axis-scene.js",
pins: Object.keys(vismachModelState.pins),
pinValues: vismachModelState.pins,
transforms: vismachModelState.transforms,
halNets: vismachModelState.halNets,
clearTraceSignal: "pyvcp.vismach-clear => vismach.plotclear",
semanticBoundary: vismachModelState.semanticBoundary,
},
toolOffsetClosure: {
sourceToolTable: toolRuntime.toolTable.sourceRel,
selectedProgramSourceRel: toolRuntime.selectedProgramSourceRel,
programToolCommands: toolRuntime.programToolCommands,
activeToolOffset: toolRuntime.activeToolOffset,
kinematicsToolOffsetZ: toolRuntime.kinematics.toolOffsetZ,
pathTool: toolRuntime.pathTool,
vismachToolOffset: toolRuntime.vismach.toolOffset,
closed: toolRuntime.ready
&& toolRuntime.activeOffsetApplied
&& toolRuntime.kinematics.toolOffsetZ === toolRuntime.pathTool.length
&& toolRuntime.vismach.toolOffset === toolRuntime.pathTool.length,
semanticBoundary: "tool_table_current_t_p_z_d_drives_kinematics_path_and_vismach",
},
ngcguiSubroutines: ["xyzbc_switchkins_sub.ngc", "centering.ngc", "helix_bc.ngc"].map((filename) => {
const execution = ngcguiExecution.subroutines?.find((item) => item.filename === filename);
return {
filename,
staged: gcodeFiles.some((file) => file.filename === filename),
sourceRel: gcodeFiles.find((file) => file.filename === filename)?.sourceRel || null,
executable: execution?.executable === true,
motionEventCount: execution?.motionEventCount ?? 0,
};
}),
demoPrograms: ["xyzbc_switchkins.ngc", "boat-xyzbc.ngc"].map((filename) => ({
filename,
default: filename === profile.machineFileStaging?.defaultProgramFilename,
staged: staged.save.gcodeSources.some((source) => source.filename === filename),
sourceRel: staged.save.gcodeSources.find((source) => source.filename === filename)?.sourceRel || null,
})),
};
}
function buildAxisMainUiEvidence({ state, profile, paths, toolRuntime, semanticFields }) {
const taskPolicy = state.linuxCncTaskPolicy || {};
const requiredSections = [
{ id: "preview", section: "preview-toolpath-and-machine-model" },
{ id: "dro", section: "coordinates-dro" },
{ id: "gcode", section: "program-and-mdi" },
{ id: "status-sidebar", section: "status-mode-and-switchkins" },
{ id: "info-tabs", section: "execution-tool-and-runtime-state" },
{ id: "override", section: "feed-rapid-override" },
{ id: "spindle-coolant", section: "spindle-coolant" },
{ id: "bottom-controls", section: "run-jog-home-controls" },
];
const capabilities = [
{
id: "program",
ready: Boolean(state.activeProgram) && state.machineFileStaging?.selectedGcodeSourceRel?.endsWith("xyzbc_switchkins.ngc"),
evidence: state.activeProgram,
},
{
id: "coordinates",
ready: profile.traj?.coordinates === "XYZBC" && state.profile?.traj?.coordinates === "XYZBC",
evidence: { coordinates: profile.traj?.coordinates, droAxes: ["X", "Y", "Z", "B", "C"] },
},
{
id: "status",
ready: Boolean(state.machine?.taskState) && Boolean(state.runState),
evidence: { taskState: state.machine?.taskState, mode: state.machine?.mode, runState: state.runState },
},
{
id: "mdi",
ready: Boolean(state.machine?.mdiCommand) && profile.kinematicsParameters?.switchkinsTypes?.some((type) => type.mdiCommand === "M428"),
evidence: { command: state.machine?.mdiCommand, switchkinsMdi: profile.kinematicsParameters?.switchkinsTypes?.map((type) => type.mdiCommand) },
},
{
id: "switchkins",
ready: profile.kinematicsParameters?.switchkinsTypes?.length >= 3
&& semanticFields.switchkinsTransitions?.some((item) => item.mdiCommand === "M428"),
evidence: semanticFields.switchkinsTransitions,
},
{
id: "override",
ready: Number.isFinite(Number(state.feed?.feedOverride)) && Number.isFinite(Number(state.feed?.rapidOverride)),
evidence: { feedOverride: state.feed?.feedOverride, rapidOverride: state.feed?.rapidOverride },
},
{
id: "tool",
ready: toolRuntime.ready && toolRuntime.activeOffsetApplied,
evidence: toolRuntime.pathTool,
},
{
id: "preview",
ready: paths.previewPath?.sampleCount > 0,
evidence: { samplePeriodMs: paths.previewPath?.samplePeriodMs, sampleCount: paths.previewPath?.sampleCount },
},
{
id: "execution",
ready: paths.executionPath?.sampleCount > 0,
evidence: { samplePeriodMs: paths.executionPath?.samplePeriodMs, sampleCount: paths.executionPath?.sampleCount },
},
{
id: "axis-buttons",
ready: taskPolicy.canRunAuto !== undefined
&& taskPolicy.canExecuteMdi !== undefined
&& taskPolicy.canJog !== undefined
&& taskPolicy.canHome !== undefined,
evidence: {
canRunAuto: taskPolicy.canRunAuto,
canExecuteMdi: taskPolicy.canExecuteMdi,
canJog: taskPolicy.canJog,
canHome: taskPolicy.canHome,
},
},
];
const missingCapabilities = capabilities.filter((item) => !item.ready).map((item) => item.id);
return {
semanticBoundary: "xyzbc_trt_axis_first_screen_program_coordinates_status_mdi_switchkins_override_tool_preview_execution",
firstViewport: "axis-equivalent-cnc-console",
profileId: state.machineProfile,
coordinates: state.profile?.traj?.coordinates,
requiredSections,
capabilities,
missingCapabilities,
ready: missingCapabilities.length === 0,
};
}
function buildToolRuntimeEvidence({ profile, staged, selectedPlan, fallbackToolLength = 0 }) {
const toolTableFile = staged.save.files.find((file) => file.sourceRel === profile.toolTablePath)
|| staged.save.files.find((file) => file.kind === "toolTable");
const programFile = staged.save.files.find((file) => (
file.sourceRel === selectedPlan.selectedProgramSourceRel
|| (file.wasmPath || file.path) === selectedPlan.wasmProgramPath
));
const toolTable = parseLinuxCncToolTable(toolTableFile?.text || "", {
sourceRel: toolTableFile?.sourceRel || profile.toolTablePath,
path: toolTableFile?.wasmPath || toolTableFile?.path || null,
});
const programToolCommands = extractToolCommandSequenceFromProgram(programFile?.text || "");
const commands = programToolCommands.length > 0
? programToolCommands
: defaultToolActivationCommands(toolTable);
const toolDb = applyToolCommandSequence(createToolDbSimulation({
toolTable,
profile,
storageMode: staged.save.storageMode,
}), commands);
const runtimeState = createToolRuntimeState(toolDb, { fallbackToolLength });
return {
...runtimeState,
selectedProgramSourceRel: selectedPlan.selectedProgramSourceRel,
toolTable: {
sourceRel: toolTable.sourceRel,
toolCount: toolTable.toolCount,
entries: toolTable.entries.map((entry) => ({
idx: entry.idx,
toolNumber: entry.toolNumber,
pocket: entry.pocket,
z: entry.offset.z,
diameter: entry.diameter,
})),
},
programToolCommands,
appliedToolCommands: commands,
defaultActivationUsed: programToolCommands.length === 0,
};
}
function defaultToolActivationCommands(toolTable) {
const preferred = toolTable.entries.find((entry) => entry.offset.z !== 0)
|| toolTable.entries.find((entry) => entry.toolNumber > 0);
if (!preferred) return [];
return [
{ code: "T", toolNumber: preferred.toolNumber, source: "default-tool-table-activation" },
{ code: "M6", source: "default-tool-table-activation" },
{ code: "G43", h: preferred.toolNumber, toolNumber: preferred.toolNumber, source: "default-tool-table-activation" },
];
}
function createPathSampling() {
return {
samplePeriodMs: SAMPLE_PERIOD_MS,
timeBase: "program-relative-ms",
resampling: "linear-position-slerp-or-axis-linear",
coordinateSystem: "machine-xyzbc-and-tcp",
};
}
function emptyPath(source, reason) {
return {
source,
samplePeriodMs: SAMPLE_PERIOD_MS,
status: "blocked",
unavailableReason: reason,
sampleCount: 0,
samples: [],
};
}
function normalizePathSample({
sampleIndex,
timeMs,
line,
motionType,
activeKinematics,
axes,
tool,
feed,
spindle,
}) {
const joint = {
x: numberOrZero(axes.x),
y: numberOrZero(axes.y),
z: numberOrZero(axes.z),
b: numberOrZero(axes.b),
c: numberOrZero(axes.c),
};
return {
sampleIndex,
timeMs,
line: Number(line) || 0,
motionType,
activeKinematics,
tool,
joint,
tcp: {
x: joint.x,
y: joint.y,
z: joint.z,
},
toolAxis: toolAxisFromBc(joint.b, joint.c),
feed: numberOrZero(feed),
spindle: numberOrZero(spindle),
};
}
function firstTool(profile) {
const tool = profile.toolTable?.tools?.[0] || {};
return {
id: Number(tool.tool) || 0,
length: Number(tool.zOffset) || 0,
diameter: Number(tool.diameter) || 0,
};
}
function xyzbcAxesFromTaskHalStatus(status = {}) {
const axis = status.motionStatus?.axis || {};
const pins = status.halSnapshot?.pins || {};
return {
x: firstFiniteNumber(
axis.x,
pins["joint.0.motor-pos-fb"]?.value,
pins["joint.0.pos-fb"]?.value,
pins["axis.0.pos-fb"]?.value,
pins["joint.0.motor-pos-cmd"]?.value,
),
y: firstFiniteNumber(
axis.y,
pins["joint.1.motor-pos-fb"]?.value,
pins["joint.1.pos-fb"]?.value,
pins["axis.1.pos-fb"]?.value,
pins["joint.1.motor-pos-cmd"]?.value,
),
z: firstFiniteNumber(
axis.z,
pins["joint.2.motor-pos-fb"]?.value,
pins["joint.2.pos-fb"]?.value,
pins["axis.2.pos-fb"]?.value,
pins["joint.2.motor-pos-cmd"]?.value,
),
b: firstFiniteNumber(
axis.b,
pins["joint.3.motor-pos-fb"]?.value,
pins["joint.3.pos-fb"]?.value,
pins["axis.3.pos-fb"]?.value,
pins["joint.3.motor-pos-cmd"]?.value,
),
c: firstFiniteNumber(
axis.c,
pins["joint.4.motor-pos-fb"]?.value,
pins["joint.4.pos-fb"]?.value,
pins["axis.4.pos-fb"]?.value,
pins["joint.4.motor-pos-cmd"]?.value,
),
};
}
function spindleFromTaskHalStatus(status = {}) {
const pins = status.halSnapshot?.pins || {};
return firstFiniteNumber(
status.motionStatus?.motion?.spindleSpeed,
pins["spindle.0.speed-out"]?.value,
pins["motion.spindle-speed-out"]?.value,
0,
);
}
function currentMotionTypeForLine(motion = [], line = 0) {
const number = Number(line);
if (!Number.isFinite(number) || number <= 0) return null;
return motion.find((event) => Number(event.line) === number)?.type || null;
}
function activeKinematics(event) {
if (event.kinsType === "identity" || event.switchkinsType === 0) return "identity";
if (event.kinsType === "tcp" || event.switchkinsType === 1) return "xyzbc-tcp";
if (event.kinsType === "userk" || event.switchkinsType === 2) return "userk";
return "unknown";
}
function motionTypeFromCanonical(type) {
if (type === "STRAIGHT_TRAVERSE") return "G0";
if (type === "STRAIGHT_FEED") return "G1";
if (type === "ARC_FEED") return "G2/G3";
return "unknown";
}
function toolAxisFromBc(bDeg, cDeg) {
const b = bDeg * Math.PI / 180;
const c = cDeg * Math.PI / 180;
return {
i: Math.sin(b) * Math.cos(c),
j: Math.sin(b) * Math.sin(c),
k: Math.cos(b),
};
}
function numberOrZero(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function lerpNumber(left, right, ratio) {
const leftNumber = Number(left);
const rightNumber = Number(right);
if (!Number.isFinite(leftNumber)) return Number.isFinite(rightNumber) ? rightNumber : 0;
if (!Number.isFinite(rightNumber)) return leftNumber;
return leftNumber + (rightNumber - leftNumber) * ratio;
}
function firstFiniteNumber(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) return number;
}
return 0;
}