const DEFAULT_SDK_MODULE_URLS = [ new URL("../../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js", import.meta.url).href, new URL("../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js", import.meta.url).href, ]; const SEMANTIC_BOUNDARY = "linuxcnc_task_motion_hal_wasm_simulation_runtime"; export async function createLinuxCncTaskHalRuntime({ sdkModuleUrl = null, moduleOptions = {}, } = {}) { const errors = []; const candidateUrls = sdkModuleUrl ? [sdkModuleUrl] : DEFAULT_SDK_MODULE_URLS; for (const url of candidateUrls) { try { const { createLinuxCncTaskHalSdk } = await import(url); const sdk = await createLinuxCncTaskHalSdk(moduleOptions); return wrapTaskHalSdk(sdk, { sdkModuleUrl: url, executionContext: "direct", }); } catch (error) { errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`); } } throw new Error(`LinuxCNC task/HAL runtime unavailable: ${errors.join(" | ")}`); } export function wrapTaskHalSdk(sdk, { sdkModuleUrl = null, executionContext = "direct", workerUrl = null, } = {}) { if (!sdk || typeof sdk.readiness !== "function") { throw new Error("wrapTaskHalSdk requires a task/HAL SDK"); } return { apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime", semanticBoundary: SEMANTIC_BOUNDARY, executionContext, sdkModuleUrl, workerUrl, loaded: true, readiness() { const readiness = sdk.readiness(); const taskRuntimeReady = readiness.taskRuntimeReady === true; const motionRuntimeReady = readiness.motionRuntimeReady === true; const halRuntimeReady = readiness.halRuntimeReady === true; return { apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime-readiness", loaded: true, semanticBoundary: SEMANTIC_BOUNDARY, sdkSemanticBoundary: readiness.semanticBoundary, executionContext, workerUrl, taskRuntimeReady, motionRuntimeReady, halRuntimeReady, halSyncReady: taskRuntimeReady && motionRuntimeReady && halRuntimeReady, nativeTaskReady: taskRuntimeReady, nativeHalSyncReady: taskRuntimeReady && motionRuntimeReady && halRuntimeReady, hardwareDrive: false, hostRealtimeKernel: false, externalUserMProcessReady: false, }; }, initSession(session = {}) { return sdk.initSession(session); }, stageFiles(files = []) { let staged = 0; for (const file of files) { const path = file.wasmPath || file.path; if (!path) continue; const rc = sdk.stageFile(path, file.text || ""); if (rc !== 0) { throw new Error(`lctask_stage_file failed for ${path} rc=${rc}`); } staged += 1; } return staged; }, openProgram(path) { const rc = sdk.openProgram(path); if (rc !== 0) { throw new Error(`lctask_open_program failed for ${path} rc=${rc}`); } return rc; }, loadProgramMotionPlan(plan = {}) { if (typeof sdk.loadProgramMotionPlan !== "function") { throw new Error("task/HAL SDK missing loadProgramMotionPlan; rebuild wasm-port/tools/build_task_hal_wasm.sh"); } const rc = sdk.loadProgramMotionPlan(plan); if (rc !== 0) { throw new Error(`lctask_load_program_motion_plan_json failed rc=${rc}`); } return rc; }, sendCommand(command) { const rc = sdk.sendCommand(command); if (rc !== 0) { throw new Error(`lctask_send_command_json failed for ${command?.type || "unknown"} rc=${rc}`); } return rc; }, runCycles(options = {}) { const rc = sdk.runCycles(options); if (rc !== 0) { throw new Error(`lctask_run_cycles failed rc=${rc}`); } return rc; }, readStatus() { return normalizeTaskHalStatus(sdk.readStatus()); }, readEvents() { return sdk.readEvents(); }, resetSession() { return sdk.resetSession(); }, }; } export function buildTaskHalSessionFromMachineFiles({ profile, plan, save, selectedProgramRel = null } = {}) { const files = save?.files || []; const iniFile = files.find((file) => file.kind === "ini") || files.find((file) => file.sourceRel === profile?.iniPath) || null; const selectedFile = selectedProgramRel ? files.find((file) => file.sourceRel === selectedProgramRel) : null; const programFile = selectedFile || files.find((file) => file.wasmPath === plan?.wasmProgramPath) || files.find((file) => file.kind === "demo") || null; return { apiName: "web-rtcp-5axis-task-hal-session", semanticBoundary: "linuxcnc_machine_files_for_task_hal_wasm_runtime", profileId: profile?.id || plan?.profileId || save?.profileId || "unknown", iniPath: iniFile?.wasmPath || plan?.wasmIniPath || plan?.iniPath || profile?.iniPath || null, iniText: iniFile?.text || "", programPath: programFile?.wasmPath || plan?.wasmProgramPath || null, programSourceRel: programFile?.sourceRel || selectedProgramRel || null, halFiles: files.filter((file) => file.kind === "hal").map(sessionFileDescriptor), toolTableFiles: files.filter((file) => file.kind === "toolTable").map(sessionFileDescriptor), remapFiles: files.filter((file) => file.kind === "remap").map(sessionFileDescriptor), files: files.map(sessionFileDescriptor), fileCount: files.length, }; } export function buildTaskHalProgramMotionPlan({ programPath = null, motion = [], timing = null, linearUnits = "mm", programLines = [], } = {}) { const segments = Array.isArray(timing?.segments) ? timing.segments : []; let planSegments = segments.map((segment, index) => { const event = motion[index] || {}; const startAxes = normalizePlanAxes(segment.startAxes || motion[index - 1]?.axes || {}); const endAxes = normalizePlanAxes(segment.endAxes || segment.axes || event.axes || startAxes); return { line: Number(segment.line ?? event.line ?? index + 1), type: segment.type || event.type || "STRAIGHT_FEED", motionClass: segment.motionClass || (event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed"), feedMode: segment.feedMode || event.feedMode || "units-per-minute", startSeconds: Number(segment.startSeconds || 0), durationSeconds: Math.max(Number(segment.durationSeconds || 0), 0), elapsedSeconds: Number(segment.elapsedSeconds || 0), feedRate: Number(segment.feedRate || event.feedRate || 0), linearUnits: segment.linearUnits || event.linearUnits || linearUnits, velocityMmPerMin: Math.max(Number(segment.velocityMmPerMin || 0), 0), requestedVelocityMmPerMin: Math.max(Number(segment.requestedVelocityMmPerMin || segment.velocityMmPerMin || 0), 0), startAxes, endAxes, }; }).filter((segment) => segment.durationSeconds > 0 || segment.line > 0); const lineSegments = buildSourceLineMotionSegments({ programLines, seedSegments: planSegments, linearUnits, }); if (shouldUseSourceLineSegments(planSegments, lineSegments)) { planSegments = lineSegments; } return { apiName: "web-rtcp-5axis-task-hal-program-motion-plan", semanticBoundary: "linuxcnc_canonical_motion_feed_timed_task_hal_plan", programPath, linearUnits, totalSeconds: Number(timing?.totalSeconds || 0), segmentCount: planSegments.length, segments: planSegments, }; } export function normalizeTaskHalStatus(status = {}) { const motion = status.motionStatus?.motion || {}; const axis = status.motionStatus?.axis || {}; const halPins = status.halSnapshot?.pins || {}; const motionProgramLine = Number(motion.programLine || 0); const halProgramLine = Number(halPins["motion.program-line"]?.value || 0); const motionQueueDepth = Number( status.motionStatus?.commandQueueDepth ?? motion.commandQueueDepth ?? status.motionStatus?.queueDepth ?? motion.queueDepth ?? 0, ); const activeLine = motionProgramLine > 0 ? motionProgramLine : halProgramLine > 0 ? halProgramLine : 1; return { ...status, semanticBoundary: SEMANTIC_BOUNDARY, summary: { taskRuntimeReady: status.taskRuntimeReady === true, motionRuntimeReady: status.motionStatus?.motionHalSyncReady === true || status.taskCommandsDriveMotionRuntime === true, halRuntimeReady: Boolean(status.halSnapshot?.halRuntimeReady ?? status.halSnapshot?.ready ?? true), halSyncReady: status.taskCommandsDriveMotionRuntime === true && Boolean(halPins["motion.program-line"]), taskHalComparisonReady: status.taskRuntimeReady === true && status.taskCommandsDriveMotionRuntime === true, switchkinsRemapHalSync: Boolean(halPins["motion.switchkins-type"]), nativeTaskReady: status.taskRuntimeReady === true, nativeHalSyncReady: status.taskCommandsDriveMotionRuntime === true && Boolean(halPins["motion.program-line"]), fullLinuxCncProgramExecutionReady: false, hardwareDrive: false, hostRealtimeKernel: false, }, ui: { taskState: String(status.task?.state || "ESTOP").toLowerCase(), taskMode: String(status.task?.mode || "MANUAL").toLowerCase(), interpState: String(status.task?.interpState || "IDLE").toLowerCase(), interpResumeState: String(status.task?.interpResumeState || status.task?.interpState || "IDLE").toLowerCase(), execState: String(status.task?.execState || "DONE").toLowerCase(), taskPaused: status.task?.taskPaused === true, singleStepping: status.task?.singleStepping === true, taskCycle: Number(status.task?.taskCycle ?? status.taskCycle ?? 0), servoCycle: Number(status.motionStatus?.cycle ?? status.task?.servoCycle ?? 0), motionQueueDepth, motionPaused: motion.paused === true, motionStepping: motion.stepping === true, motionId: Number(motion.motionId || 0), idForStep: Number(motion.idForStep || 0), halChangedPinCount: Array.isArray(status.halSnapshot?.changedPins) ? status.halSnapshot.changedPins.length : Number(status.halSnapshot?.changedPinCount || 0), activeLine, motionProgramLine, halProgramLine, activeLineSource: motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback", activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0 ? motionProgramLine === halProgramLine : false, switchkinsType: Number(motion.switchkinsType ?? halPins["motion.switchkins-type"]?.value ?? 0), axisPose: { x: Number(axis.x ?? halPins["axis.0.pos-cmd"]?.value ?? 0), y: Number(axis.y ?? halPins["axis.1.pos-cmd"]?.value ?? 0), z: Number(axis.z ?? halPins["axis.2.pos-cmd"]?.value ?? 0), a: Number(axis.a ?? halPins["axis.3.pos-cmd"]?.value ?? 0), b: Number(axis.b ?? halPins["axis.4.pos-cmd"]?.value ?? 0), c: Number(axis.c ?? halPins["axis.5.pos-cmd"]?.value ?? 0), }, axisPoseFrame: isJogMotion(motion) ? "task-local" : "work", currentVelocity: Number(motion.currentVel || motion.currentVelocity || 0) * 60, }, }; } function shouldUseSourceLineSegments(planSegments, lineSegments) { if (lineSegments.length < 3) return false; const plannedLines = new Set(planSegments.map((segment) => Number(segment.line)).filter(Number.isFinite)); const lineCount = lineSegments.length; return plannedLines.size <= 2 && lineCount > plannedLines.size; } function buildSourceLineMotionSegments({ programLines = [], seedSegments = [], linearUnits = "mm", } = {}) { if (!Array.isArray(programLines) || programLines.length === 0) return []; const seedByLine = new Map(seedSegments.map((segment) => [Number(segment.line), segment])); const axes = normalizePlanAxes(seedSegments[0]?.startAxes || {}); const segments = []; let elapsedSeconds = 0; let feedRate = firstPositive(seedSegments.map((segment) => segment.feedRate), 100); let rapidVelocity = firstPositive( seedSegments.filter((segment) => segment.motionClass === "rapid").map((segment) => segment.velocityMmPerMin), 2100, ); for (let index = 0; index < programLines.length; index += 1) { const line = index + 1; const code = stripSourceLineComments(programLines[index]); if (!isExecutableGcodeLine(code)) continue; const seed = seedByLine.get(line) || null; const startAxes = normalizePlanAxes(seed?.startAxes || axes); const parsed = parseGcodeLineMotion(code, startAxes, feedRate); if (parsed.feedRate > 0) feedRate = parsed.feedRate; const motionClass = seed?.motionClass || parsed.motionClass; const endAxes = normalizePlanAxes(seed?.endAxes || parsed.endAxes); const velocityMmPerMin = Number(seed?.velocityMmPerMin) > 0 ? Number(seed.velocityMmPerMin) : motionClass === "rapid" ? rapidVelocity : Math.max(feedRate, 1); if (motionClass === "rapid" && velocityMmPerMin > 0) { rapidVelocity = velocityMmPerMin; } const durationSeconds = Math.max( Number(seed?.durationSeconds || 0), estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin), 0.05, ); const segment = { line, type: seed?.type || parsed.type, motionClass, feedMode: seed?.feedMode || parsed.feedMode, startSeconds: elapsedSeconds, durationSeconds, elapsedSeconds: elapsedSeconds + durationSeconds, feedRate, linearUnits: seed?.linearUnits || linearUnits, velocityMmPerMin, requestedVelocityMmPerMin: Number(seed?.requestedVelocityMmPerMin || velocityMmPerMin), startAxes, endAxes, }; segments.push(segment); Object.assign(axes, endAxes); elapsedSeconds += durationSeconds; } return segments; } function parseGcodeLineMotion(code, startAxes, currentFeedRate) { const numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)"; const gCodes = [...code.matchAll(new RegExp(`\\bG\\s*(${numberPattern})\\b`, "gi"))].map((match) => Number(match[1])); const feedMode = gCodes.includes(93) ? "inverse-time" : "units-per-minute"; const rapid = gCodes.includes(0); const feed = gCodes.some((value) => value === 1 || value === 2 || value === 3); const endAxes = { ...startAxes }; for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) { const match = code.match(new RegExp(`\\b${axis}\\s*(${numberPattern})`, "i")); if (match) endAxes[axis] = Number(match[1]); } const feedMatch = code.match(new RegExp(`\\bF\\s*(${numberPattern})`, "i")); const feedRate = feedMatch && Number(feedMatch[1]) > 0 ? Number(feedMatch[1]) : Number(currentFeedRate || 0); return { type: rapid ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED", motionClass: rapid && !feed ? "rapid" : "feed", feedMode, feedRate, endAxes, }; } function stripSourceLineComments(line) { return String(line || "") .replace(/\([^)]*\)/g, " ") .replace(/;.*$/g, " ") .trim(); } function isExecutableGcodeLine(code) { if (!code || code === "%") return false; return /\b[GMTXYZABCUVWF]\s*[-+]?\d/i.test(code); } function estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin) { const distance = Math.sqrt(["x", "y", "z"].reduce((total, axis) => { const delta = Number(endAxes[axis] || 0) - Number(startAxes[axis] || 0); return total + delta * delta; }, 0)); if (distance <= 0 || velocityMmPerMin <= 0) return 0; return distance / Math.max(velocityMmPerMin / 60, 0.000001); } function firstPositive(values, fallback) { for (const value of values) { const number = Number(value); if (Number.isFinite(number) && number > 0) return number; } return fallback; } function normalizePlanAxes(axes = {}) { return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => [ axis, Number.isFinite(Number(axes[axis])) ? Number(axes[axis]) : 0, ])); } function isJogMotion(motion = {}) { return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true; } function sessionFileDescriptor(file) { return { sourceRel: file.sourceRel, wasmPath: file.wasmPath || file.path, path: file.wasmPath || file.path, kind: file.kind, bytes: Number(file.bytes || String(file.text || "").length), text: file.text || "", }; }