const TOOL_WORD_PATTERN = /([A-Z])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))/gi; const TOOL_DB_STORAGE_ROOT = "web-rtcp-5axis-xyzbc-trt-sim-plan/tool-db"; const TOOL_OFFSET_AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const TOOL_PARAMETER_KEYS = { toolNumber: 5400, x: 5401, y: 5402, z: 5403, a: 5404, b: 5405, c: 5406, u: 5407, v: 5408, w: 5409, diameter: 5410, frontAngle: 5411, backAngle: 5412, orientation: 5413, }; let browserMemoryToolDbStorage = null; export function parseLinuxCncToolTable(text = "", options = {}) { const randomToolChanger = Boolean(options.randomToolChanger); const pockets = [createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 })]; const diagnostics = []; const lines = String(text).split(/\r?\n/); let nonRandomIdx = 1; lines.forEach((rawLine, index) => { const lineNumber = index + 1; const [bodyPart, ...commentParts] = rawLine.split(";"); const body = bodyPart.trim(); const comment = commentParts.join(";").trim(); if (!body) return; const words = {}; for (const match of body.matchAll(TOOL_WORD_PATTERN)) { words[match[1].toUpperCase()] = Number(match[2]); } if (!Number.isFinite(words.T)) { diagnostics.push({ line: lineNumber, severity: "warning", message: "tool table row missing T word", rawLine, }); return; } if (!Number.isFinite(words.P)) { diagnostics.push({ line: lineNumber, severity: "warning", message: "tool table row missing P word", rawLine, }); } const realPocket = Number.isFinite(words.P) ? words.P : words.T; const idx = randomToolChanger ? realPocket : nonRandomIdx; if (!randomToolChanger) nonRandomIdx += 1; const entry = normalizeToolEntry({ idx, line: lineNumber, rawLine, comment, toolNumber: words.T, pocket: realPocket, diameter: Number.isFinite(words.D) ? words.D : 0, orientation: Number.isFinite(words.Q) ? words.Q : 0, frontAngle: Number.isFinite(words.I) ? words.I : 0, backAngle: Number.isFinite(words.J) ? words.J : 0, offset: offsetFromWords(words), }); if (pockets[idx]?.toolNumber > 0) { diagnostics.push({ line: lineNumber, severity: "warning", message: `multiple tools assigned to internal pocket/index ${idx}`, rawLine, }); } pockets[idx] = entry; }); const entries = compactToolEntries(pockets); return { apiName: "web-rtcp-5axis-tool-table", sourceRel: options.sourceRel || null, path: options.path || null, randomToolChanger, entries, pockets: normalizePockets(pockets), toolCount: entries.length, diagnostics, rawText: String(text), semanticBoundary: "linuxcnc_tool_table_text_parsed_for_web_simulation", linuxCncReferences: [ "linuxcnc/src/emc/tooldata/tooldata_common.cc:70", "linuxcnc/src/emc/tooldata/tooldata_common.cc:365", "linuxcnc/src/emc/nml_intf/emctool.h:29", ], }; } export function serializeLinuxCncToolTable(toolTable) { const table = normalizeToolTable(toolTable); const startIdx = table.randomToolChanger ? 0 : 1; const lines = []; for (let idx = startIdx; idx < table.pockets.length; idx += 1) { const entry = table.pockets[idx]; if (!entry || entry.toolNumber < 0) continue; lines.push(formatLinuxCncToolLine(entry, table.randomToolChanger)); } return `${lines.join("\n")}${lines.length ? "\n" : ""}`; } export function createToolDbSimulation({ toolTable = null, profile = null, storageMode = null } = {}) { const normalizedTable = normalizeToolTable(toolTable?.apiName === "web-rtcp-5axis-tool-table" ? toolTable : parseLinuxCncToolTable("", { sourceRel: profile?.toolTablePath || null })); const spindleEntry = normalizedTable.pockets[0] || createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 }); return { apiName: "web-rtcp-5axis-tool-db-simulation", ready: true, processReady: true, processScope: "web_simulation_only", hostProcessReady: false, hostProcessExecution: false, dbProgramReady: false, storageMode: storageMode || null, storageCapability: null, profileId: profile?.id || null, toolTablePath: normalizedTable.sourceRel || profile?.toolTablePath || null, randomToolChanger: normalizedTable.randomToolChanger, toolTable: normalizedTable, toolInSpindle: spindleEntry.toolNumber > 0 ? spindleEntry.toolNumber : 0, toolFromPocket: spindleEntry.toolNumber > 0 ? spindleEntry.pocket : 0, pocketPrepped: -1, preparedTool: null, preparedPocket: null, preparedIndex: null, currentPocket: spindleEntry.toolNumber > 0 ? spindleEntry.idx : 0, activeToolOffset: null, toolTableCurrent: cloneToolEntry(spindleEntry), iocontrol: createInitialIoControlPins(spindleEntry), emcioStatus: createInitialEmcioStatus(spindleEntry), interpreterParameters: createToolParameters(null), events: [], lastSavedText: null, lastSavedPath: null, semanticBoundary: "linuxcnc_tool_db_web_simulation_not_native_db_program", linuxCncReferences: [ "linuxcnc/src/emc/task/taskclass.cc:281", "linuxcnc/src/emc/task/taskclass.cc:425", "linuxcnc/src/emc/task/taskclass.cc:482", "linuxcnc/src/emc/task/taskclass.cc:614", "linuxcnc/src/emc/rs274ngc/interp_convert.cc:6236", "linuxcnc/src/emc/task/emccanon.cc:3075", ], }; } export function listToolEntries(toolDb) { return compactToolEntries(normalizeToolTable(toolDb?.toolTable).pockets).map(cloneToolEntry); } export function queryToolEntry(toolDb, selector = {}) { const table = normalizeToolTable(toolDb?.toolTable); const entry = resolveToolEntry(table, selector); return entry ? cloneToolEntry(entry) : null; } export function prepareTool(toolDb, toolNumber, options = {}) { let state = cloneToolDb(toolDb); const idx = findToolIndexForTool(state.toolTable, toolNumber, state.randomToolChanger); const entry = idx >= 0 ? state.toolTable.pockets[idx] : null; state.preparedTool = Number(toolNumber); state.preparedIndex = idx >= 0 ? idx : null; state.preparedPocket = entry?.pocket ?? null; if (idx === -1) { state.preparedTool = null; state.pocketPrepped = -1; state.iocontrol.toolPrepIndex = -1; state.iocontrol.toolPrepNumber = 0; state.iocontrol.toolPrepPocket = 0; state.iocontrol.toolPrepare = false; state.iocontrol.toolPrepared = false; state.emcioStatus.status = "ERROR"; state.events.push(createToolDbEvent("T", { toolNumber: Number(toolNumber), idx, pocket: null, found: false, error: "tool_number_not_found", linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:425", })); return state; } else { state.iocontrol.toolPrepIndex = idx; if (state.randomToolChanger || idx !== 0) { state.iocontrol.toolPrepNumber = entry?.toolNumber ?? 0; state.iocontrol.toolPrepPocket = entry?.pocket ?? 0; } else { state.iocontrol.toolPrepNumber = 0; state.iocontrol.toolPrepPocket = 0; } } state.iocontrol.toolPrepare = true; state.iocontrol.toolPrepared = options.toolPrepared ?? true; state.emcioStatus.status = "EXEC"; state.events.push(createToolDbEvent("T", { toolNumber: Number(toolNumber), idx, pocket: entry?.pocket ?? null, found: Boolean(entry), linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:425", })); if (options.autoReadToolInputs !== false) { state = readToolInputs(state); } return state; } export function loadPreparedTool(toolDb, options = {}) { let state = cloneToolDb(toolDb); if (state.pocketPrepped === -1 && state.iocontrol.toolPrepare && state.iocontrol.toolPrepared) { state = readToolInputs(state); } const idx = state.pocketPrepped; const entry = idx >= 0 ? state.toolTable.pockets[idx] : null; if (state.randomToolChanger && idx === 0) { state.events.push(createToolDbEvent("M6", { idx, noOp: true, reason: "random_toolchanger_spindle_pocket", })); return state; } if (!state.randomToolChanger && idx > 0 && state.toolInSpindle === entry?.toolNumber) { state.events.push(createToolDbEvent("M6", { idx, toolNumber: state.toolInSpindle, noOp: true, reason: "tool_already_in_spindle", })); return state; } if (idx !== -1) { state.iocontrol.toolChange = true; state.iocontrol.toolChanged = options.toolChanged ?? true; state.emcioStatus.status = "EXEC"; } state.events.push(createToolDbEvent("M6", { idx, toolNumber: entry?.toolNumber ?? 0, pocket: entry?.pocket ?? 0, found: Boolean(entry), linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:482", })); if (options.autoReadToolInputs !== false) { state = readToolInputs(state); } return state; } export function readToolInputs(toolDb) { let state = cloneToolDb(toolDb); if (state.iocontrol.toolPrepare && state.iocontrol.toolPrepared) { state.pocketPrepped = state.iocontrol.toolPrepIndex; state.emcioStatus.tool.pocketPrepped = state.pocketPrepped; state.iocontrol.toolPrepare = false; state.emcioStatus.status = "DONE"; state.events.push(createToolDbEvent("READ_TOOL_INPUTS_PREPARED", { pocketPrepped: state.pocketPrepped, linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:616", })); } if (state.iocontrol.toolChange && state.iocontrol.toolChanged) { const idx = state.pocketPrepped; let loaded = createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 }); if (!state.randomToolChanger && idx === 0) { state.toolTable.pockets[0] = loaded; } else { const entry = state.toolTable.pockets[idx] || createEmptyToolEntry({ idx, pocket: 0, toolNumber: 0 }); loaded = cloneToolEntry({ ...entry, idx: 0, }); if (state.randomToolChanger) { loaded.pocket = 0; const previousSpindle = state.toolTable.pockets[0] || createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 }); state.toolTable.pockets[idx] = normalizeToolEntry({ ...previousSpindle, idx, pocket: entry.pocket, }); } state.toolTable.pockets[0] = loaded; } state.toolTable = refreshToolTable(state.toolTable); state.toolInSpindle = loaded.toolNumber > 0 ? loaded.toolNumber : 0; state.toolFromPocket = state.toolInSpindle > 0 ? loaded.pocket : 0; state.currentPocket = state.toolInSpindle > 0 ? idx : 0; state.toolTableCurrent = cloneToolEntry(loaded); state.iocontrol.toolNumber = state.toolInSpindle; state.iocontrol.toolFromPocket = state.toolFromPocket; state.emcioStatus.tool.toolInSpindle = state.toolInSpindle; state.emcioStatus.tool.toolFromPocket = state.toolFromPocket; state.emcioStatus.tool.pocketPrepped = -1; state.pocketPrepped = -1; state.preparedTool = null; state.preparedPocket = null; state.preparedIndex = null; state.iocontrol.toolPrepNumber = 0; state.iocontrol.toolPrepPocket = 0; state.iocontrol.toolPrepIndex = 0; state.iocontrol.toolChange = false; state.emcioStatus.status = "DONE"; state.events.push(createToolDbEvent("READ_TOOL_INPUTS_CHANGED", { toolNumber: state.toolInSpindle, toolFromPocket: state.toolFromPocket, currentPocket: state.currentPocket, linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:623", })); } return state; } export function applyToolLengthOffset(toolDb, toolNumber = null) { const state = cloneToolDb(toolDb); const table = normalizeToolTable(state.toolTable); const idx = Number.isFinite(Number(toolNumber)) ? findToolIndexForTool(table, Number(toolNumber), state.randomToolChanger) : 0; const entry = idx >= 0 ? table.pockets[idx] : null; state.activeToolOffset = entry ? createActiveToolOffset(entry, idx) : null; state.interpreterParameters = createToolParameters(state.activeToolOffset); state.events.push(createToolDbEvent("G43", { idx, toolNumber: toolNumber ?? state.toolInSpindle, activeToolOffset: state.activeToolOffset, found: Boolean(entry), linuxCncReference: "linuxcnc/src/emc/rs274ngc/interp_convert.cc:6236", })); return state; } export function setToolNumber(toolDb, toolNumber) { let state = cloneToolDb(toolDb); if (!state.randomToolChanger && Number(toolNumber) === 0) { const unloaded = createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 }); state.toolTable.pockets[0] = unloaded; state.toolTable = refreshToolTable(state.toolTable); state.toolInSpindle = 0; state.toolFromPocket = 0; state.currentPocket = 0; state.toolTableCurrent = cloneToolEntry(unloaded); state.iocontrol.toolNumber = 0; state.iocontrol.toolFromPocket = 0; state.emcioStatus.tool.toolInSpindle = 0; state.emcioStatus.tool.toolFromPocket = 0; state.events.push(createToolDbEvent("M61", { toolNumber: 0, idx: 0, pocket: 0, found: true, linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:572", })); return state; } const idx = findToolIndexForTool(state.toolTable, toolNumber, state.randomToolChanger); const entry = idx >= 0 ? state.toolTable.pockets[idx] : null; if (!entry) { state.events.push(createToolDbEvent("M61", { toolNumber: Number(toolNumber), idx, found: false, })); return state; } state.toolTable.pockets[0] = cloneToolEntry({ ...entry, idx: 0 }); state.toolTable = refreshToolTable(state.toolTable); state.toolInSpindle = entry.toolNumber; state.toolFromPocket = entry.toolNumber > 0 ? entry.pocket : 0; state.currentPocket = idx; state.toolTableCurrent = cloneToolEntry(state.toolTable.pockets[0]); state.iocontrol.toolNumber = state.toolInSpindle; state.iocontrol.toolFromPocket = state.toolFromPocket; state.emcioStatus.tool.toolInSpindle = state.toolInSpindle; state.emcioStatus.tool.toolFromPocket = state.toolFromPocket; state.events.push(createToolDbEvent("M61", { toolNumber: state.toolInSpindle, idx, pocket: state.toolFromPocket, found: true, linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:572", })); return state; } export function editToolEntry(toolDb, patch = {}) { const state = cloneToolDb(toolDb); const selector = { idx: patch.idx, toolNumber: patch.toolNumber ?? patch.toolno, pocket: patch.pocket ?? patch.pocketno, }; const current = resolveToolEntry(state.toolTable, selector); const idx = Number.isFinite(Number(patch.idx)) ? Number(patch.idx) : current?.idx ?? nextAvailableToolIndex(state.toolTable); const nextEntry = normalizeToolEntry({ ...(current || createEmptyToolEntry({ idx, pocket: patch.pocket ?? idx, toolNumber: patch.toolNumber ?? idx })), ...patch, idx, toolNumber: patch.toolNumber ?? patch.toolno ?? current?.toolNumber ?? idx, pocket: patch.pocket ?? patch.pocketno ?? current?.pocket ?? idx, diameter: patch.diameter ?? current?.diameter ?? 0, frontAngle: patch.frontAngle ?? patch.frontangle ?? current?.frontAngle ?? 0, backAngle: patch.backAngle ?? patch.backangle ?? current?.backAngle ?? 0, orientation: patch.orientation ?? current?.orientation ?? 0, offset: { ...(current?.offset || createZeroOffset()), ...(patch.offset || {}), ...(Number.isFinite(Number(patch.length)) ? { z: Number(patch.length) } : {}), ...(Number.isFinite(Number(patch.toolLength)) ? { z: Number(patch.toolLength) } : {}), ...(Number.isFinite(Number(patch.zOffset)) ? { z: Number(patch.zOffset) } : {}), }, }); state.toolTable.pockets[idx] = nextEntry; if (state.currentPocket === idx || state.toolInSpindle === nextEntry.toolNumber) { state.toolTable.pockets[0] = cloneToolEntry({ ...nextEntry, idx: 0 }); state.toolTableCurrent = cloneToolEntry(state.toolTable.pockets[0]); state.toolInSpindle = nextEntry.toolNumber; state.toolFromPocket = nextEntry.pocket; state.iocontrol.toolNumber = state.toolInSpindle; state.iocontrol.toolFromPocket = state.toolFromPocket; state.emcioStatus.tool.toolInSpindle = state.toolInSpindle; state.emcioStatus.tool.toolFromPocket = state.toolFromPocket; } if (state.activeToolOffset?.idx === idx || state.activeToolOffset?.toolNumber === nextEntry.toolNumber) { state.activeToolOffset = createActiveToolOffset(nextEntry, idx); state.interpreterParameters = createToolParameters(state.activeToolOffset); } state.toolTable = refreshToolTable(state.toolTable); state.events.push(createToolDbEvent("EDIT_TOOL", { idx, toolNumber: nextEntry.toolNumber, pocket: nextEntry.pocket, linuxCncReference: "linuxcnc/src/emc/task/taskclass.cc:527", })); return state; } export async function saveToolDbSimulation(toolDb, options = {}) { const state = cloneToolDb(toolDb); const text = serializeLinuxCncToolTable(state.toolTable); const path = options.path || toolDbStoragePath(state); const storageInfo = resolveToolDbStorage(options); await saveTextFile(path, text, storageInfo.storage); state.lastSavedText = text; state.lastSavedPath = path; state.storageMode = options.storageMode || storageInfo.mode; state.storageCapability = storageInfo.capability; state.events.push(createToolDbEvent("SAVE_TOOL_TABLE", { path, bytes: text.length, storageMode: state.storageMode, linuxCncReference: "linuxcnc/src/emc/tooldata/tooldata_common.cc:383", })); return { toolDb: state, path, text, storageMode: state.storageMode, storageCapability: storageInfo.capability, semanticBoundary: "tool_table_saved_in_web_storage_boundary", }; } export function applyToolCommandSequence(toolDb, commands = []) { return commands.reduce((state, command) => { const code = String(command.code || command).toUpperCase(); if (code === "T") return prepareTool(state, command.toolNumber); if (code === "M6") return loadPreparedTool(state); if (code === "G43") return applyToolLengthOffset(state, command.toolNumber ?? command.h ?? null); if (code === "M61") return setToolNumber(state, command.toolNumber ?? command.q); throw new Error(`unsupported tool DB simulation command: ${code}`); }, toolDb); } export function extractToolCommandSequenceFromProgram(programText = "") { const commands = []; const lines = String(programText).split(/\r?\n/); lines.forEach((rawLine, index) => { const line = rawLine .replace(/\([^)]*\)/g, " ") .split(";")[0]; const words = [...line.matchAll(/([GMTQH])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))/gi)] .map((match) => ({ letter: match[1].toUpperCase(), value: Number(match[2]), index: match.index ?? 0, })) .filter((word) => Number.isFinite(word.value)); if (!words.length) return; const hWord = words.find((word) => word.letter === "H"); const qWord = words.find((word) => word.letter === "Q"); for (const word of words) { if (word.letter === "T") { commands.push(createProgramToolCommand("T", index + 1, rawLine, { toolNumber: word.value, })); } else if (word.letter === "M" && word.value === 6) { commands.push(createProgramToolCommand("M6", index + 1, rawLine)); } else if (word.letter === "M" && word.value === 61 && Number.isFinite(qWord?.value)) { commands.push(createProgramToolCommand("M61", index + 1, rawLine, { q: qWord?.value, toolNumber: qWord?.value, })); } else if (word.letter === "G" && word.value === 43) { commands.push(createProgramToolCommand("G43", index + 1, rawLine, { h: hWord?.value, toolNumber: hWord?.value, })); } } }); return commands; } export function createToolDbReadiness(toolDb) { const ready = toolDb?.processReady === true && toolDb?.processScope === "web_simulation_only"; return { apiName: "web-rtcp-5axis-tool-db-readiness", ready, toolDbProcessReady: ready, toolDbProcessScope: ready ? "web_simulation_only" : "not_ready", hostToolDbProcessReady: false, hostProcessExecution: false, dbProgramReady: false, toolCount: toolDb?.toolTable?.toolCount || 0, toolTablePath: toolDb?.toolTablePath || null, storageMode: toolDb?.storageMode || null, semanticBoundary: "tool_db_process_ready_for_web_simulation_only", }; } export function createToolRuntimeState(toolDb, options = {}) { if (!toolDb) { return { apiName: "web-rtcp-5axis-tool-runtime-state", ready: false, activeOffsetApplied: false, toolInSpindle: 0, toolFromPocket: 0, currentPocket: 0, activeToolNumber: 0, activePocket: 0, currentTool: null, activeToolOffset: null, kinematics: { toolOffsetZ: 0, source: "tool_db_not_ready", }, pathTool: createPathTool(null, 0), vismach: { toolOffset: finiteNumber(options.fallbackToolLength, 0), source: "fallback_tool_preview_length", }, interpreterParameters: createToolParameters(null), semanticBoundary: "linuxcnc_current_tool_offset_runtime_state_for_web_simulation", }; } const state = cloneToolDb(toolDb); const activeOffset = state.activeToolOffset ? { ...state.activeToolOffset, offset: { ...state.activeToolOffset.offset }, } : null; const currentTool = state.toolTableCurrent ? cloneToolEntry(state.toolTableCurrent) : cloneToolEntry(state.toolTable.pockets[0] || createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 })); const activeToolNumber = activeOffset?.toolNumber ?? (currentTool.toolNumber > 0 ? currentTool.toolNumber : 0); const activePocket = activeOffset?.pocket ?? (currentTool.toolNumber > 0 ? currentTool.pocket : 0); const activeOffsetZ = activeOffset ? finiteNumber(activeOffset.offset?.z, 0) : 0; return { apiName: "web-rtcp-5axis-tool-runtime-state", ready: state.processReady === true, activeOffsetApplied: Boolean(activeOffset), toolInSpindle: state.toolInSpindle, toolFromPocket: state.toolFromPocket, currentPocket: state.currentPocket, activeToolNumber, activePocket, currentTool, activeToolOffset: activeOffset, kinematics: { toolOffsetZ: activeOffsetZ, source: activeOffset ? "activeToolOffset.offset.z" : "no_active_g43_tool_length_offset", halPin: "motion.tooloffset.z", downstreamPins: ["xyzbc-trt-kins.tool-offset", "xyzbc-trt-gui.tool-offset"], }, pathTool: createPathTool(activeOffset || currentTool, activeOffsetZ), vismach: { toolOffset: activeOffset ? activeOffsetZ : finiteNumber(options.fallbackToolLength, 0), source: activeOffset ? "activeToolOffset.offset.z" : "fallback_tool_preview_length", }, interpreterParameters: { ...state.interpreterParameters }, semanticBoundary: "linuxcnc_current_tool_offset_runtime_state_for_web_simulation", }; } export function detectToolDbStorageCapability(globalScope = globalThis) { const forcedUnavailable = Boolean(globalScope.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__); const isBrowser = typeof globalScope.window === "object" || typeof globalScope.document === "object"; const secureContext = !isBrowser || globalScope.isSecureContext !== false; const hasOpfs = typeof globalScope.navigator?.storage?.getDirectory === "function"; const opfsAvailable = hasOpfs && secureContext && !forcedUnavailable; return { apiName: "web-rtcp-5axis-tool-db-storage-capability", opfsAvailable, opfsUnavailable: !opfsAvailable, secureContext, forcedUnavailable, hasNavigatorStorage: Boolean(globalScope.navigator?.storage), hasGetDirectory: hasOpfs, fallbackMode: opfsAvailable ? null : "memory-fallback", reason: opfsAvailable ? "opfs_available" : forcedUnavailable ? "forced_unavailable" : !secureContext ? "non_secure_context" : !hasOpfs ? "missing_opfs_get_directory" : "opfs_unavailable", }; } export function createMemoryToolDbStorage(seed = {}) { const files = new Map(Object.entries(seed)); return { apiName: "web-rtcp-5axis-memory-tool-db-storage", files, async getDirectory() { return createDirectoryHandle(files, []); }, }; } function createProgramToolCommand(code, line, rawLine, fields = {}) { return { code, line, rawLine, ...fields, semanticBoundary: "linuxcnc_tool_command_scanned_for_web_simulation", }; } function normalizeToolTable(toolTable = {}) { const randomToolChanger = Boolean(toolTable.randomToolChanger); const rawPockets = Array.isArray(toolTable.pockets) ? toolTable.pockets : pocketsFromEntries(toolTable.entries || [], randomToolChanger); const pockets = normalizePockets(rawPockets); const entries = compactToolEntries(pockets); return { apiName: "web-rtcp-5axis-tool-table", sourceRel: toolTable.sourceRel || null, path: toolTable.path || null, randomToolChanger, entries, pockets, toolCount: entries.length, diagnostics: [...(toolTable.diagnostics || [])], rawText: typeof toolTable.rawText === "string" ? toolTable.rawText : "", semanticBoundary: toolTable.semanticBoundary || "linuxcnc_tool_table_text_parsed_for_web_simulation", linuxCncReferences: toolTable.linuxCncReferences || [], }; } function refreshToolTable(toolTable) { const table = normalizeToolTable(toolTable); return { ...table, rawText: serializeLinuxCncToolTable(table), }; } function pocketsFromEntries(entries = [], randomToolChanger = false) { const pockets = [createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 })]; let nextIdx = 1; for (const entry of entries) { const idx = Number.isFinite(Number(entry.idx)) ? Number(entry.idx) : randomToolChanger ? Number(entry.pocket ?? entry.pocketno ?? entry.P ?? nextIdx) : nextIdx; pockets[idx] = normalizeToolEntry({ ...entry, idx }); if (!randomToolChanger && idx >= nextIdx) nextIdx = idx + 1; } return pockets; } function normalizePockets(rawPockets = []) { const pockets = [...rawPockets]; pockets[0] = normalizeToolEntry(pockets[0] || createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 })); for (let idx = 1; idx < pockets.length; idx += 1) { if (pockets[idx]) pockets[idx] = normalizeToolEntry({ ...pockets[idx], idx }); } return pockets; } function compactToolEntries(pockets = []) { return pockets .slice(1) .filter((entry) => entry && entry.toolNumber >= 0) .map(cloneToolEntry) .sort((left, right) => left.idx - right.idx); } function normalizeToolEntry(entry = {}) { const toolNumber = Number(entry.toolNumber ?? entry.toolno ?? entry.T ?? 0); const idx = Number(entry.idx ?? entry.index ?? entry.P ?? entry.pocket ?? 0); return { idx, line: entry.line || null, rawLine: entry.rawLine || null, comment: entry.comment || "", toolNumber, toolno: toolNumber, pocket: Number(entry.pocket ?? entry.pocketno ?? entry.P ?? idx), pocketno: Number(entry.pocket ?? entry.pocketno ?? entry.P ?? idx), diameter: Number(entry.diameter ?? entry.D ?? 0), orientation: Number(entry.orientation ?? entry.Q ?? 0), frontAngle: Number(entry.frontAngle ?? entry.frontangle ?? entry.I ?? 0), backAngle: Number(entry.backAngle ?? entry.backangle ?? entry.J ?? 0), offset: { x: Number(entry.offset?.x ?? entry.x ?? entry.X ?? 0), y: Number(entry.offset?.y ?? entry.y ?? entry.Y ?? 0), z: Number(entry.offset?.z ?? entry.z ?? entry.Z ?? entry.length ?? entry.toolLength ?? 0), a: Number(entry.offset?.a ?? entry.a ?? entry.A ?? 0), b: Number(entry.offset?.b ?? entry.b ?? entry.B ?? 0), c: Number(entry.offset?.c ?? entry.c ?? entry.C ?? 0), u: Number(entry.offset?.u ?? entry.u ?? entry.U ?? 0), v: Number(entry.offset?.v ?? entry.v ?? entry.V ?? 0), w: Number(entry.offset?.w ?? entry.w ?? entry.W ?? 0), }, }; } function createEmptyToolEntry({ idx = 0, pocket = -1, toolNumber = -1 } = {}) { return normalizeToolEntry({ idx, pocket, toolNumber, diameter: 0, frontAngle: 0, backAngle: 0, orientation: 0, offset: createZeroOffset(), }); } function cloneToolEntry(entry) { const normalized = normalizeToolEntry(entry); return { ...normalized, offset: { ...normalized.offset }, }; } function createZeroOffset() { return { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0, }; } function offsetFromWords(words) { return Object.fromEntries(TOOL_OFFSET_AXES.map((axis) => { const letter = axis.toUpperCase(); return [axis, Number.isFinite(words[letter]) ? words[letter] : 0]; })); } function resolveToolEntry(toolTable, selector = {}) { const table = normalizeToolTable(toolTable); if (Number.isFinite(Number(selector.idx))) { return table.pockets[Number(selector.idx)] || null; } if (Number.isFinite(Number(selector.toolNumber ?? selector.toolno))) { const idx = findToolIndexForTool(table, Number(selector.toolNumber ?? selector.toolno), table.randomToolChanger); return idx >= 0 ? table.pockets[idx] : null; } if (Number.isFinite(Number(selector.pocket ?? selector.pocketno))) { const pocket = Number(selector.pocket ?? selector.pocketno); return table.pockets.find((entry, idx) => idx > 0 && entry?.pocket === pocket) || table.pockets.find((entry) => entry?.pocket === pocket) || null; } return null; } function findToolIndexForTool(toolTable, toolNumber, randomToolChanger = false) { const number = Number(toolNumber); if (!randomToolChanger && number === 0) return 0; const table = normalizeToolTable(toolTable); const nonSpindleIndex = table.pockets.findIndex((entry, idx) => idx > 0 && entry?.toolNumber === number); if (nonSpindleIndex >= 0) return nonSpindleIndex; const index = table.pockets.findIndex((entry) => entry?.toolNumber === number); return index >= 0 ? index : -1; } function nextAvailableToolIndex(toolTable) { const table = normalizeToolTable(toolTable); for (let idx = 1; idx < table.pockets.length; idx += 1) { if (!table.pockets[idx] || table.pockets[idx].toolNumber < 0) return idx; } return Math.max(table.pockets.length, 1); } function createInitialIoControlPins(spindleEntry = null) { const loaded = spindleEntry?.toolNumber > 0 ? spindleEntry : null; return { toolPrepare: false, toolPrepared: false, toolPrepNumber: 0, toolPrepPocket: 0, toolPrepIndex: 0, toolChange: false, toolChanged: false, toolNumber: loaded?.toolNumber || 0, toolFromPocket: loaded?.pocket || 0, }; } function createInitialEmcioStatus(spindleEntry = null) { const loaded = spindleEntry?.toolNumber > 0 ? spindleEntry : null; return { status: "DONE", tool: { toolInSpindle: loaded?.toolNumber || 0, toolFromPocket: loaded?.pocket || 0, pocketPrepped: -1, }, }; } function cloneToolDb(toolDb) { const toolTable = normalizeToolTable(toolDb?.toolTable); const spindleEntry = toolTable.pockets[0] || createEmptyToolEntry({ idx: 0, pocket: 0, toolNumber: 0 }); return { ...toolDb, randomToolChanger: Boolean(toolDb?.randomToolChanger ?? toolTable.randomToolChanger), toolTable, toolTableCurrent: toolDb?.toolTableCurrent ? cloneToolEntry(toolDb.toolTableCurrent) : cloneToolEntry(spindleEntry), iocontrol: { ...createInitialIoControlPins(spindleEntry), ...(toolDb?.iocontrol || {}) }, emcioStatus: { ...createInitialEmcioStatus(spindleEntry), ...(toolDb?.emcioStatus || {}), tool: { ...createInitialEmcioStatus(spindleEntry).tool, ...(toolDb?.emcioStatus?.tool || {}), }, }, activeToolOffset: toolDb?.activeToolOffset ? { ...toolDb.activeToolOffset, offset: { ...toolDb.activeToolOffset.offset }, } : null, interpreterParameters: { ...(toolDb?.interpreterParameters || createToolParameters(null)) }, events: [...(toolDb?.events || [])], }; } function createActiveToolOffset(entry, idx) { return { idx, toolNumber: entry.toolNumber, pocket: entry.pocket, offset: { ...entry.offset }, diameter: entry.diameter, frontAngle: entry.frontAngle, backAngle: entry.backAngle, orientation: entry.orientation, }; } function createToolParameters(activeOffset) { const parameters = { [TOOL_PARAMETER_KEYS.toolNumber]: activeOffset?.toolNumber || 0, }; for (const axis of TOOL_OFFSET_AXES) { parameters[TOOL_PARAMETER_KEYS[axis]] = activeOffset?.offset?.[axis] || 0; } parameters[TOOL_PARAMETER_KEYS.diameter] = activeOffset?.diameter || 0; parameters[TOOL_PARAMETER_KEYS.frontAngle] = activeOffset?.frontAngle || 0; parameters[TOOL_PARAMETER_KEYS.backAngle] = activeOffset?.backAngle || 0; parameters[TOOL_PARAMETER_KEYS.orientation] = activeOffset?.orientation || 0; return parameters; } function createPathTool(tool, length) { return { id: Number(tool?.toolNumber) || 0, pocket: Number(tool?.pocket) || 0, length: finiteNumber(length, 0), diameter: finiteNumber(tool?.diameter, 0), }; } function finiteNumber(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number)) return number; } return 0; } function createToolDbEvent(code, payload = {}) { return { apiName: "web-rtcp-5axis-tool-db-event", code, payload, createdAt: new Date().toISOString(), semanticBoundary: "linuxcnc_tool_task_semantics_mirrored_for_web_simulation", promotionScope: "web_simulation_only", hostProcessExecution: false, }; } function formatLinuxCncToolLine(entry, randomToolChanger) { const normalized = normalizeToolEntry(entry); const pocketWord = randomToolChanger ? normalized.idx : normalized.pocket; const words = [ `T${String(formatInteger(normalized.toolNumber)).padEnd(3, " ")}`, `P${String(formatInteger(pocketWord)).padEnd(3, " ")}`, ]; if (normalized.diameter !== 0) words.push(`D${formatLinuxCncFloat(normalized.diameter)}`); for (const axis of TOOL_OFFSET_AXES) { const value = normalized.offset[axis]; if (value !== 0) words.push(`${axis.toUpperCase()}${formatLinuxCncFloat(value)}`); } if (normalized.frontAngle !== 0) words.push(`I${formatLinuxCncFloat(normalized.frontAngle)}`); if (normalized.backAngle !== 0) words.push(`J${formatLinuxCncFloat(normalized.backAngle)}`); if (normalized.orientation !== 0) words.push(`Q${formatInteger(normalized.orientation)}`); return `${words.join(" ")}${normalized.comment ? ` ;${normalized.comment}` : ""}`; } function formatInteger(value) { const number = Number(value); if (!Number.isFinite(number)) return 0; return Math.trunc(number); } function formatLinuxCncFloat(value) { const number = Number(value); if (!Number.isFinite(number)) return "+0.000000"; return `${number >= 0 ? "+" : ""}${number.toFixed(6)}`; } function toolDbStoragePath(toolDb) { const profileId = toolDb.profileId || "unknown-profile"; const filename = basename(toolDb.toolTablePath || "tool.tbl"); return `${TOOL_DB_STORAGE_ROOT}/${profileId}/${filename}`; } function basename(path = "") { return String(path).split("/").filter(Boolean).pop() || ""; } function resolveToolDbStorage(options = {}) { if (options.storage) { const mode = options.storageMode || storageModeFor(options.storage); if (options.requireOpfs === true && mode !== "opfs") { throw new Error(`OPFS storage required for tool DB, got ${mode}`); } return { storage: options.storage, mode, capability: { apiName: "web-rtcp-5axis-tool-db-storage-capability", opfsAvailable: mode === "opfs", opfsUnavailable: mode !== "opfs", fallbackMode: mode === "opfs" ? null : mode || "custom", reason: "explicit_storage", }, }; } const capability = detectToolDbStorageCapability(); if (capability.opfsAvailable) { return { storage: globalThis.navigator.storage, mode: "opfs", capability, }; } if (options.requireOpfs === true) { throw new Error(`OPFS storage required for tool DB: ${capability.reason}`); } browserMemoryToolDbStorage ??= createMemoryToolDbStorage(); return { storage: browserMemoryToolDbStorage, mode: "memory-fallback", capability, }; } function storageModeFor(storage) { return storage?.apiName === "web-rtcp-5axis-memory-tool-db-storage" || storage?.apiName === "web-rtcp-5axis-memory-session-storage" ? "memory" : "custom"; } async function saveTextFile(path, text, storage) { const root = await storage.getDirectory(); const parts = path.split("/").filter(Boolean); let directory = root; for (const part of parts.slice(0, -1)) { directory = await directory.getDirectoryHandle(part, { create: true }); } const handle = await directory.getFileHandle(parts[parts.length - 1], { create: true }); const writable = await handle.createWritable(); await writable.write(text); await writable.close(); } function createDirectoryHandle(files, prefix) { return { async getDirectoryHandle(name, options = {}) { const nextPrefix = [...prefix, name]; if (!options.create) { const hasChild = [...files.keys()].some((path) => path.startsWith(`${nextPrefix.join("/")}/`)); if (!hasChild) throw new Error(`directory not found: ${nextPrefix.join("/")}`); } return createDirectoryHandle(files, nextPrefix); }, async getFileHandle(name, options = {}) { const path = [...prefix, name].join("/"); if (!options.create && !files.has(path)) { throw new Error(`file not found: ${path}`); } return { async createWritable() { let buffer = ""; return { async write(chunk) { buffer += String(chunk); }, async close() { files.set(path, buffer); }, }; }, async getFile() { return { async text() { return files.get(path) || ""; }, }; }, }; }, }; }