const AXIS_SECTION_RE = /^AXIS_([A-Z])$/; const JOINT_SECTION_RE = /^JOINT_(\d+)$/; export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unknown" } = {}) { const sections = parseIniSections(text); const kinsText = getFirstValue(sections, "KINS", "KINEMATICS") || ""; const coordinates = getFirstValue(sections, "TRAJ", "COORDINATES") || ""; const jointCount = numberOrNull(getFirstValue(sections, "KINS", "JOINTS")); const axisLimits = parseAxisLimits(sections); const jointConfig = parseJointConfig(sections, coordinates); const remaps = parseRemaps(sections); const hal = parseHal(sections); const display = parseDisplay(sections); const halui = { mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"), }; const kinematicsModuleId = inferKinematicsModuleId(kinsText); return { apiName: "web-rtcp-5axis-linuxcnc-ini-config", profileId, path, machineName: getFirstValue(sections, "EMC", "MACHINE") || null, kinematics: parseKinematics(kinsText), kinematicsModuleId, kinematicsParameters: { sparm: parseKinematicsParameter(kinsText, "sparm"), joints: jointCount, switchkinsTypes: inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }), }, traj: { coordinates, linearUnits: getFirstValue(sections, "TRAJ", "LINEAR_UNITS") || null, angularUnits: getFirstValue(sections, "TRAJ", "ANGULAR_UNITS") || null, defaultLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_VELOCITY")), maxLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_VELOCITY")), defaultLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_ACCELERATION")), maxLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_ACCELERATION")), }, display, rs274ngc: { subroutinePath: getFirstValue(sections, "RS274NGC", "SUBROUTINE_PATH") || null, halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")), parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null, remaps, }, hal, halui, axisLimits, jointConfig, emcio: { toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null, }, validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }), semanticBoundary: "linuxcnc_ini_file_browser_parser", }; } export async function loadLinuxCncIniConfig(profile, { baseUrl = import.meta.url, fetchImpl = globalThis.fetch } = {}) { if (!profile?.iniPath) { throw new Error("profile is missing iniPath"); } if (typeof fetchImpl !== "function") { throw new Error("fetch is not available for LinuxCNC INI loading"); } const candidateUrls = [ new URL(`../../${profile.iniPath}`, baseUrl), new URL(`../../../../wasm-port/vendor/linuxcnc/${profile.iniPath}`, baseUrl), ]; const errors = []; let response = null; for (const url of candidateUrls) { try { response = await fetchImpl(url.href); if (response.ok) break; errors.push(`${url.href}: HTTP ${response.status}`); response = null; } catch (error) { errors.push(`${url.href}: ${error.message}`); } } if (!response) { throw new Error(`failed to load LinuxCNC INI ${profile.iniPath}: ${errors.join(" | ")}`); } return parseLinuxCncIni(await response.text(), { path: profile.iniPath, profileId: profile.id, }); } export function applyIniConfigToProfile(profile, iniConfig) { if (!iniConfig) return profile; return { ...profile, machineName: iniConfig.machineName || profile.machineName, kinematics: iniConfig.kinematics.name || profile.kinematics, kinematicsModuleId: iniConfig.kinematicsModuleId || profile.kinematicsModuleId, kinematicsParameters: { ...profile.kinematicsParameters, ...iniConfig.kinematicsParameters, switchkinsTypes: mergeSwitchkinsTypes( profile.kinematicsParameters?.switchkinsTypes || [], iniConfig.kinematicsParameters.switchkinsTypes, ), }, display: { ...profile.display, ...iniConfig.display, }, rs274ngc: { ...profile.rs274ngc, subroutinePath: iniConfig.rs274ngc.subroutinePath || profile.rs274ngc?.subroutinePath, halPinVars: iniConfig.rs274ngc.halPinVars ?? profile.rs274ngc?.halPinVars, parameterFile: iniConfig.rs274ngc.parameterFile || profile.rs274ngc?.parameterFile, }, hal: { ...profile.hal, halui: iniConfig.hal.halui || profile.hal?.halui, halFiles: iniConfig.hal.halFiles.length > 0 ? iniConfig.hal.halFiles : profile.hal?.halFiles, postguiHalFiles: iniConfig.hal.postguiHalFiles.length > 0 ? iniConfig.hal.postguiHalFiles : profile.hal?.postguiHalFiles, halcmd: { ...profile.hal?.halcmd, raw: iniConfig.hal.halcmd, initialSets: iniConfig.hal.initialSets.length > 0 ? iniConfig.hal.initialSets : profile.hal?.halcmd?.initialSets, }, }, halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui, traj: { ...profile.traj, ...dropNullish(iniConfig.traj), }, axisLimits: Object.keys(iniConfig.axisLimits).length > 0 ? iniConfig.axisLimits : profile.axisLimits, jointConfig: iniConfig.jointConfig.length > 0 ? iniConfig.jointConfig : profile.jointConfig, linuxCncIniConfig: iniConfig, }; } function parseIniSections(text) { const sections = new Map(); let current = null; for (const rawLine of String(text).split(/\r?\n/)) { const line = stripIniComment(rawLine).trim(); if (!line) continue; const sectionMatch = line.match(/^\[([^\]]+)]$/); if (sectionMatch) { current = sectionMatch[1].trim().toUpperCase(); if (!sections.has(current)) sections.set(current, new Map()); continue; } if (!current) continue; const equals = line.indexOf("="); if (equals < 0) continue; const key = line.slice(0, equals).trim().toUpperCase(); const value = line.slice(equals + 1).trim(); const section = sections.get(current); if (!section.has(key)) section.set(key, []); section.get(key).push(value); } return sections; } function stripIniComment(line) { let quote = null; for (let index = 0; index < line.length; index += 1) { const char = line[index]; if ((char === "\"" || char === "'") && line[index - 1] !== "\\") { quote = quote === char ? null : quote || char; } if (!quote && (char === "#" || char === ";")) { return line.slice(0, index); } } return line; } function getValues(sections, sectionName, key) { return sections.get(sectionName.toUpperCase())?.get(key.toUpperCase()) || []; } function getFirstValue(sections, sectionName, key) { return getValues(sections, sectionName, key)[0] ?? null; } function parseAxisLimits(sections) { const result = {}; for (const [sectionName, values] of sections) { const match = sectionName.match(AXIS_SECTION_RE); if (!match) continue; result[match[1]] = { min: numberOrNull(first(values, "MIN_LIMIT")), max: numberOrNull(first(values, "MAX_LIMIT")), maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")), maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")), }; } return result; } function parseJointConfig(sections, coordinates) { const axisOrder = String(coordinates || "").split(""); return [...sections.entries()] .map(([sectionName, values]) => { const match = sectionName.match(JOINT_SECTION_RE); if (!match) return null; const id = Number(match[1]); return { id, axis: axisOrder[id] || null, type: first(values, "TYPE") || null, home: numberOrNull(first(values, "HOME")), min: numberOrNull(first(values, "MIN_LIMIT")), max: numberOrNull(first(values, "MAX_LIMIT")), maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")), maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")), homeSearchVelocity: numberOrNull(first(values, "HOME_SEARCH_VEL")), homeSequence: numberOrNull(first(values, "HOME_SEQUENCE")), }; }) .filter(Boolean) .sort((left, right) => left.id - right.id); } function parseRemaps(sections) { return getValues(sections, "RS274NGC", "REMAP").map((value) => { const code = value.match(/\bM\d+\b/i)?.[0]?.toUpperCase() || null; const ngc = value.match(/\bngc=([^\s]+)/i)?.[1] || null; const modalGroup = numberOrNull(value.match(/\bmodalgroup=(\d+)/i)?.[1]); return { raw: value, code, modalGroup, ngc }; }); } function parseHal(sections) { const halcmd = getValues(sections, "HAL", "HALCMD"); return { halui: getFirstValue(sections, "HAL", "HALUI") || null, halFiles: getValues(sections, "HAL", "HALFILE"), postguiHalFiles: getValues(sections, "HAL", "POSTGUI_HALFILE"), halcmd, initialSets: halcmd .map((line) => line.match(/^\s*(sets|setp)\s+:?([^\s]+)\s+([-+0-9.eE]+)/i)) .filter(Boolean) .map((match) => ({ op: match[1], pin: match[2], value: Number(match[3]) })), }; } function parseDisplay(sections) { const jogAxes = getFirstValue(sections, "DISPLAY", "JOG_AXES"); return dropNullish({ geometry: getFirstValue(sections, "DISPLAY", "GEOMETRY"), display: getFirstValue(sections, "DISPLAY", "DISPLAY"), jogAxes: jogAxes ? jogAxes.split("") : null, pyvcp: getFirstValue(sections, "DISPLAY", "PYVCP"), openFile: getFirstValue(sections, "DISPLAY", "OPEN_FILE"), programPrefix: getFirstValue(sections, "DISPLAY", "PROGRAM_PREFIX"), positionOffset: getFirstValue(sections, "DISPLAY", "POSITION_OFFSET"), positionFeedback: getFirstValue(sections, "DISPLAY", "POSITION_FEEDBACK"), maxFeedOverride: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_FEED_OVERRIDE")), maxLinearVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_LINEAR_VELOCITY")), maxAngularVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_ANGULAR_VELOCITY")), }); } function parseKinematics(text) { const [name, ...parameters] = String(text || "").trim().split(/\s+/).filter(Boolean); return { name: name || null, raw: text, parameters, }; } function parseKinematicsParameter(text, key) { return String(text || "").match(new RegExp(`\\b${key}=([^\\s]+)`, "i"))?.[1] || null; } function inferKinematicsModuleId(kinsText) { const name = parseKinematics(kinsText).name || ""; if (name.includes("xyzbc")) return "xyzbc-trt"; if (name.includes("xyzac")) return "xyzac-trt"; return name.replace(/-kins$/, "") || null; } function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) { const mdiCommands = halui.mdiCommands.length > 0 ? halui.mdiCommands : ["M429", "M428", "M430"]; const tcpType = coordinates === "XYZBC" ? "tcp-xyzbc" : "tcp-xyzac"; const tcpLabel = `${coordinates || kinematicsModuleId || "TCP"} TCP`; return mdiCommands.map((command, index) => ({ value: index === 0 ? 0 : index, label: index === 0 ? "identity" : index === 1 ? tcpLabel : "USERK", mdiCommand: command, webKinsType: index === 0 ? "identity" : index === 1 ? tcpType : "userk", })); } function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) { const missing = []; if (!coordinates) missing.push("TRAJ.COORDINATES"); if (!jointCount) missing.push("KINS.JOINTS"); if (!kinsText) missing.push("KINS.KINEMATICS"); for (const axis of String(coordinates || "").split("")) { if (!axisLimits[axis]) missing.push(`AXIS_${axis}`); } if (jointCount && jointConfig.length !== jointCount) { missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`); } return { ready: missing.length === 0, missing, axisCount: Object.keys(axisLimits).length, jointCount: jointConfig.length, }; } function first(values, key) { return values.get(key)?.[0] ?? null; } function numberOrNull(value) { if (value === null || value === undefined || value === "") return null; const number = Number(value); return Number.isFinite(number) ? number : null; } function boolFromIni(value) { if (value === null || value === undefined) return null; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function dropNullish(object) { return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined)); } function mergeSwitchkinsTypes(profileTypes, iniTypes) { if (!iniTypes?.length) return profileTypes; return iniTypes.map((iniType) => ({ ...iniType, ...(profileTypes.find((entry) => entry.mdiCommand === iniType.mdiCommand || entry.value === iniType.value) || {}), ...iniType, })); }