import { buildRtcpFrame } from "../runtime/rtcp-frame.js"; import { createLinuxCncBoundaryAdapter, createLinuxCncBoundaryReadiness } from "../runtime/linuxcnc-boundary-adapter.js"; import { createFullLinuxCncExecutionBoundary } from "../runtime/full-execution-boundary.js"; import { DEFAULT_SESSION_FILENAME, DEFAULT_SESSION_ID, createFiveAxisSessionPayload, loadFiveAxisSessionSnapshot, restoreFiveAxisSessionState, saveFiveAxisSessionSnapshot, } from "../runtime/five-axis-session.js"; import { fiveAxisProfiles, getFiveAxisProfile } from "../profiles/index.js"; import { applyIniConfigToProfile } from "../runtime/linuxcnc-ini-runtime.js"; import { listLinuxCncGcodeSources, listProjectGcodeFiles, selectMachineFileProgram, stageProfileMachineFiles, } from "../runtime/linuxcnc-machine-file-staging.js"; import { buildTaskHalProgramMotionPlan, buildTaskHalSessionFromMachineFiles, } from "../runtime/linuxcnc-task-hal-runtime.js"; import { applyToolCommandSequence, createToolDbReadiness, createToolDbSimulation, createToolRuntimeState, editToolEntry, extractToolCommandSequenceFromProgram, listToolEntries, parseLinuxCncToolTable, queryToolEntry, saveToolDbSimulation, } from "../runtime/tool-db-simulation.js"; import { createControlledUserMReadiness, createControlledUserMSimulation, runControlledUserM, runControlledUserMProgramScan, } from "../runtime/controlled-user-m-simulation.js"; import { createLinuxCncParityMatrix } from "../runtime/linuxcnc-parity-matrix.js"; import { gmoccapyHalModel, resolveGmoccapyHardwareButton } from "../runtime/gmoccapy-hal-model.js"; import { createLinuxCncTaskPolicyStatus, deriveLinuxCncTaskState, gateLinuxCncTaskAction, normalizeLinuxCncTaskMode, } from "./linuxcnc-task-policy.js"; import { buildProgramExecutionTiming, timingAtMotionIndex } from "../runtime/execution-timing.js"; import { buildAxisExecutionTraceFromProgram, buildAxisPreviewPathFromProgram, } from "../runtime/axis-preview-path.js"; const defaultProfile = getFiveAxisProfile("xyzbc-trt"); const initialLinuxCncBoundaryAdapter = createLinuxCncBoundaryAdapter({ profile: defaultProfile, }); const initialLinuxCncBoundaryReadiness = createLinuxCncBoundaryReadiness(initialLinuxCncBoundaryAdapter); const initialControlledUserMSimulation = createControlledUserMSimulation(); const MACHINE_PROJECT_OPFS_ROOT = "web-rtcp-5axis-xyzbc-trt-sim-plan/machines"; const initialAxisPose = { x: 0.0, y: 0.0, z: 0.0, a: 0.0, b: 0.0, c: 0.0, }; function createTaskHalStatusLoopState({ active = false, sequence = 0, profileId = null, iniPath = null, kinematicsModuleId = null, tickCount = 0, batchSize = 5, intervalMs = 25, taskPeriodNs = 10000000, servoPeriodNs = 1000000, lastStatusAt = null, lastError = null, stopReason = null, } = {}) { return { apiName: "web-rtcp-5axis-task-hal-status-loop", active, sequence, profileId, iniPath, kinematicsModuleId, tickCount, batchSize, intervalMs, taskPeriodNs, servoPeriodNs, lastStatusAt, lastError, stopReason, semanticBoundary: "js_status_polling_loop_for_linuxcnc_task_hal_motion_status", }; } function withDefaultWebOpfsRequirement(options = {}) { if (options.storage || options.requireOpfs !== undefined) { return options; } const isBrowser = typeof globalThis.window === "object" || typeof globalThis.document === "object"; return isBrowser ? { ...options, requireOpfs: true } : options; } function buildProgramAxisPathFromProgram(options = {}) { return buildAxisExecutionTraceFromProgram(options) || buildAxisPreviewPathFromProgram(options); } const initialState = { machineProfile: "xyzbc-trt", availableProfiles: fiveAxisProfiles.map(({ id, title, traj, kinematicsModuleId, kinematics }) => ({ id, title, coordinates: traj.coordinates, kinematicsModuleId: kinematicsModuleId || id, kinematics, })), profile: defaultProfile, sessionName: "xyzbc-trt-web-session", sessionPersistence: { apiName: "web-rtcp-5axis-session-persistence-state", sessionId: DEFAULT_SESSION_ID, filename: DEFAULT_SESSION_FILENAME, status: "not-saved", path: null, storageMode: null, storageCapability: null, savedAt: null, restoredAt: null, lastError: null, }, machineFileStaging: { apiName: "web-rtcp-5axis-machine-file-staging-state", status: "not-staged", profileId: null, fileCount: 0, opfsRoot: null, storageMode: null, storageCapability: null, savedAt: null, lastError: null, plan: null, save: null, gcodeSources: [], gcodeFiles: [], selectedGcodeSourceRel: null, }, machineProject: null, programValidation: null, linuxCncParityMatrix: null, rightSidebarEntrances: [], sourceMode: "fixture-ui-only", frameSourceMode: "fixture-ui-only", desiredFrameSourceMode: "fixture-ui-only", machine: { powerOn: false, estopActive: false, motionEnabled: false, taskState: "estop-reset", mode: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, resumeInhibit: false, manualPanel: "manual", allHomed: false, homed: [false, false, false, false, false], noForceHoming: false, homing: false, homeState: "unhomed", selectedJoint: 0, jogAxis: "x", jogIncrement: 1, mdiCommand: "G0 X0 Y0 Z0", mdiDistanceMode: "absolute", resetCount: 0, }, runState: "idle", activeProgram: "./demos/xyzbc_switchkins.ngc", programSource: "linuxcnc-axis-default", programStartLine: 1, activeLine: 2, lineCount: 3, fileSizeBytes: 109, kinsType: "identity", rtcpState: "off", axisPose: initialAxisPose, jointPose: [], tcpPose: { x: 0.0, y: 0.0, z: 0.0, a: 0.0, c: 0.0, }, toolAxisVector: { x: 0.0, y: 0.0, z: 1.0, }, rtcpFrame: null, kinematicsRuntime: null, kinematicsRuntimeReadiness: null, kinematicsExecutionContext: "none", interpreterRuntime: null, interpreterRuntimeReadiness: null, programExecution: null, programExecutionTiming: null, programAxisPreviewPath: null, programElapsedSeconds: 0, programRemainingSeconds: 0, programExecutionSourceMode: "fixture-line-playback", programExecutionMotionIndex: 0, programExecutionSampleIndex: 0, programRuntimeFeedback: null, programRuntimeFeedbackHistory: [], programLineExecution: {}, programUiExecution: null, linuxCncProcessMonitor: null, taskHalRuntime: null, taskHalRuntimeReadiness: null, taskHalStatus: null, taskHalSession: null, taskHalExecutionPending: false, taskHalExecutionSequence: 0, taskHalStatusLoop: createTaskHalStatusLoopState(), taskHalPauseLock: null, taskHalFallbackReason: null, pendingJogCommand: null, interpreterExecutionPending: false, interpreterExecutionSequence: 0, machineFileExecution: null, toolDbSimulation: null, toolDbReadiness: createToolDbReadiness(null), toolRuntimeState: createToolRuntimeState(null, { fallbackToolLength: 84.019, }), controlledUserMSimulation: initialControlledUserMSimulation, controlledUserMReadiness: createControlledUserMReadiness(initialControlledUserMSimulation), fullExecutionBoundary: null, linuxCncTaskPolicy: null, asyncFrameRefreshPending: false, asyncFrameRefreshSequence: 0, lastKinematicsResult: null, linuxCncBoundaryAdapter: initialLinuxCncBoundaryAdapter, linuxCncBoundaryReadiness: initialLinuxCncBoundaryReadiness, linuxCncIniConfig: null, iniConfigReadiness: { apiName: "web-rtcp-5axis-ini-config-readiness", loaded: false, ready: false, path: null, missing: ["LinuxCNC INI not loaded"], }, dro: { x: 0.0, y: 0.0, z: 0.0, a: 0.0, b: 0.0, c: 0.0, tcpX: 43.0, tcpY: -32.15, tcpZ: -11.306, dtgX: 0.0, dtgY: 0.01, dtgZ: 2.25, }, feed: { currentVelocity: 0, rapidOverride: 100, feedRate: 4500000, feedOverride: 100, }, spindle: { rpm: 1600, override: 100, enabled: false, direction: "stop", halPins: { on: 0, forward: 0, reverse: 0, speedOut: 0, atSpeed: 0, }, }, coolant: { flood: false, mist: false, }, gmoccapyGui: { ignoreLimits: false, optionalBlocks: false, optionalStop: false, feedOverrideCounts: 0, rapidOverrideCounts: 0, spindleOverrideCounts: 0, jogVelocityCounts: 0, feedOverrideCountEnabled: false, rapidOverrideCountEnabled: false, spindleOverrideCountEnabled: false, jogVelocityCountEnabled: false, feedOverrideAnalogEnabled: false, rapidOverrideAnalogEnabled: false, spindleOverrideAnalogEnabled: false, jogVelocityAnalogEnabled: false, jogVelocity: 100, jogIncrementIndex: 0, jogIncrementLabel: "Continuous", jogIncrementOutput: 0, turtleJog: false, activeJogPin: null, settingsUnlockMode: "use", settingsUnlockPin: false, setupSensitive: true, toolsensorConfigured: false, toolMeasurement: false, probeHeight: 0, blockHeight: 0, searchVelocity: 0, probeVelocity: 0, userMessagesConfigured: false, userMessagePins: [], warningConfirm: false, error: false, deletedMessageCount: 0, activeNativePage: "main", nativePageMode: "implemented", filePageStatus: "main", filePageLastAction: null, macroPageConfigured: true, macroButtonsEnabled: true, macroLastCommand: null, macroLastName: null, toolEditorStatus: "closed", toolEditorWritebackEnabled: false, toolEditorLastAction: null, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: null, }, preview: { pathPoints: 64, selectedView: "iso", fullscreen: false, cameraRevision: 0, }, toolPreview: { toolNumber: 1, diameter: 6, length: 84.019, units: "mm", holder: "CAT40", }, operatorMessage: "ready", mdiHistory: [], }; initialState.linuxCncTaskPolicy = createLinuxCncTaskPolicyStatus(initialState); initialState.machineProject = createMachineProjectState(initialState); initialState.programValidation = createProgramValidationState(initialState); initialState.rightSidebarEntrances = createRightSidebarEntranceState(initialState, initialState.linuxCncTaskPolicy); initialState.linuxCncParityMatrix = createLinuxCncParityMatrix(initialState); initialState.linuxCncProcessMonitor = createLinuxCncProcessMonitor(initialState); const programLines = [ "; zmax zmin r frate n a b c dist", "o call [10] [5] [10][1000][3][0][20][45][20]", "m2", ]; export function createSimulationStore(seed = {}) { const seedAxisPose = seed.axisPose || initialAxisPose; const seedKinsType = seed.kinsType || initialState.kinsType; const seedRtcpState = seed.rtcpState || initialState.rtcpState; const seedFrame = buildRtcpFrame({ axisPose: seedAxisPose, activeLine: seed.activeLine || initialState.activeLine, kinsType: seedKinsType, rtcpEnabled: seedRtcpState === "on" || seedKinsType === "tcp-xyzac", sourceMode: seed.desiredFrameSourceMode || seed.sourceMode || seed.frameSourceMode || initialState.sourceMode, }); let state = { ...initialState, ...seed, axisPose: seedFrame.axisPose, jointPose: seedFrame.jointPose, tcpPose: seedFrame.tcpPose, toolAxisVector: seedFrame.toolAxisVector, rtcpState: seedFrame.rtcpState, rtcpFrame: seedFrame, fullExecutionBoundary: null, dro: buildDroFromFrame(seedFrame, seed.programRuntimeFeedback || initialState.programRuntimeFeedback), programLines: seed.programLines || programLines, }; const initialAxisPreviewPath = seed.programAxisPreviewPath === undefined ? buildProgramAxisPathFromProgram({ filename: state.activeProgram, sourceRel: state.programSourceRel, content: state.programLines.join("\n"), tool: currentPathTool(state), }) : seed.programAxisPreviewPath; state = { ...state, machine: normalizeMachineForLinuxCncTask(state.machine, state.runState), programAxisPreviewPath: initialAxisPreviewPath, preview: { ...state.preview, pathPoints: initialAxisPreviewPath?.sampleCount || state.preview.pathPoints, }, }; state.linuxCncTaskPolicy = createLinuxCncTaskPolicyStatus(state); state.machineProject = createMachineProjectState(state); state.programValidation = createProgramValidationState(state); state.rightSidebarEntrances = createRightSidebarEntranceState(state, state.linuxCncTaskPolicy); state.linuxCncParityMatrix = createLinuxCncParityMatrix(state); state.linuxCncProcessMonitor = createLinuxCncProcessMonitor(state); state.fullExecutionBoundary = createFullLinuxCncExecutionBoundary(state); const listeners = new Set(); let taskHalStatusLoopTimer = null; const notify = () => { for (const listener of listeners) { listener(state); } }; const setState = (patch) => { const merged = { ...state, ...patch }; const mergedMachine = normalizeMachineForLinuxCncTask(merged.machine, merged.runState); const next = { ...merged, machine: mergedMachine, axisPose: clampAxisPoseToProfile(merged.axisPose, merged.profile), }; const frameState = buildFrameForState(next, patch); const frame = patch.rtcpFrame || frameState.frame; const baseState = { ...next, sourceMode: frame.sourceMode, frameSourceMode: frame.sourceMode, desiredFrameSourceMode: next.desiredFrameSourceMode || frame.sourceMode, axisPose: frame.axisPose, jointPose: frame.jointPose, tcpPose: frame.tcpPose, toolAxisVector: frame.toolAxisVector, rtcpState: frame.rtcpState, rtcpFrame: frame, lastKinematicsResult: frameState.lastKinematicsResult, dro: buildDroFromFrame(frame, next.programRuntimeFeedback), }; const taskPolicy = createLinuxCncTaskPolicyStatus(baseState); const projectState = createMachineProjectState(baseState); const validationState = createProgramValidationState(baseState); const sidebarState = createRightSidebarEntranceState(baseState, taskPolicy); const nextState = { ...baseState, linuxCncTaskPolicy: taskPolicy, machineProject: projectState, programValidation: validationState, rightSidebarEntrances: sidebarState, }; state = { ...nextState, linuxCncParityMatrix: createLinuxCncParityMatrix(nextState), linuxCncProcessMonitor: createLinuxCncProcessMonitor(nextState), fullExecutionBoundary: createFullLinuxCncExecutionBoundary(nextState), }; notify(); scheduleAsyncKinematicsRefresh(); }; const waitForStatePredicate = (predicate, timeoutMs = 10000) => { if (predicate(state)) return Promise.resolve(state); return new Promise((resolve, reject) => { const startedAt = Date.now(); const listener = (nextState) => { if (predicate(nextState)) { listeners.delete(listener); resolve(nextState); return; } if (Date.now() - startedAt > timeoutMs) { listeners.delete(listener); reject(new Error("timed out waiting for store state")); } }; listeners.add(listener); }); }; const waitForStatePolling = (predicate, timeoutMs = 10000, intervalMs = 25) => { if (predicate(state)) return Promise.resolve(state); return new Promise((resolve, reject) => { const startedAt = Date.now(); const tick = () => { if (predicate(state)) { resolve(state); return; } if (Date.now() - startedAt > timeoutMs) { reject(new Error("timed out waiting for store state")); return; } setTimeout(tick, intervalMs); }; setTimeout(tick, intervalMs); }); }; const dispatch = (action) => { switch (action.type) { case "BOOT_READY": setState({ bootReady: true }); break; case "ATTACH_KINEMATICS_RUNTIME": { const runtime = action.runtime || null; const readiness = runtime?.readiness ? runtime.readiness() : null; const runtimeDescriptor = runtime?.loaded ? { apiName: runtime.apiName, moduleId: runtime.moduleId, wasmFile: runtime.wasmFile, supportedModules: runtime.supportedModules, loaded: runtime.loaded, sourceMode: runtime.sourceMode, semanticBoundary: runtime.semanticBoundary, switchkinsType: runtime.switchkinsType, switchRc: runtime.switchRc, } : null; const adapter = createLinuxCncBoundaryAdapter({ profile: state.profile, runtime: { kinematicsWasm: runtimeDescriptor, interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), }, }); setState({ kinematicsRuntime: runtime, kinematicsRuntimeReadiness: readiness, kinematicsExecutionContext: runtime?.executionContext || (runtime?.loaded ? "direct" : "none"), linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), desiredFrameSourceMode: runtime?.loaded ? "source-derived-kinematics-wasm" : "fixture-ui-only", operatorMessage: runtime?.loaded ? `LinuxCNC kinematics ${runtime.moduleId} ready` : "LinuxCNC kinematics runtime missing", }); } break; case "ATTACH_INI_CONFIG": { const baseProfile = getFiveAxisProfile(action.profileId || state.machineProfile); const profile = applyIniConfigToProfile(baseProfile, action.iniConfig); const adapter = createLinuxCncBoundaryAdapter({ profile, runtime: { kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), }, }); setState({ machineProfile: profile.id, profile, kinsType: normalizeKinsTypeForProfile(state.kinsType, profile), axisPose: clampAxisPoseToProfile(state.axisPose, profile), linuxCncIniConfig: action.iniConfig, iniConfigReadiness: createIniConfigReadiness(action.iniConfig), linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), operatorMessage: `LinuxCNC INI loaded ${action.iniConfig.path}`, }); } break; case "INI_CONFIG_FAILED": setState({ iniConfigReadiness: { apiName: "web-rtcp-5axis-ini-config-readiness", loaded: false, ready: false, path: action.path || state.profile.iniPath, missing: [action.error], }, operatorMessage: `LinuxCNC INI error: ${action.error}`, }); break; case "SET_PROFILE": { const profile = getFiveAxisProfile(action.profileId); const defaultKinsType = defaultKinsTypeForProfile(profile); const adapter = createLinuxCncBoundaryAdapter({ profile, runtime: { kinematicsWasm: null, interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), }, }); setState({ machineProfile: profile.id, profile, activeProgram: profile.samplePrograms[0] || state.activeProgram, kinsType: defaultKinsType, rtcpState: rtcpStateFromKinsType(defaultKinsType), kinematicsRuntime: null, kinematicsRuntimeReadiness: null, kinematicsExecutionContext: "none", toolDbSimulation: null, toolDbReadiness: createToolDbReadiness(null), toolRuntimeState: createToolRuntimeState(null, { fallbackToolLength: state.toolPreview?.length, }), ...createInitialControlledUserMState(), linuxCncIniConfig: null, iniConfigReadiness: initialState.iniConfigReadiness, linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), sessionPersistence: { ...state.sessionPersistence, status: "profile-switched", lastError: null, }, operatorMessage: `profile ${profile.id}`, }); } break; case "ATTACH_INTERPRETER_RUNTIME": { const runtime = action.runtime || null; const readiness = runtime?.readiness ? runtime.readiness() : null; const adapter = createLinuxCncBoundaryAdapter({ profile: state.profile, runtime: { kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), interpreterWasm: createInterpreterDescriptor(runtime), }, }); setState({ interpreterRuntime: runtime, interpreterRuntimeReadiness: readiness, linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), operatorMessage: runtime?.loaded ? "LinuxCNC interpreter ready" : "LinuxCNC interpreter runtime missing", }); } break; case "ATTACH_TASK_HAL_RUNTIME": { const runtime = action.runtime || null; const maybeReadiness = action.readiness || (runtime?.readiness ? runtime.readiness() : null); const readiness = typeof maybeReadiness?.then === "function" ? { apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime-readiness", loaded: Boolean(runtime?.loaded), taskRuntimeReady: false, motionRuntimeReady: false, halRuntimeReady: false, pending: true, } : maybeReadiness; setState({ taskHalRuntime: runtime, taskHalRuntimeReadiness: readiness, taskHalFallbackReason: runtime?.loaded ? null : "LinuxCNC task/HAL runtime missing", operatorMessage: runtime?.loaded ? "LinuxCNC task/HAL runtime ready" : "LinuxCNC task/HAL runtime missing", }); if (runtime?.loaded && state.machineFileStaging?.status === "staged") { initializeTaskHalSession().catch(() => {}); } } break; case "TASK_HAL_RUNTIME_FAILED": setState({ taskHalRuntime: null, taskHalRuntimeReadiness: { apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime-readiness", loaded: false, taskRuntimeReady: false, motionRuntimeReady: false, halRuntimeReady: false, nativeTaskReady: false, nativeHalSyncReady: false, error: action.error, }, taskHalFallbackReason: action.error, operatorMessage: `LinuxCNC task/HAL runtime blocked: ${action.error}`, }); break; case "RUN_INTERPRETER_PROGRAM": if (!state.interpreterRuntime?.loaded) { setState({ programExecutionSourceMode: "fixture-line-playback", operatorMessage: "LinuxCNC interpreter unavailable; using fixture line playback", }); break; } { const programText = state.programLines.join("\n"); const sequence = state.interpreterExecutionSequence + 1; setState({ interpreterExecutionPending: true, interpreterExecutionSequence: sequence, operatorMessage: "LinuxCNC interpreter running program", }); try { Promise.resolve(state.interpreterRuntime.runProgram(programText)) .then((execution) => { dispatch({ type: "INTERPRETER_PROGRAM_COMPLETE", sequence, execution, }); }) .catch((error) => { dispatch({ type: "INTERPRETER_PROGRAM_FAILED", sequence, error: error instanceof Error ? error.message : String(error), }); }); } catch (error) { dispatch({ type: "INTERPRETER_PROGRAM_FAILED", sequence, error: error instanceof Error ? error.message : String(error), }); } } break; case "INTERPRETER_PROGRAM_COMPLETE": if (action.sequence !== state.interpreterExecutionSequence) { break; } { const execution = action.execution; const timing = buildTimingForState(state, execution); const firstTiming = timingAtMotionIndex(timing, 0); const firstMotion = execution.motion[0] || null; const firstKinsType = kinsTypeFromProgramMotion(state, firstMotion) || state.kinsType; const firstFeedback = createInitialProgramRuntimeFeedback({ state, timing, motion: firstMotion, timingSnapshot: firstTiming, }); const initialUiPatch = applyProgramPlaybackUiPatch({ ...state, programExecution: execution, programExecutionTiming: timing, programExecutionSampleIndex: 0, programExecutionMotionIndex: 0, activeLine: firstMotion?.line || state.programStartLine, axisPose: axisPoseFromCanonicalMotion(firstMotion, state.axisPose), kinsType: firstKinsType, rtcpState: rtcpStateFromKinsType(firstKinsType), programRuntimeFeedback: firstFeedback, }, { activeLine: firstMotion?.line || state.programStartLine, axisPose: axisPoseFromCanonicalMotion(firstMotion, state.axisPose), kinsType: firstKinsType, rtcpState: rtcpStateFromKinsType(firstKinsType), motionIndex: 0, sampleIndex: 0, runtimeFeedback: firstFeedback, }); setState({ programExecution: execution, programExecutionTiming: timing, programExecutionSourceMode: execution.sourceMode, machineFileExecution: execution.machineFilePlan ? execution : state.machineFileExecution, ...initialUiPatch, programLineExecution: createProgramLineExecutionPatch(state.programLineExecution, firstFeedback, { status: "ready", source: execution.sourceMode, }), programElapsedSeconds: firstTiming.elapsedSeconds, programRemainingSeconds: firstTiming.remainingSeconds, interpreterExecutionPending: false, preview: { ...state.preview, pathPoints: state.programAxisPreviewPath?.sampleCount || Math.max(execution.summary.motionEventCount, 1), }, feed: { ...state.feed, currentVelocity: Number.isFinite(firstFeedback?.currentVelocityMmPerMin) ? firstFeedback.currentVelocityMmPerMin : state.feed.currentVelocity, }, operatorMessage: execution.summary.switchkinsEventCount > 0 ? `LinuxCNC interpreter motion events ${execution.summary.motionEventCount}, switchkins ${execution.summary.switchkinsCodes.join("/")}` : `LinuxCNC interpreter motion events ${execution.summary.motionEventCount}`, }); } break; case "INTERPRETER_PROGRAM_FAILED": if (action.sequence !== state.interpreterExecutionSequence) { break; } setState({ programExecution: null, programExecutionTiming: null, programElapsedSeconds: 0, programRemainingSeconds: 0, programExecutionSourceMode: "fixture-line-playback", programExecutionSampleIndex: 0, programRuntimeFeedback: null, programLineExecution: {}, interpreterExecutionPending: false, preview: { ...state.preview, pathPoints: state.programAxisPreviewPath?.sampleCount || state.preview.pathPoints, }, operatorMessage: `LinuxCNC interpreter blocked: ${action.error}`, }); break; case "RUN_MACHINE_FILE_PROGRAM": if (!state.interpreterRuntime?.loaded || typeof state.interpreterRuntime.runMachineFileProgram !== "function") { setState({ operatorMessage: "machine-file run blocked: LinuxCNC interpreter machine-file runtime unavailable", }); break; } if (!state.machineFileStaging?.plan || !state.machineFileStaging?.save) { setState({ operatorMessage: "machine-file run blocked: machine files not staged", }); break; } if (!state.machineFileStaging.selectedGcodeSourceRel) { setState({ operatorMessage: "machine-file run blocked: select a LinuxCNC source-directory 5-axis G-code program", }); break; } { const sequence = state.interpreterExecutionSequence + 1; setState({ interpreterExecutionPending: true, interpreterExecutionSequence: sequence, operatorMessage: "LinuxCNC machine-file remap run starting", }); try { Promise.resolve(state.interpreterRuntime.runMachineFileProgram({ plan: selectMachineFileProgramForState(state), files: state.machineFileStaging.save.files, executionMode: "fiveAxisRemap", })) .then((execution) => { dispatch({ type: "INTERPRETER_PROGRAM_COMPLETE", sequence, execution }); }) .catch((error) => { dispatch({ type: "INTERPRETER_PROGRAM_FAILED", sequence, error: error instanceof Error ? error.message : String(error), }); }); } catch (error) { dispatch({ type: "INTERPRETER_PROGRAM_FAILED", sequence, error: error instanceof Error ? error.message : String(error), }); } } break; case "SESSION_SAVE_STARTED": setState({ sessionPersistence: { ...state.sessionPersistence, status: "saving", lastError: null, }, operatorMessage: "saving 5-axis session", }); break; case "SESSION_SAVE_COMPLETE": setState({ sessionPersistence: { ...state.sessionPersistence, status: "saved", path: action.path, storageMode: action.storageMode || null, storageCapability: action.storageCapability || null, savedAt: action.savedAt, lastError: null, }, operatorMessage: `5-axis session saved ${action.path} (${action.storageMode || "unknown"})`, }); break; case "SESSION_RESTORE_STARTED": setState({ sessionPersistence: { ...state.sessionPersistence, status: "restoring", lastError: null, }, operatorMessage: "restoring 5-axis session", }); break; case "SESSION_RESTORE_COMPLETE": { const restoredProfile = getFiveAxisProfile(action.restoredState.machineProfile); const adapter = createLinuxCncBoundaryAdapter({ profile: restoredProfile, runtime: { kinematicsWasm: createKinematicsDescriptor(state.kinematicsRuntime), interpreterWasm: createInterpreterDescriptor(state.interpreterRuntime), }, }); setState({ ...action.restoredState, profile: restoredProfile, linuxCncIniConfig: state.linuxCncIniConfig, iniConfigReadiness: state.iniConfigReadiness, kinematicsRuntime: state.kinematicsRuntime, kinematicsRuntimeReadiness: state.kinematicsRuntimeReadiness, kinematicsExecutionContext: state.kinematicsExecutionContext, interpreterRuntime: state.interpreterRuntime, interpreterRuntimeReadiness: state.interpreterRuntimeReadiness, taskHalRuntime: state.taskHalRuntime, taskHalRuntimeReadiness: state.taskHalRuntimeReadiness, taskHalStatus: state.taskHalStatus, taskHalSession: state.taskHalSession, machineFileStaging: state.machineFileStaging, machineFileExecution: state.machineFileExecution, linuxCncBoundaryAdapter: adapter, linuxCncBoundaryReadiness: createLinuxCncBoundaryReadiness(adapter), sessionPersistence: { ...state.sessionPersistence, status: "restored", path: action.path, storageMode: action.storageMode || null, storageCapability: action.storageCapability || null, restoredAt: action.restoredAt, lastError: null, }, operatorMessage: `5-axis session restored ${action.path} (${action.storageMode || "unknown"})`, }); } break; case "SESSION_PERSISTENCE_FAILED": setState({ sessionPersistence: { ...state.sessionPersistence, status: "error", lastError: action.error, }, operatorMessage: `5-axis session error: ${action.error}`, }); break; case "MACHINE_FILE_STAGING_STARTED": setState({ machineFileStaging: { ...state.machineFileStaging, status: "staging", profileId: state.machineProfile, lastError: null, }, operatorMessage: "staging LinuxCNC machine files", }); break; case "MACHINE_FILE_STAGING_COMPLETE": setState({ machineFileStaging: { ...state.machineFileStaging, status: "staged", profileId: action.plan.profileId, fileCount: action.save.fileCount, opfsRoot: action.save.opfsRoot, storageMode: action.save.storageMode || null, storageCapability: action.save.storageCapability || null, savedAt: action.save.savedAt, lastError: null, plan: action.plan, save: action.save, gcodeSources: listLinuxCncGcodeSources(action.save), gcodeFiles: listProjectGcodeFiles(action.save), selectedGcodeSourceRel: action.selectedGcodeSourceRel || action.plan.selectedProgramSourceRel || null, }, ...createToolDbStatePatchFromStagedFiles({ profile: state.profile, save: action.save, }), operatorMessage: `LinuxCNC machine files staged ${action.save.fileCount}`, }); if (state.taskHalRuntime?.loaded) { initializeTaskHalSession().catch(() => {}); } break; case "LOAD_LINUXCNC_GCODE_SOURCE": { const sourceRel = action.sourceRel; const selectedFile = state.machineFileStaging?.save?.files?.find((file) => file.sourceRel === sourceRel); if (!selectedFile) { setState({ operatorMessage: `LinuxCNC G-code source not staged: ${sourceRel}`, }); break; } const selectedPlan = selectMachineFileProgram( state.machineFileStaging.plan, state.machineFileStaging.save, sourceRel, ); const loadedProgram = buildLoadedProgram({ filename: selectedFile.sourceRel, content: selectedFile.text, programSource: "linuxcnc-vendored-5axis-gcode", sourceRel: selectedFile.sourceRel, wasmPath: selectedFile.wasmPath, }); const toolUserPatch = createProgramToolUserSimulationPatch({ state, programText: selectedFile.text, sourceRel: selectedFile.sourceRel, }); const axisPreviewPath = buildProgramAxisPathFromProgram({ filename: selectedFile.sourceRel, sourceRel: selectedFile.sourceRel, content: selectedFile.text, tool: currentPathTool({ ...state, toolRuntimeState: toolUserPatch.toolRuntimeState, }), }); setState({ ...loadedProgram, ...toolUserPatch, machineFileStaging: { ...state.machineFileStaging, plan: selectedPlan, selectedGcodeSourceRel: sourceRel, }, machine: { ...state.machine, mode: state.machine.mode, }, axisPose: initialAxisPose, runState: "idle", programAxisPreviewPath: axisPreviewPath, programRuntimeFeedback: null, programUiExecution: null, programLineExecution: {}, preview: { ...state.preview, pathPoints: axisPreviewPath?.sampleCount || Math.max(loadedProgram.programLines.length, 1), }, operatorMessage: `loaded LinuxCNC 5-axis source ${selectedFile.sourceRel}`, }); if (state.taskHalRuntime?.loaded) { initializeTaskHalSession({ openProgram: true }).catch(() => {}); } if (state.interpreterRuntime?.loaded) { dispatch({ type: "RUN_INTERPRETER_PROGRAM" }); } } break; case "RUN_CONTROLLED_USER_M": { const result = runControlledUserM( state.controlledUserMSimulation || createControlledUserMSimulation(), action.code, { profile: state.profile, sourceRel: action.sourceRel || state.programSourceRel || null, line: action.line || null, }, ); setState({ controlledUserMSimulation: result.simulation, controlledUserMReadiness: createControlledUserMReadiness(result.simulation), ...result.statePatch, operatorMessage: result.event.allowed ? `controlled user-M ${result.event.code} simulated` : `controlled user-M ${result.event.code} blocked`, }); } break; case "TASK_HAL_SESSION_READY": if (action.session?.programSourceRel && action.session.programSourceRel !== state.machineFileStaging?.selectedGcodeSourceRel) { break; } setState({ taskHalSession: action.session, taskHalFallbackReason: null, operatorMessage: `LinuxCNC task/HAL session ready ${action.session.programPath || "-"}`, }); break; case "TASK_HAL_STATUS_APPLIED": setState(applyTaskHalStatusPatch(state, action.status, action.operatorMessage, { loopSequence: action.loopSequence, preserveMachine: action.preserveMachine, preserveAxisPose: action.preserveAxisPose, })); break; case "TASK_HAL_STATUS_LOOP_STARTED": setState({ taskHalStatusLoop: { ...createTaskHalStatusLoopState({ active: true, sequence: action.sequence, profileId: action.profileId, iniPath: action.iniPath, kinematicsModuleId: action.kinematicsModuleId, batchSize: action.batchSize, intervalMs: action.intervalMs, taskPeriodNs: action.taskPeriodNs, servoPeriodNs: action.servoPeriodNs, }), }, programRuntimeFeedbackHistory: [], operatorMessage: action.operatorMessage || "task/HAL status loop running", }); break; case "TASK_HAL_STATUS_LOOP_STOPPED": setState({ taskHalStatusLoop: { ...state.taskHalStatusLoop, active: false, sequence: Number(state.taskHalStatusLoop?.sequence || 0) + 1, stopReason: action.reason || "stopped", lastError: action.error || null, }, operatorMessage: action.operatorMessage || state.operatorMessage, }); break; case "TASK_HAL_PROGRAM_STOPPED": setState(createStoppedProgramStatePatch(state, { reason: action.reason || "stopped", operatorMessage: action.operatorMessage || "task/HAL program stopped", })); break; case "TASK_HAL_COMMAND_FAILED": setState({ taskHalFallbackReason: action.error, taskHalExecutionPending: false, taskHalPauseLock: null, taskHalStatusLoop: { ...state.taskHalStatusLoop, active: false, lastError: action.error, stopReason: "error", }, pendingJogCommand: null, operatorMessage: `task/HAL fallback: ${action.error}`, }); break; case "MACHINE_FILE_STAGING_FAILED": setState({ machineFileStaging: { ...state.machineFileStaging, status: "error", profileId: state.machineProfile, lastError: action.error, }, operatorMessage: `machine file staging error: ${action.error}`, }); break; case "SAVE_SESSION_REQUEST": saveSession().catch(() => {}); break; case "RESTORE_SESSION_REQUEST": restoreSession().catch(() => {}); break; case "STAGE_MACHINE_FILES_REQUEST": stageMachineFiles(action.options || {}).catch(() => {}); break; case "RUN_FULL_BOUNDARY_AUDIT_REQUEST": runFullBoundaryAudit(action.options || {}).catch(() => {}); break; case "RUN_READY": runReadySequence().catch((error) => { dispatch({ type: "TASK_HAL_COMMAND_FAILED", error: error instanceof Error ? error.message : String(error), }); }); break; case "RUN_FROM_OPERATOR": return operatorRunSequence().catch((error) => { dispatch({ type: "TASK_HAL_COMMAND_FAILED", error: error instanceof Error ? error.message : String(error), }); }); case "SET_FRAME_SOURCE": setState({ sourceMode: action.sourceMode, frameSourceMode: action.sourceMode, desiredFrameSourceMode: action.sourceMode, operatorMessage: `frame source ${action.sourceMode}`, }); break; case "REFRESH_KINEMATICS_FRAME": return refreshAsyncKinematicsFrame({ operatorMessage: "LinuxCNC kinematics frame refreshed" }); break; case "GMOCAPY_HARDWARE_BUTTON": { if (action.value === false || action.risingEdge === false) { setState({ operatorMessage: "gmoccapy hardware button falling edge ignored" }); break; } const button = resolveGmoccapyHardwareButton(action); if (!button) { setState({ operatorMessage: `gmoccapy hardware button unmapped: ${action.pin || `${action.location}:${action.index}`}` }); break; } if (!button.webDispatch) { setState({ operatorMessage: `gmoccapy hardware button diagnostic-only: ${button.pin}` }); break; } dispatch(button.webDispatch); } break; case "GMOCAPY_HAL_PIN": { const result = applyGmoccapyHalPinPatch(state, action, gmoccapyHalModel); if (!result.applied) { setState({ operatorMessage: result.operatorMessage }); break; } setState(result.patch); } break; case "GMOCAPY_NATIVE_PAGE": { const patch = applyGmoccapyNativePagePatch(state, action, gmoccapyHalModel); setState(patch); } break; case "GMOCAPY_PAGE_ACTION": { const patch = applyGmoccapyPageActionPatch(state, action, gmoccapyHalModel); setState(patch); } break; case "GMOCAPY_RUN_MACRO": { const result = applyGmoccapyMacroPatch(state, action, gmoccapyHalModel); if (!result.applied) { setState({ operatorMessage: result.operatorMessage }); break; } setState(result.patch); } break; case "GMOCAPY_TOOL_EDITOR_ACTION": { const patch = applyGmoccapyToolEditorPatch(state, action, gmoccapyHalModel); setState(patch); } break; case "TOGGLE_POWER": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const turningOn = state.machine.taskState === "estop-reset"; if (state.taskHalRuntime?.loaded) { const nextMachine = createPowerToggleMachinePatch(state.machine, turningOn); setState({ machine: nextMachine, runState: turningOn ? "idle" : "powered-off", kinsType: turningOn ? state.kinsType : "identity", rtcpState: turningOn ? state.rtcpState : "off", feed: turningOn ? state.feed : { ...state.feed, currentVelocity: 0 }, coolant: turningOn ? state.coolant : { ...state.coolant, flood: false, mist: false }, spindle: turningOn ? state.spindle : stoppedSpindleState(state.spindle), operatorMessage: turningOn ? "task/HAL machine power on" : "task/HAL machine power off", }); runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: turningOn ? "ON" : "OFF" }, ], { operatorMessage: turningOn ? "task/HAL machine power on" : "task/HAL machine power off" }).catch(() => {}); break; } const nextMachine = createPowerToggleMachinePatch(state.machine, turningOn); setState({ machine: nextMachine, runState: turningOn ? "idle" : "powered-off", kinsType: turningOn ? state.kinsType : "identity", rtcpState: turningOn ? state.rtcpState : "off", feed: turningOn ? state.feed : { ...state.feed, currentVelocity: 0 }, coolant: turningOn ? state.coolant : { ...state.coolant, flood: false, mist: false }, spindle: turningOn ? state.spindle : stoppedSpindleState(state.spindle), operatorMessage: turningOn ? "machine power on" : "machine power off", }); } break; case "ESTOP": setState({ machine: { ...state.machine, powerOn: false, estopActive: true, motionEnabled: false, taskState: "estop", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: "estopped", taskHalPauseLock: null, kinsType: "identity", rtcpState: "off", feed: { ...state.feed, currentVelocity: 0, }, coolant: { ...state.coolant, flood: false, mist: false, }, spindle: { ...stoppedSpindleState(state.spindle), }, programRuntimeFeedback: zeroProgramRuntimeVelocity(state.programRuntimeFeedback), operatorMessage: "emergency stop active", }); if (state.taskHalRuntime?.loaded) { runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: "ESTOP" }, ], { operatorMessage: "task/HAL emergency stop active", preserveMachine: { ...state.machine, powerOn: false, estopActive: true, motionEnabled: false, taskState: "estop", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, }).catch(() => {}); } break; case "RESET": setState({ machine: { ...state.machine, powerOn: false, estopActive: false, motionEnabled: false, taskState: "estop-reset", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, resetCount: state.machine.resetCount + 1, }, runState: "idle", taskHalPauseLock: null, kinsType: "identity", rtcpState: "off", coolant: { ...state.coolant, flood: false, mist: false, }, spindle: { ...stoppedSpindleState(state.spindle), }, programRuntimeFeedback: zeroProgramRuntimeVelocity(state.programRuntimeFeedback), operatorMessage: "estop reset; machine off", }); if (state.taskHalRuntime?.loaded) { runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: "ESTOP_RESET" }, ], { operatorMessage: "task/HAL estop reset; machine off", preserveMachine: { ...state.machine, powerOn: false, estopActive: false, motionEnabled: false, taskState: "estop-reset", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, }).catch(() => {}); } break; case "SET_MODE": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const requestedMode = String(action.mode || ""); const mode = normalizeLinuxCncTaskMode(requestedMode); const manualPanel = requestedMode === "jog" ? "jog" : mode === "manual" ? "manual" : null; if (state.taskHalRuntime?.loaded) { const manualModePatch = mode === "manual" ? { interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, } : {}; const requestedMachine = { ...state.machine, mode, manualPanel, ...manualModePatch, }; setState({ machine: requestedMachine, operatorMessage: `task/HAL mode ${mode} requested`, }); runTaskHalCommandSequence([ { type: "EMC_TASK_SET_MODE", mode: mode.toUpperCase() }, ], { operatorMessage: `task/HAL mode ${mode}`, preserveMachine: requestedMachine, }).catch(() => {}); break; } setState({ machine: { ...state.machine, mode, manualPanel, interpState: mode === "manual" ? "idle" : state.machine.interpState, interpResumeState: mode === "manual" ? "idle" : state.machine.interpResumeState, taskPaused: mode === "manual" ? false : state.machine.taskPaused, motionPaused: mode === "manual" ? false : state.machine.motionPaused, singleStepping: mode === "manual" ? false : state.machine.singleStepping, motionStepping: mode === "manual" ? false : state.machine.motionStepping, }, runState: mode === "manual" && state.runState === "running" ? "stopped" : state.runState, operatorMessage: `mode ${mode}`, }); } break; case "SET_MDI_COMMAND": setState({ machine: { ...state.machine, mdiCommand: String(action.command ?? ""), }, operatorMessage: "MDI command staged", }); break; case "SET_ACTIVE_JOINT": { const joint = Math.min(Math.max(Number(action.joint) || 0, 0), Math.max((state.profile?.joints?.length || 5) - 1, 0)); const axis = (state.profile?.jointConfig?.[joint]?.axis || ["X", "Y", "Z", "B", "C"][joint] || "X").toLowerCase(); setState({ machine: { ...state.machine, selectedJoint: joint, jogAxis: axis, }, operatorMessage: `joint ${joint} selected`, }); } break; case "SET_JOG_INCREMENT": { const increment = Math.max(Number(action.increment) || 0, 0); setState({ machine: { ...state.machine, jogIncrement: increment, }, gmoccapyGui: { ...state.gmoccapyGui, jogIncrementLabel: increment === 0 ? "Continuous" : increment.toFixed(4), jogIncrementOutput: increment, }, operatorMessage: increment === 0 ? "jog continuous" : `jog increment ${increment}`, }); } break; case "JOG": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const axis = action.axis || state.machine.jogAxis; const direction = Number(action.direction || 1); const increment = Number(action.increment || state.machine.jogIncrement); if (state.taskHalRuntime?.loaded) { const nextAxisPose = clampAxisPoseToProfile({ ...state.axisPose, [axis]: Number(state.axisPose[axis] || 0) + direction * increment, }, state.profile); const pendingJogCommand = { axis, direction, increment, basePose: { ...state.axisPose }, createdAtLine: state.activeLine, }; setState({ machine: { ...state.machine, mode: "manual", manualPanel: "jog", jogAxis: axis, jogIncrement: increment, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, axisPose: nextAxisPose, runState: "jogging", pendingJogCommand, operatorMessage: `task/HAL jog ${axis.toUpperCase()} ${direction > 0 ? "+" : "-"}${increment}`, }); runTaskHalCommandSequence([ { type: "EMC_JOG_INCR", axis: axis.toUpperCase(), distance: direction * increment, velocity: Number(action.velocity || 60), }, ], { pendingJogCommand, preserveAxisPose: nextAxisPose, preserveMachine: { ...state.machine, mode: "manual", manualPanel: "jog", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, operatorMessage: `task/HAL jog ${axis.toUpperCase()} ${direction > 0 ? "+" : "-"}${increment}`, }).catch(() => {}); break; } setState({ machine: { ...state.machine, mode: "manual", manualPanel: "jog", jogAxis: axis, jogIncrement: increment, }, axisPose: { ...state.axisPose, [axis]: Number(state.axisPose[axis] || 0) + direction * increment, }, runState: "jogging", operatorMessage: `jog ${axis.toUpperCase()} ${direction > 0 ? "+" : "-"}${increment}`, }); } break; case "RUN_MDI": { const command = normalizeMdiCommand(action.command ?? state.machine.mdiCommand); const gate = gateLinuxCncTaskAction(state, { ...action, command }); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const preserveManualTouchOff = action.manualTouchOff === true && state.machine.mode === "manual"; if (state.taskHalRuntime?.loaded) { const mdiResult = executeMdiCommand(state, command); const mdiPatch = preserveManualTouchOff ? createManualTouchOffMdiPatch(state, mdiResult.patch, command) : mdiResult.patch; setState(mdiPatch); runTaskHalCommandSequence([ { type: "EMC_TASK_SET_MODE", mode: "MDI" }, { type: "EMC_TASK_PLAN_EXECUTE", mdi: command }, ], { operatorMessage: `task/HAL MDI ${command}`, preserveAxisPose: mdiPatch.axisPose, preserveMachine: mdiPatch.machine, }).catch(() => {}); break; } const mdiResult = executeMdiCommand(state, command); setState(preserveManualTouchOff ? createManualTouchOffMdiPatch(state, mdiResult.patch, command) : mdiResult.patch); } break; case "LOAD_PROGRAM": { const loadedProgram = buildLoadedProgram(action); const toolCommands = extractToolCommandSequenceFromProgram(loadedProgram.programLines.join("\n")); const toolDbSimulation = state.toolDbSimulation && toolCommands.length > 0 ? applyToolCommandSequence(state.toolDbSimulation, toolCommands) : state.toolDbSimulation; const toolRuntimeState = createToolRuntimeState(toolDbSimulation, { fallbackToolLength: state.toolPreview?.length, }); const axisPreviewPath = buildProgramAxisPathFromProgram({ filename: loadedProgram.activeProgram, sourceRel: loadedProgram.programSourceRel, content: action.content, tool: currentPathTool({ ...state, toolRuntimeState, }), }); setState({ ...loadedProgram, toolDbSimulation, toolDbReadiness: createToolDbReadiness(toolDbSimulation), toolRuntimeState, machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, axisPose: initialAxisPose, runState: "idle", programAxisPreviewPath: axisPreviewPath, programRuntimeFeedback: null, programLineExecution: {}, preview: { ...state.preview, pathPoints: axisPreviewPath?.sampleCount || Math.max(loadedProgram.programLines.length, 1), }, operatorMessage: `loaded ${loadedProgram.activeProgram}`, }); if (state.interpreterRuntime?.loaded) { dispatch({ type: "RUN_INTERPRETER_PROGRAM" }); } } break; case "RUN": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { const preconditions = validateRunPreconditions(state, { requireTaskHalSession: false }); if (!preconditions.ok) { setState({ operatorMessage: preconditions.operatorMessage }); break; } setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, taskHalPauseLock: null, operatorMessage: "task/HAL program run requested; waiting for status", }); runValidatedTaskHalProgramRun().catch(() => {}); break; } const playback = nextProgramRuntimeSamplePlayback(state, 5); const playbackPatch = applyProgramPlaybackUiPatch(state, { activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, motionIndex: playback.motionIndex, sampleIndex: playback.sampleIndex, runtimeFeedback: playback.runtimeFeedback, }); setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: playback.complete ? "idle" : "reading", interpResumeState: playback.complete ? "idle" : "reading", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: playback.complete ? "complete" : "running", taskHalPauseLock: null, ...playbackPatch, programLineExecution: createProgramLineExecutionPatch(state.programLineExecution, playback.runtimeFeedback, { status: playback.complete ? "done" : "running", source: playback.runtimeFeedback?.sourceMode, }), programElapsedSeconds: playback.timing.elapsedSeconds, programRemainingSeconds: playback.timing.remainingSeconds, feed: { ...state.feed, currentVelocity: playback.timing.currentVelocity, }, operatorMessage: `executing line ${playback.activeLine}`, }); } break; case "STOP": case "ABORT": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { stopTaskHalStatusLoop(action.type === "ABORT" ? "aborted" : "stopped", { operatorMessage: action.type === "ABORT" ? "task/HAL abort requested" : "task/HAL stop requested", }); setState(createStoppedProgramStatePatch(state, { reason: action.type === "ABORT" ? "aborted" : "stopped", operatorMessage: action.type === "ABORT" ? "task abort requested" : "program stop requested", })); runTaskHalCommandSequence([ { type: "EMC_TASK_ABORT" }, ], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped", preserveMachine: { ...state.machine, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, }) .then(() => { dispatch({ type: "TASK_HAL_PROGRAM_STOPPED", reason: action.type === "ABORT" ? "aborted" : "stopped", operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped", }); }) .catch(() => {}); break; } setState({ machine: { ...state.machine, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: "stopped", taskHalPauseLock: null, feed: { ...state.feed, currentVelocity: 0, }, operatorMessage: action.type === "ABORT" ? "task abort complete" : "program stopped", }); } break; case "SET_SPINDLE_DIRECTION": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const direction = normalizeSpindleDirection(action.direction); const spindleEnabled = direction !== "stop"; const spindleActualRpm = spindleEnabled ? Number(state.spindle.rpm || 0) * (Number(state.spindle.override || 100) / 100) : 0; setState({ spindle: { ...state.spindle, enabled: spindleEnabled, direction, halPins: { on: spindleEnabled ? 1 : 0, forward: direction === "forward" ? 1 : 0, reverse: direction === "reverse" ? 1 : 0, speedOut: spindleActualRpm, atSpeed: spindleEnabled ? 1 : 0, }, }, operatorMessage: direction === "stop" ? "spindle stopped" : `spindle ${direction}`, }); } break; case "PAUSE": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { stopTaskHalStatusLoop("paused", { operatorMessage: "task/HAL pause requested" }); const pausedMachine = { ...state.machine, mode: programControlModeForMachine(state.machine), manualPanel: null, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState : state.machine.interpState || "reading", interpState: "paused", taskPaused: true, motionPaused: true, singleStepping: false, motionStepping: false, }; const pauseLock = createTaskHalPauseLock(state, "task-hal-plan-pause"); setState({ machine: pausedMachine, runState: "paused", taskHalPauseLock: pauseLock, feed: { ...state.feed, currentVelocity: 0, }, programRuntimeFeedback: zeroProgramRuntimeVelocity(state.programRuntimeFeedback), operatorMessage: "task/HAL pause requested", }); runTaskHalCommandSequence([ { type: "EMC_TASK_PLAN_PAUSE" }, ], { operatorMessage: "task/HAL program paused", }).then((status) => { if (isTaskHalStatusPaused(status)) { startTaskHalStatusLoop({ batchSize: 1, operatorMessage: "task/HAL paused status monitor running", }); } }).catch(() => {}); break; } const pauseLock = createTaskHalPauseLock(state, "fixture-plan-pause"); setState({ machine: { ...state.machine, mode: programControlModeForMachine(state.machine), manualPanel: null, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState : state.machine.interpState, interpState: "paused", taskPaused: true, motionPaused: true, singleStepping: false, motionStepping: false, }, runState: "paused", taskHalPauseLock: pauseLock, feed: { ...state.feed, currentVelocity: 0, }, programRuntimeFeedback: zeroProgramRuntimeVelocity(state.programRuntimeFeedback), operatorMessage: "program paused", }); } break; case "PAUSE_RESUME": { const interpState = state.machine?.interpState || state.linuxCncTaskPolicy?.interpState || "idle"; const taskMode = normalizeLinuxCncTaskMode(state.machine?.mode); if (taskMode !== "auto" && taskMode !== "mdi") { setState({ operatorMessage: "pause blocked: task mode must be auto or MDI" }); break; } if (state.machine?.motionPaused === true || state.machine?.taskPaused === true || interpState === "paused" || state.runState === "paused") { dispatch({ type: "RESUME", source: "pauseresume" }); break; } if (interpState !== "idle") { dispatch({ type: "PAUSE", source: "pauseresume" }); break; } setState({ operatorMessage: "pause ignored: interpreter is idle" }); } break; case "RESUME": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { const resumeState = state.machine.interpResumeState === "idle" || state.machine.interpResumeState === "paused" ? "reading" : state.machine.interpResumeState || "reading"; const resumedMachine = { ...state.machine, mode: programControlModeForMachine(state.machine), manualPanel: null, interpState: resumeState, interpResumeState: resumeState, taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }; setState({ machine: resumedMachine, runState: resumeState === "reading" || resumeState === "waiting" ? "running" : "idle", taskHalPauseLock: null, operatorMessage: "task/HAL resume requested", }); runTaskHalCommandSequence([ { type: "EMC_TASK_PLAN_RESUME" }, ], { operatorMessage: "task/HAL program resumed", }).then((status) => { if (shouldContinueTaskHalStatusLoop(state, status)) { startTaskHalStatusLoop({ operatorMessage: "task/HAL status loop resumed", }); } }).catch(() => {}); break; } const resumeState = state.machine.interpResumeState === "idle" ? "reading" : state.machine.interpResumeState; setState({ machine: { ...state.machine, mode: programControlModeForMachine(state.machine), manualPanel: null, interpState: resumeState, interpResumeState: resumeState, taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: resumeState === "reading" || resumeState === "waiting" ? "running" : "idle", taskHalPauseLock: null, operatorMessage: "program resumed", }); } break; case "STEP": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { stopTaskHalStatusLoop("step", { operatorMessage: "task/HAL step requested" }); const playback = nextProgramRuntimeSamplePlayback(state, 1); const playbackPatch = applyProgramPlaybackUiPatch(state, { activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, motionIndex: playback.motionIndex, sampleIndex: playback.sampleIndex, runtimeFeedback: playback.runtimeFeedback, }); const steppedMachine = { ...state.machine, mode: "auto", manualPanel: null, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState || "reading" : state.machine.interpState || "reading", interpState: "paused", taskPaused: true, motionPaused: true, singleStepping: true, motionStepping: true, }; setState({ machine: steppedMachine, runState: "stepping", taskHalPauseLock: null, ...playbackPatch, programElapsedSeconds: playback.timing.elapsedSeconds, programRemainingSeconds: playback.timing.remainingSeconds, feed: { ...state.feed, currentVelocity: playback.timing.currentVelocity, }, operatorMessage: `task/HAL step requested line ${playback.activeLine}`, }); runTaskHalCommandSequence([ { type: "EMC_TASK_PLAN_STEP" }, ], { taskCycles: 1, operatorMessage: "task/HAL stepped one cycle", preserveMachine: steppedMachine, }).catch(() => {}); break; } const playback = nextProgramRuntimeSamplePlayback(state, 1); const playbackPatch = applyProgramPlaybackUiPatch(state, { activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, motionIndex: playback.motionIndex, sampleIndex: playback.sampleIndex, runtimeFeedback: playback.runtimeFeedback, }); setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState : state.machine.interpState, interpState: "paused", taskPaused: true, motionPaused: true, singleStepping: true, motionStepping: true, }, runState: "stepping", taskHalPauseLock: null, ...playbackPatch, programElapsedSeconds: playback.timing.elapsedSeconds, programRemainingSeconds: playback.timing.remainingSeconds, feed: { ...state.feed, currentVelocity: playback.timing.currentVelocity, }, operatorMessage: `stepped to line ${playback.activeLine}`, }); } break; case "RUN_FRAME": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const playback = nextProgramRuntimeSamplePlayback(state, 5); const playbackPatch = applyProgramPlaybackUiPatch(state, { activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, motionIndex: playback.motionIndex, sampleIndex: playback.sampleIndex, runtimeFeedback: playback.runtimeFeedback, }); setState({ machine: { ...state.machine, interpState: playback.complete ? "idle" : "reading", interpResumeState: playback.complete ? "idle" : "reading", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: "running", ...playbackPatch, programElapsedSeconds: playback.timing.elapsedSeconds, programRemainingSeconds: playback.timing.remainingSeconds, feed: { ...state.feed, currentVelocity: playback.timing.currentVelocity, }, }); } break; case "HOME": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } const homeAxisPose = homeAxisPoseForState(state); const homedFalse = createHomedArrayForState(state, false); const homedTrue = createHomedArrayForState(state, true); if (state.taskHalRuntime?.loaded) { setState({ machine: { ...state.machine, mode: "manual", manualPanel: "manual", allHomed: false, homed: homedFalse, homing: true, homeState: "homing", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: "idle", axisPose: homeAxisPose, programRuntimeFeedback: null, operatorMessage: "task/HAL home requested", }); runTaskHalCommandSequence([ { type: "EMC_JOINT_HOME", joint: -1 }, ], { operatorMessage: "task/HAL machine homed", preserveAxisPose: homeAxisPose, preserveMachine: { manualPanel: "manual", allHomed: true, homed: homedTrue, homing: false, homeState: "homed", }, }).catch(() => {}); break; } setState({ machine: { ...state.machine, mode: "manual", manualPanel: "manual", allHomed: true, homed: homedTrue, homing: false, homeState: "homed", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: "idle", axisPose: homeAxisPose, programRuntimeFeedback: null, operatorMessage: "machine homed to fixture origin", }); } break; case "UNHOME": setState({ machine: { ...state.machine, allHomed: false, homed: createHomedArrayForState(state, false), homing: false, homeState: "unhomed", }, operatorMessage: "machine unhomed", }); break; case "SET_RTCP": if (action.enabled && !profileSupportsTcp(state.profile)) { setState({ kinsType: "identity", rtcpState: "off", operatorMessage: `RTCP blocked: ${state.profile.id} is ${state.profile.kinematics} reference only`, }); break; } { const requestedKinsType = action.enabled ? tcpKinsTypeForProfile(state.profile) : "identity"; const gate = gateKinsTypeChange(state, requestedKinsType); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } setState({ kinsType: requestedKinsType, rtcpState: action.enabled ? "on" : "off", }); } break; case "RESET_VIEW": setState({ preview: { ...state.preview, selectedView: "iso", cameraRevision: (state.preview.cameraRevision ?? 0) + 1, }, operatorMessage: "preview fit to program", }); break; case "CLEAR_PREVIEW": setState({ preview: { ...state.preview, pathPoints: 0 }, operatorMessage: "preview path cleared", }); break; case "SET_VIEW": setState({ preview: { ...state.preview, selectedView: action.view, cameraRevision: (state.preview.cameraRevision ?? 0) + 1, }, operatorMessage: `preview view ${action.view}`, }); break; case "TOGGLE_FULLSCREEN": setState({ preview: { ...state.preview, fullscreen: !state.preview.fullscreen }, operatorMessage: state.preview.fullscreen ? "fullscreen preview off" : "fullscreen preview on", }); break; case "SET_KINS_TYPE": { const gate = gateKinsTypeChange(state, action.kinsType); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } setState({ kinsType: action.kinsType, rtcpState: action.kinsType.startsWith("tcp-") ? "on" : "off", operatorMessage: `kinematics ${action.kinsType}`, }); break; } case "ADJUST_OVERRIDE": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } setState({ feed: { ...state.feed, [`${action.target}Override`]: clampPercent( state.feed[`${action.target}Override`] + action.delta, 0, 200, ), }, gmoccapyGui: { ...state.gmoccapyGui, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: `${action.target} override manual step`, }, operatorMessage: `${action.target} override adjusted`, }); } break; case "ADJUST_SPINDLE_OVERRIDE": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } setState({ spindle: { ...state.spindle, override: clampPercent(state.spindle.override + action.delta, 0, 150), halPins: spindleHalPinsForState({ ...state.spindle, override: clampPercent(state.spindle.override + action.delta, 0, 150), }), }, gmoccapyGui: { ...state.gmoccapyGui, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "spindle override manual step", }, operatorMessage: "spindle override adjusted", }); } break; case "RESET_OVERRIDE": setState(resetOverridePatchForTarget(state, action.target)); break; case "SET_BLOCK_DELETE": setState({ gmoccapyGui: { ...state.gmoccapyGui, optionalBlocks: Boolean(action.enabled), lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "block delete set from Web optional blocks button", }, operatorMessage: `block delete ${action.enabled ? "on" : "off"}`, }); break; case "SET_OPTIONAL_STOP": setState({ gmoccapyGui: { ...state.gmoccapyGui, optionalStop: Boolean(action.enabled), lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "optional stop set from Web control", }, operatorMessage: `optional stop ${action.enabled ? "on" : "off"}`, }); break; case "SET_IGNORE_LIMITS": setState({ gmoccapyGui: { ...state.gmoccapyGui, ignoreLimits: Boolean(action.enabled), lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "ignore limits set from Web checkbox", }, operatorMessage: action.enabled ? "limit override requested" : "limit override clear", }); break; case "TOGGLE_COOLANT": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } setState({ coolant: { ...state.coolant, [action.kind]: !state.coolant[action.kind], }, operatorMessage: `${action.kind} coolant toggled`, }); } break; case "RELOAD_PROGRAM": setState({ machine: { ...state.machine, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "idle", activeLine: state.programStartLine === 496 ? 501 : state.programStartLine, programExecutionMotionIndex: 0, programExecutionSampleIndex: 0, programRuntimeFeedback: null, programLineExecution: {}, axisPose: initialAxisPose, preview: { ...state.preview, pathPoints: Math.max(state.programLines.length, 1) }, operatorMessage: "program reloaded", }); break; default: throw new Error(`Unknown action type: ${action.type}`); } }; const refreshAsyncKinematicsFrame = async ({ operatorMessage = state.operatorMessage } = {}) => { if (state.taskHalPauseLock?.active === true || isTaskHalRunPausedByOperator(state)) { return state.rtcpFrame; } if (state.desiredFrameSourceMode !== "source-derived-kinematics-wasm") { return state.rtcpFrame; } if (!state.kinematicsRuntime?.loaded || !isAsyncKinematicsRuntime(state.kinematicsRuntime)) { return state.rtcpFrame; } const sequence = state.asyncFrameRefreshSequence + 1; state = { ...state, asyncFrameRefreshPending: true, asyncFrameRefreshSequence: sequence, }; notify(); try { await switchKinematicsRuntimeForState(state); const frameSource = await state.kinematicsRuntime.frameForJoints( jointsFromAxisPose(state.axisPose, state.profile), { jointCount: state.kinematicsRuntime.jointCount || 5 }, ); if (state.asyncFrameRefreshSequence !== sequence) { return state.rtcpFrame; } if (state.taskHalPauseLock?.active === true || isTaskHalRunPausedByOperator(state)) { state = { ...state, asyncFrameRefreshPending: false, }; notify(); return state.rtcpFrame; } const frame = buildRtcpFrame({ axisPose: state.axisPose, activeLine: state.activeLine, kinsType: state.kinsType, rtcpEnabled: state.rtcpState === "on" || state.kinsType.startsWith("tcp-"), sourceMode: "source-derived-kinematics-wasm", profile: state.profile, linuxCncKinematicsResult: frameSource, }); const nextState = { ...state, sourceMode: "source-derived-kinematics-wasm", frameSourceMode: "source-derived-kinematics-wasm", desiredFrameSourceMode: "source-derived-kinematics-wasm", axisPose: frame.axisPose, jointPose: frame.jointPose, tcpPose: frame.tcpPose, toolAxisVector: frame.toolAxisVector, rtcpState: frame.rtcpState, rtcpFrame: frame, lastKinematicsResult: frameSource, dro: buildDroFromFrame(frame, state.programRuntimeFeedback), asyncFrameRefreshPending: false, operatorMessage, }; const taskPolicy = createLinuxCncTaskPolicyStatus(nextState); const derivedState = { ...nextState, linuxCncTaskPolicy: taskPolicy, machineProject: createMachineProjectState(nextState), programValidation: createProgramValidationState(nextState), rightSidebarEntrances: createRightSidebarEntranceState(nextState, taskPolicy), }; state = { ...derivedState, linuxCncParityMatrix: createLinuxCncParityMatrix(derivedState), linuxCncProcessMonitor: createLinuxCncProcessMonitor(derivedState), fullExecutionBoundary: createFullLinuxCncExecutionBoundary(derivedState), }; notify(); return frame; } catch (error) { if (state.asyncFrameRefreshSequence !== sequence) { return state.rtcpFrame; } state = { ...state, asyncFrameRefreshPending: false, operatorMessage: `LinuxCNC kinematics refresh failed: ${error instanceof Error ? error.message : String(error)}`, }; notify(); return state.rtcpFrame; } }; const saveSession = async (options = {}) => { dispatch({ type: "SESSION_SAVE_STARTED" }); try { const storageOptions = withDefaultWebOpfsRequirement(options); const sessionId = options.sessionId || state.sessionPersistence.sessionId; const filename = options.filename || state.sessionPersistence.filename; const payload = createFiveAxisSessionPayload(state); const { snapshot, path, storageMode, storageCapability } = await saveFiveAxisSessionSnapshot(sessionId, payload, { filename, storage: storageOptions.storage, storageMode: storageOptions.storageMode, requireOpfs: storageOptions.requireOpfs, metadata: options.metadata, }); dispatch({ type: "SESSION_SAVE_COMPLETE", path, storageMode, storageCapability, savedAt: snapshot.createdAt, }); return { snapshot, path, storageMode, storageCapability }; } catch (error) { dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message }); throw error; } }; const restoreSession = async (options = {}) => { dispatch({ type: "SESSION_RESTORE_STARTED" }); try { const storageOptions = withDefaultWebOpfsRequirement(options); const sessionId = options.sessionId || state.sessionPersistence.sessionId; const filename = options.filename || state.sessionPersistence.filename; const { snapshot, path, storageMode, storageCapability } = await loadFiveAxisSessionSnapshot(sessionId, { filename, storage: storageOptions.storage, storageMode: storageOptions.storageMode, requireOpfs: storageOptions.requireOpfs, }); dispatch({ type: "SESSION_RESTORE_COMPLETE", restoredState: restoreFiveAxisSessionState(snapshot), path, storageMode, storageCapability, restoredAt: new Date().toISOString(), }); await refreshAsyncKinematicsFrame({ operatorMessage: `5-axis session restored ${path}` }); return { snapshot, path, storageMode, storageCapability }; } catch (error) { dispatch({ type: "SESSION_PERSISTENCE_FAILED", error: error.message }); throw error; } }; const stageMachineFiles = async (options = {}) => { dispatch({ type: "MACHINE_FILE_STAGING_STARTED" }); try { const storageOptions = withDefaultWebOpfsRequirement(options); const { plan, save } = await stageProfileMachineFiles(state.profile, { ...options, ...storageOptions, iniText: options.iniText || state.linuxCncIniConfig?.sourceText, }); dispatch({ type: "MACHINE_FILE_STAGING_COMPLETE", plan, save }); return { plan, save }; } catch (error) { dispatch({ type: "MACHINE_FILE_STAGING_FAILED", error: error instanceof Error ? error.message : String(error), }); throw error; } }; const queryToolDb = (selector = {}) => { if (!state.toolDbSimulation) return null; if (selector.all === true || selector.list === true) { return listToolEntries(state.toolDbSimulation); } return queryToolEntry(state.toolDbSimulation, selector); }; const editToolDb = (patch = {}) => { if (!state.toolDbSimulation) { throw new Error("tool DB simulation is not ready"); } const toolDbSimulation = editToolEntry(state.toolDbSimulation, patch); setState({ toolDbSimulation, toolDbReadiness: createToolDbReadiness(toolDbSimulation), toolRuntimeState: createToolRuntimeState(toolDbSimulation, { fallbackToolLength: state.toolPreview?.length, }), operatorMessage: `tool DB edited T${patch.toolNumber ?? patch.toolno ?? "-"}`, }); return state.toolDbSimulation; }; const saveToolDb = async (options = {}) => { if (!state.toolDbSimulation) { throw new Error("tool DB simulation is not ready"); } const save = await saveToolDbSimulation(state.toolDbSimulation, withDefaultWebOpfsRequirement(options)); setState({ toolDbSimulation: save.toolDb, toolDbReadiness: createToolDbReadiness(save.toolDb), toolRuntimeState: createToolRuntimeState(save.toolDb, { fallbackToolLength: state.toolPreview?.length, }), operatorMessage: `tool DB saved ${save.path} (${save.storageMode})`, }); return save; }; const runFullBoundaryAudit = async (options = {}) => { if (state.machineFileStaging?.status !== "staged" || options.restage === true) { await stageMachineFiles(options); } if (!state.machineFileStaging.selectedGcodeSourceRel) { const sourceRel = options.sourceRel || defaultLinuxCncGcodeSourceForState(state)?.sourceRel; if (sourceRel) { dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel }); await Promise.resolve(); } } dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" }); }; const initializeTaskHalSession = async ({ openProgram = true } = {}) => { if (!state.taskHalRuntime?.loaded || !state.machineFileStaging?.save?.files?.length) { return null; } stopTaskHalStatusLoop("session-initialize", { notify: false }); const preserveMachine = { powerOn: state.machine.powerOn, estopActive: state.machine.estopActive, taskState: state.machine.taskState, mode: state.machine.mode, allHomed: state.machine.allHomed, }; const selectedPlan = selectMachineFileProgramForState(state); const session = buildTaskHalSessionFromMachineFiles({ profile: state.profile, plan: selectedPlan, save: state.machineFileStaging.save, selectedProgramRel: state.machineFileStaging.selectedGcodeSourceRel, }); if (session.programSourceRel && session.programSourceRel !== state.machineFileStaging.selectedGcodeSourceRel) { return null; } await state.taskHalRuntime.resetSession?.(); await state.taskHalRuntime.initSession({ profileId: session.profileId, iniPath: session.iniPath, iniText: session.iniText, programPath: session.programPath, semanticBoundary: session.semanticBoundary, }); await state.taskHalRuntime.stageFiles(session.files); if (openProgram && session.programPath) { await state.taskHalRuntime.openProgram(session.programPath); await loadTaskHalMotionPlanForSession(session); } if (preserveMachine.powerOn) { await state.taskHalRuntime.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" }); } if (preserveMachine.allHomed) { await state.taskHalRuntime.sendCommand({ type: "EMC_JOINT_HOME", joint: -1 }); } await state.taskHalRuntime.sendCommand({ type: "EMC_TASK_SET_MODE", mode: normalizeLinuxCncTaskMode(preserveMachine.mode).toUpperCase(), }); await state.taskHalRuntime.runCycles({ ...deriveTaskHalCyclePeriods(state), taskCycles: 1, }); if (session.programSourceRel && session.programSourceRel !== state.machineFileStaging.selectedGcodeSourceRel) { return null; } dispatch({ type: "TASK_HAL_SESSION_READY", session }); const status = await state.taskHalRuntime.readStatus(); const statusPreserveMachine = isTaskHalRunPausedByOperator(state) ? { ...state.machine } : preserveMachine; dispatch({ type: "TASK_HAL_STATUS_APPLIED", status, preserveMachine: statusPreserveMachine, operatorMessage: `LinuxCNC task/HAL session ready ${session.programPath || "-"}`, }); return session; }; const runValidatedTaskHalProgramRun = async () => { const preflight = validateRunPreconditions(state, { requireTaskHalSession: false }); if (!preflight.ok) { setState({ operatorMessage: preflight.operatorMessage }); return null; } const expectedProgramPath = expectedTaskHalProgramPathForState(state); if (!state.taskHalSession || (expectedProgramPath && state.taskHalSession.programPath !== expectedProgramPath)) { await initializeTaskHalSession({ openProgram: true }); if (isTaskHalRunPausedByOperator(state)) { return null; } } const loadedMotionPlan = await loadTaskHalMotionPlanForSession(state.taskHalSession); if (isTaskHalRunPausedByOperator(state)) { return null; } if (!loadedMotionPlan) { setState({ operatorMessage: "run blocked: task/HAL feed motion plan not loaded" }); return null; } const ready = validateRunPreconditions(state, { requireTaskHalSession: true }); if (!ready.ok) { setState({ operatorMessage: ready.operatorMessage }); return null; } if (isTaskHalRunPausedByOperator(state)) { return null; } stopTaskHalStatusLoop("restarted", { operatorMessage: "task/HAL status loop restarting", }); if (isTaskHalRunPausedByOperator(state)) { return null; } const status = await runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: "ON" }, { type: "EMC_TASK_SET_MODE", mode: "AUTO" }, { type: "EMC_TASK_PLAN_RUN", line: 0 }, ], { taskCycles: 5, operatorMessage: `task/HAL program run ${ready.profileId} ${ready.kinematicsModuleId}`, allowFixtureSession: false, }); if (isTaskHalRunPausedByOperator(state)) { return status; } if (shouldContinueTaskHalStatusLoop(state, status)) { startTaskHalStatusLoop({ profileId: ready.profileId, iniPath: ready.iniPath, kinematicsModuleId: ready.kinematicsModuleId, operatorMessage: `task/HAL status loop running ${ready.profileId} ${ready.kinematicsModuleId}`, }); } return status; }; const runReadySequence = async () => { if (!state.machineFileStaging?.selectedGcodeSourceRel) { const sourceRel = defaultLinuxCncGcodeSourceForState(state)?.sourceRel; if (sourceRel) { dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel }); await waitForStatePredicate((nextState) => nextState.machineFileStaging?.selectedGcodeSourceRel === sourceRel); } } if (state.taskHalRuntime?.loaded) { await initializeTaskHalSession({ openProgram: true }); setState({ operatorMessage: state.machine.powerOn && state.machine.allHomed ? "RUN ready: program opened; press Run" : "RUN setup ready: reset ESTOP, power on, Home All, then Run", }); return state; } setState({ operatorMessage: state.machine.powerOn && state.machine.allHomed ? "RUN ready: program opened; press Run" : "RUN setup ready: reset ESTOP, power on, Home All, then Run", }); return state; }; const operatorRunSequence = async () => { if (!state.machineFileStaging?.selectedGcodeSourceRel) { const sourceRel = defaultLinuxCncGcodeSourceForState(state)?.sourceRel; if (sourceRel) { dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel }); await waitForStatePredicate((nextState) => nextState.machineFileStaging?.selectedGcodeSourceRel === sourceRel); } } const runGateState = { ...state, machine: { ...state.machine, mode: "auto", manualPanel: null, }, }; const runGate = gateLinuxCncTaskAction(runGateState, { type: "RUN" }); if (!runGate.allowed) { setState({ operatorMessage: runGate.operatorMessage }); return state; } const tcpKinsType = tcpKinsTypeForProfile(state.profile); if (state.taskHalRuntime?.loaded) { if ( !Array.isArray(state.programExecution?.motion) || state.programExecution.motion.length === 0 || state.interpreterExecutionPending ) { if (!state.interpreterExecutionPending) { dispatch({ type: "RUN_MACHINE_FILE_PROGRAM" }); } await waitForStatePolling((nextState) => ( nextState.interpreterExecutionPending === false && Array.isArray(nextState.programExecution?.motion) && nextState.programExecution.motion.length > 0 ), 15000); } if (tcpKinsType && state.kinsType !== tcpKinsType) { dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType }); } setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, taskHalPauseLock: null, operatorMessage: "RUN ready: motion plan source ready; preparing task/HAL session", }); const expectedProgramPath = expectedTaskHalProgramPathForState(state); if (!state.taskHalSession || (expectedProgramPath && state.taskHalSession.programPath !== expectedProgramPath)) { setState({ operatorMessage: "RUN preparing: initializing task/HAL session" }); await initializeTaskHalSession({ openProgram: true }); if (isTaskHalRunPausedByOperator(state)) { return state; } } setState({ operatorMessage: "RUN preparing: loading task/HAL motion plan" }); const loadedMotionPlan = await loadTaskHalMotionPlanWithSessionRetry(); if (isTaskHalRunPausedByOperator(state)) { return state; } if (!loadedMotionPlan) { setState({ operatorMessage: "run blocked: task/HAL feed motion plan not loaded" }); return state; } const ready = validateRunPreconditions(state, { requireTaskHalSession: true }); if (!ready.ok) { setState({ operatorMessage: ready.operatorMessage }); return state; } if (isTaskHalRunPausedByOperator(state)) { return state; } stopTaskHalStatusLoop("restarted", { operatorMessage: "task/HAL status loop restarting", }); if (isTaskHalRunPausedByOperator(state)) { return state; } setState({ activeLine: state.programStartLine || 1, programExecutionMotionIndex: 0, programExecutionSampleIndex: 0, programRuntimeFeedback: null, programLineExecution: {}, operatorMessage: "RUN executing: sending task/HAL PLAN_RUN", }); const status = await runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: "ON" }, { type: "EMC_TASK_SET_MODE", mode: "AUTO" }, { type: "EMC_TASK_PLAN_RUN", line: 0 }, ], { taskCycles: 5, operatorMessage: `task/HAL program run ${ready.profileId} ${ready.kinematicsModuleId}`, allowFixtureSession: false, }); if (isTaskHalRunPausedByOperator(state)) { return state; } if (shouldContinueTaskHalStatusLoop(state, status)) { startTaskHalStatusLoop({ profileId: ready.profileId, iniPath: ready.iniPath, kinematicsModuleId: ready.kinematicsModuleId, operatorMessage: `task/HAL status loop running ${ready.profileId} ${ready.kinematicsModuleId}`, }); } return state; } const policy = createLinuxCncTaskPolicyStatus(state); if (policy.taskMode !== "auto") { setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, }, operatorMessage: "mode auto", }); } dispatch({ type: "RUN" }); return state; }; const loadTaskHalMotionPlanForSession = async (session = state.taskHalSession) => { if (!state.taskHalRuntime?.loaded || typeof state.taskHalRuntime.loadProgramMotionPlan !== "function") { return null; } setState({ operatorMessage: "RUN preparing: building task/HAL motion plan" }); const motion = state.programExecution?.motion || []; const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution); if (!session?.programPath || !Array.isArray(motion) || motion.length === 0 || !Array.isArray(timing?.segments) || timing.segments.length === 0) { return null; } const previewPlan = buildTaskHalProgramMotionPlanFromPreviewPath({ programPath: session.programPath, path: state.programAxisPreviewPath, profile: state.profile, linearUnits: timing.linearUnits || state.profile?.traj?.linearUnits || "mm", }); const plan = previewPlan || buildTaskHalProgramMotionPlan({ programPath: session.programPath, motion, timing, programLines: state.programLines, linearUnits: timing.linearUnits || state.profile?.traj?.linearUnits || "mm", }); if (plan.segmentCount <= 0) { return null; } setState({ operatorMessage: `RUN preparing: worker loading ${plan.segmentCount} task/HAL motion segments` }); await withTimeout( state.taskHalRuntime.loadProgramMotionPlan(plan), 5000, "task/HAL feed motion plan load timed out", ); return plan; }; const loadTaskHalMotionPlanWithSessionRetry = async () => { let plan = null; try { plan = await loadTaskHalMotionPlanForSession(state.taskHalSession); } catch (error) { setState({ operatorMessage: `task/HAL motion plan reload: ${error instanceof Error ? error.message : String(error)}` }); } if (plan) return plan; await initializeTaskHalSession({ openProgram: true }); return loadTaskHalMotionPlanForSession(state.taskHalSession); }; const startTaskHalStatusLoop = ({ profileId = state.machineProfile, iniPath = state.profile?.iniPath || null, kinematicsModuleId = state.profile?.kinematicsModuleId || state.machineProfile, batchSize = 5, intervalMs = 25, taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs, servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs, operatorMessage = "task/HAL status loop running", } = {}) => { if (!state.taskHalRuntime?.loaded) return null; stopTaskHalStatusLoop("restarted", { notify: false }); const sequence = Number(state.taskHalStatusLoop?.sequence || 0) + 1; dispatch({ type: "TASK_HAL_STATUS_LOOP_STARTED", sequence, profileId, iniPath, kinematicsModuleId, batchSize, intervalMs, taskPeriodNs, servoPeriodNs, operatorMessage, }); const tick = () => runTaskHalStatusLoopTick(sequence).catch((error) => { stopTaskHalStatusLoop("error", { error: error instanceof Error ? error.message : String(error), operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`, }); }); taskHalStatusLoopTimer = setTimeout(tick, intervalMs); return sequence; }; const runTaskHalStatusLoopTick = async (sequence) => { const loop = state.taskHalStatusLoop || {}; if (!loop.active || loop.sequence !== sequence || !state.taskHalRuntime?.loaded) { return null; } await state.taskHalRuntime.runCycles({ taskPeriodNs: loop.taskPeriodNs, servoPeriodNs: loop.servoPeriodNs, taskCycles: loop.batchSize, }); const status = await state.taskHalRuntime.readStatus(); if (state.taskHalStatusLoop?.sequence !== sequence || state.taskHalStatusLoop?.active !== true) { return status; } dispatch({ type: "TASK_HAL_STATUS_APPLIED", status, loopSequence: sequence, operatorMessage: `task/HAL status tick ${Number(state.taskHalStatusLoop?.tickCount || 0) + 1}`, }); if (shouldContinueTaskHalStatusLoop(state, status)) { taskHalStatusLoopTimer = setTimeout( () => runTaskHalStatusLoopTick(sequence).catch((error) => { stopTaskHalStatusLoop("error", { error: error instanceof Error ? error.message : String(error), operatorMessage: `task/HAL status loop failed: ${error instanceof Error ? error.message : String(error)}`, }); }), Number(state.taskHalStatusLoop?.intervalMs || loop.intervalMs || 25), ); } else { stopTaskHalStatusLoop(state.runState === "complete" ? "complete" : state.runState, { operatorMessage: state.runState === "complete" ? "task/HAL program complete" : `task/HAL status loop ${state.runState}`, }); } return status; }; const stopTaskHalStatusLoop = (reason = "stopped", { error = null, operatorMessage = null, notify = true, } = {}) => { if (taskHalStatusLoopTimer) { clearTimeout(taskHalStatusLoopTimer); taskHalStatusLoopTimer = null; } if (notify && (state.taskHalStatusLoop?.active || state.taskHalStatusLoop?.stopReason !== reason || error)) { dispatch({ type: "TASK_HAL_STATUS_LOOP_STOPPED", reason, error, operatorMessage, }); } }; const runTaskHalCommandSequence = async (commands, { taskCycles = 1, taskPeriodNs = deriveTaskHalCyclePeriods(state).taskPeriodNs, servoPeriodNs = deriveTaskHalCyclePeriods(state).servoPeriodNs, operatorMessage = "task/HAL command complete", pendingJogCommand = null, allowFixtureSession = true, preserveMachine = null, preserveAxisPose = null, } = {}) => { if (!state.taskHalRuntime?.loaded) { throw new Error("LinuxCNC task/HAL runtime not attached"); } const sequence = state.taskHalExecutionSequence + 1; setState({ taskHalExecutionPending: true, taskHalExecutionSequence: sequence, pendingJogCommand, operatorMessage: "LinuxCNC task/HAL command running", }); try { if (!state.taskHalSession && state.machineFileStaging?.save?.files?.length) { await initializeTaskHalSession({ openProgram: true }); } else if (allowFixtureSession && !state.taskHalSession && state.programLines?.length) { await initializeFixtureTaskHalSessionForState(); } if (!state.taskHalSession) { throw new Error("LinuxCNC task/HAL session not initialized"); } for (const command of commands) { await state.taskHalRuntime.sendCommand(command); } await state.taskHalRuntime.runCycles({ taskPeriodNs, servoPeriodNs, taskCycles }); const status = await state.taskHalRuntime.readStatus(); if (state.taskHalExecutionSequence !== sequence) { return status; } dispatch({ type: "TASK_HAL_STATUS_APPLIED", status, operatorMessage, preserveMachine, preserveAxisPose }); return status; } catch (error) { dispatch({ type: "TASK_HAL_COMMAND_FAILED", error: error instanceof Error ? error.message : String(error), }); throw error; } }; const initializeFixtureTaskHalSessionForState = async () => { if (!state.taskHalRuntime?.loaded) return null; const programPath = "web-ui/current-program.ngc"; const iniText = state.linuxCncIniConfig?.sourceText || `[TRAJ]\nCOORDINATES = ${state.profile.traj.coordinates.split("").join(" ")}\n`; await state.taskHalRuntime.resetSession?.(); await state.taskHalRuntime.initSession({ profileId: state.machineProfile, iniPath: state.profile.iniPath, iniText, programPath, semanticBoundary: "linuxcnc_task_hal_fixture_program_session", }); await state.taskHalRuntime.stageFiles([ { sourceRel: state.programSourceRel || state.activeProgram, wasmPath: programPath, path: programPath, kind: "demo", text: state.programLines.join("\n"), bytes: state.programLines.join("\n").length, }, ]); await state.taskHalRuntime.openProgram(programPath); const session = { apiName: "web-rtcp-5axis-task-hal-fixture-session", semanticBoundary: "linuxcnc_task_hal_fixture_program_session", profileId: state.machineProfile, iniPath: state.profile.iniPath, programPath, fileCount: 1, }; dispatch({ type: "TASK_HAL_SESSION_READY", session }); return session; }; const scheduleAsyncKinematicsRefresh = () => { if (state.taskHalPauseLock?.active === true || isTaskHalRunPausedByOperator(state)) return null; if (state.asyncFrameRefreshPending) return null; if (state.desiredFrameSourceMode !== "source-derived-kinematics-wasm") return null; if (!state.kinematicsRuntime?.loaded || !isAsyncKinematicsRuntime(state.kinematicsRuntime)) return null; const frame = state.rtcpFrame; if ( frame?.sourceMode === "source-derived-kinematics-wasm" && frame.readiness?.linuxCncKinematicsReady === true && frame.activeLine === state.activeLine && frame.kinsType === state.kinsType && frame.axisPose?.x === state.axisPose.x && frame.axisPose?.y === state.axisPose.y && frame.axisPose?.z === state.axisPose.z && frame.axisPose?.a === state.axisPose.a && frame.axisPose?.b === state.axisPose.b && frame.axisPose?.c === state.axisPose.c ) { return null; } return refreshAsyncKinematicsFrame(); }; return { getState: () => state, subscribe(listener) { listeners.add(listener); listener(state); return () => listeners.delete(listener); }, dispatch, refreshKinematicsFrame: refreshAsyncKinematicsFrame, saveSession, restoreSession, stageMachineFiles, queryToolDb, editToolDb, saveToolDb, runFullBoundaryAudit, initializeTaskHalSession, }; } function buildFrameForState(state, patch = {}) { const requestedSourceMode = state.desiredFrameSourceMode || state.frameSourceMode || state.sourceMode; let linuxCncKinematicsResult = patch.lastKinematicsResult || null; let sourceMode = requestedSourceMode; if (requestedSourceMode === "source-derived-kinematics-wasm") { if (state.kinematicsRuntime?.loaded && !isAsyncKinematicsRuntime(state.kinematicsRuntime)) { switchKinematicsRuntimeForState(state); linuxCncKinematicsResult = state.kinematicsRuntime.frameForJoints( jointsFromAxisPose(state.axisPose, state.profile), { jointCount: state.kinematicsRuntime.jointCount || 5 }, ); } else if (state.kinematicsRuntime?.loaded && isAsyncKinematicsRuntime(state.kinematicsRuntime)) { sourceMode = "fixture-ui-only"; linuxCncKinematicsResult = null; } else { sourceMode = "fixture-ui-only"; linuxCncKinematicsResult = null; } } const frame = buildRtcpFrame({ axisPose: state.axisPose, activeLine: state.activeLine, kinsType: state.kinsType, rtcpEnabled: state.rtcpState === "on" || state.kinsType.startsWith("tcp-"), sourceMode, profile: state.profile, linuxCncKinematicsResult, }); return { frame, lastKinematicsResult: linuxCncKinematicsResult, }; } function createKinematicsDescriptor(runtime) { if (!runtime?.loaded) return null; return { apiName: runtime.apiName, moduleId: runtime.moduleId, wasmFile: runtime.wasmFile, supportedModules: runtime.supportedModules, loaded: runtime.loaded, sourceMode: runtime.sourceMode, semanticBoundary: runtime.semanticBoundary, executionContext: runtime.executionContext || "direct", workerUrl: runtime.workerUrl || null, switchkinsType: runtime.switchkinsType, switchRc: runtime.switchRc, }; } function createInterpreterDescriptor(runtime) { if (!runtime?.loaded) return null; return { apiName: runtime.apiName, loaded: runtime.loaded, sourceMode: runtime.sourceMode, semanticBoundary: runtime.semanticBoundary, executionContext: runtime.executionContext || "direct", }; } function selectMachineFileProgramForState(state) { const sourceRel = state.machineFileStaging?.selectedGcodeSourceRel; if (!sourceRel) return state.machineFileStaging.plan; return selectMachineFileProgram( state.machineFileStaging.plan, state.machineFileStaging.save, sourceRel, ); } function defaultLinuxCncGcodeSourceForState(state) { const sources = state.machineFileStaging?.gcodeSources || []; const defaultProgramFilename = state.profile?.machineFileStaging?.defaultProgramFilename; if (defaultProgramFilename) { const match = sources.find((source) => source.filename === defaultProgramFilename); if (match) return match; } const preferred = `${state.machineProfile || "xyzac-trt"}_switchkins.ngc`; return sources.find((source) => source.filename === preferred) || sources.find((source) => source.filename.includes(state.machineProfile || "xyzac")) || sources[0] || null; } function createInitialControlledUserMState() { const simulation = createControlledUserMSimulation(); return { controlledUserMSimulation: simulation, controlledUserMReadiness: createControlledUserMReadiness(simulation), }; } function createToolDbStatePatchFromStagedFiles({ profile, save }) { const toolTableFile = findStagedToolTableFile(profile, save); if (!toolTableFile) { return { toolDbSimulation: null, toolDbReadiness: createToolDbReadiness(null), toolRuntimeState: createToolRuntimeState(null, { fallbackToolLength: 84.019, }), }; } const toolTable = parseLinuxCncToolTable(toolTableFile.text, { sourceRel: toolTableFile.sourceRel, path: toolTableFile.wasmPath || toolTableFile.path || null, }); const toolDbSimulation = createToolDbSimulation({ toolTable, profile, storageMode: save?.storageMode || null, }); return { toolDbSimulation, toolDbReadiness: createToolDbReadiness(toolDbSimulation), toolRuntimeState: createToolRuntimeState(toolDbSimulation, { fallbackToolLength: 84.019, }), }; } function createProgramToolUserSimulationPatch({ state, programText, sourceRel }) { const toolCommands = extractToolCommandSequenceFromProgram(programText); const toolDbSimulation = state.toolDbSimulation && toolCommands.length > 0 ? applyToolCommandSequence(state.toolDbSimulation, toolCommands) : state.toolDbSimulation; const userMScan = runControlledUserMProgramScan( state.controlledUserMSimulation || createControlledUserMSimulation(), programText, { profile: state.profile, sourceRel, }, ); return { toolDbSimulation, toolDbReadiness: createToolDbReadiness(toolDbSimulation), toolRuntimeState: createToolRuntimeState(toolDbSimulation, { fallbackToolLength: state.toolPreview?.length, }), controlledUserMSimulation: userMScan.simulation, controlledUserMReadiness: createControlledUserMReadiness(userMScan.simulation), }; } function findStagedToolTableFile(profile, save) { const files = Array.isArray(save?.files) ? save.files : []; return files.find((file) => file.sourceRel === profile?.toolTablePath) || files.find((file) => file.kind === "toolTable" && file.sourceRel?.endsWith(`${profile?.id}.tbl`)) || files.find((file) => file.kind === "toolTable") || null; } function createMachineProjectState(state = {}) { const profile = state.profile || {}; const profileId = profile.id || state.machineProfile || "unknown"; const staging = state.machineFileStaging || {}; const save = staging.save || {}; const files = Array.isArray(save.files) ? save.files : []; const gcodeFiles = Array.isArray(staging.gcodeFiles) && staging.gcodeFiles.length > 0 ? staging.gcodeFiles : Array.isArray(save.gcodeFiles) ? save.gcodeFiles : listProjectGcodeFiles(save); const configFiles = files .filter((file) => !["demo", "remap"].includes(file.kind)) .map(projectFileDescriptor); const iniFile = files.find((file) => file.kind === "ini" && file.sourceRel === profile.iniPath) || files.find((file) => file.kind === "ini") || null; const selectedProgram = gcodeFiles.find((file) => file.sourceRel === staging.selectedGcodeSourceRel) || null; const projectRoot = staging.opfsRoot || `${MACHINE_PROJECT_OPFS_ROOT}/${profileId}`; const machineRel = staging.plan?.machineRel || profile.machineFileStaging?.machineRel || "axis/vismach/5axis/table-rotary-tilting"; const demoDirectory = staging.plan?.demoDirectory || profile.machineFileStaging?.demoDirectory || "demos"; return { apiName: "web-rtcp-5axis-machine-project", profileId, projectRoot, status: staging.status || "not-staged", storageMode: staging.storageMode || save.storageMode || null, storageCapability: staging.storageCapability || save.storageCapability || null, linuxCncIniSourceRel: profile.iniPath || null, linuxCncIniLoaded: state.iniConfigReadiness?.loaded === true, ini: iniFile ? { ...projectFileDescriptor(iniFile), sourceMatchesProfile: iniFile.sourceRel === profile.iniPath, textMatchesLoadedIni: typeof state.linuxCncIniConfig?.sourceText === "string" ? iniFile.text === state.linuxCncIniConfig.sourceText : null, contentBoundary: "linuxcnc_ini_source_text_staged_without_web_rewrite", } : null, configFiles, configFileCount: configFiles.length, gcodeDirectory: `${projectRoot}/configs/sim/${machineRel}/${demoDirectory}`, gcodeFiles: gcodeFiles.map(projectFileDescriptor), gcodeFileCount: gcodeFiles.length, selectedProgram: selectedProgram ? projectFileDescriptor(selectedProgram) : null, fileKindCounts: countProjectFileKinds(files), semanticBoundary: "linuxcnc_ini_project_directory_machine_config_and_gcode_files", }; } function createProgramValidationState(state = {}) { const execution = state.programExecution || null; const summary = execution?.summary || {}; const motion = Array.isArray(execution?.motion) ? execution.motion : []; const selectedSourceRel = state.machineFileStaging?.selectedGcodeSourceRel || state.programSourceRel || null; const linuxCncFiveAxisSource = isLinuxCncFiveAxisDemoSource(selectedSourceRel); const motionEventCount = Number(summary.motionEventCount ?? motion.length ?? 0); const canonicalEventCount = Number(summary.canonicalEventCount ?? 0); const switchkinsEventCount = Number(summary.switchkinsEventCount ?? 0); const timing = state.programExecutionTiming || execution?.plannerTiming || null; const plannerSamples = Array.isArray(execution?.plannerTiming?.samples) ? execution.plannerTiming.samples : []; const sampleCount = Array.isArray(timing?.samples) && timing.samples.length > 0 ? timing.samples.length : plannerSamples.length; const ready = Boolean(summary.ready && motionEventCount > 0); return { apiName: "web-rtcp-5axis-program-validation", status: state.interpreterExecutionPending ? "validating" : ready ? "validated" : "pending", ready, activeProgram: state.activeProgram || null, programSource: state.programSource || null, programSourceRel: selectedSourceRel, sourceGuard: linuxCncFiveAxisSource ? "linuxcnc_vendored_5axis_gcode_source_file" : state.programSource === "operator-file" ? "operator_file_not_promoted_to_linuxcnc_source" : "fixture_or_mdi_program_not_linuxcnc_source", previewSource: execution?.sourceMode === "linuxcnc-interpreter-wasm" ? "linuxcnc_interpreter_canonical_motion" : execution?.sourceMode || state.programExecutionSourceMode || "fixture-line-playback", executionTraceSource: state.programRuntimeFeedback?.sourceMode || (plannerSamples.length > 0 ? "linuxcnc_tp_samples" : state.programExecutionSourceMode), motionEventCount, canonicalEventCount, switchkinsEventCount, plannerSampleCount: sampleCount, plannerRuntimeReady: execution?.plannerTiming?.plannerRuntimeReady === true || timing?.plannerRuntimeReady === true, remapRuntimeReady: summary.remapRuntimeReady === true, currentLine: Number(state.programRuntimeFeedback?.line || state.activeLine || 0), currentMotionIndex: Number(state.programExecutionMotionIndex || 0), currentSampleIndex: Number(state.programExecutionSampleIndex || 0), axisFeedbackSource: state.programRuntimeFeedback?.sourceMode || state.programExecutionSourceMode || "ui-state", realtimeAxisValues: { ...pickExecutionAxes(state.axisPose || {}), tcpX: Number(state.tcpPose?.x || 0), tcpY: Number(state.tcpPose?.y || 0), tcpZ: Number(state.tcpPose?.z || 0), }, rtcpState: state.rtcpState || "off", kinsType: state.kinsType || "identity", semanticBoundary: "program_validation_consumes_linuxcnc_interpreter_tp_and_task_hal_feedback", }; } function createLinuxCncProcessMonitor(state = {}) { const feedback = state.programRuntimeFeedback || {}; const uiExecution = state.programUiExecution || createProgramUiExecution(state); const status = state.taskHalStatus || {}; const ui = status.ui || {}; const motion = status.motionStatus?.motion || {}; const halPins = status.halSnapshot?.pins || {}; const toolRuntime = state.toolRuntimeState || {}; const toolDb = state.toolDbSimulation || {}; const currentTool = toolRuntime.currentTool || toolRuntime.activeToolOffset || toolRuntime.pathTool || null; const path = state.programAxisPreviewPath || null; const previewTool = path?.samples?.find((sample) => sample?.tool)?.tool || null; const runtimePathTool = toolRuntime.pathTool || null; const pathTool = Number(runtimePathTool?.id || 0) > 0 || Number(runtimePathTool?.diameter || 0) > 0 ? runtimePathTool : previewTool; const sampleCount = Number(path?.sampleCount || path?.samples?.length || 0); const sampleIndex = clampNumber( Number(state.programExecutionSampleIndex ?? feedback.sampleIndex ?? 0), 0, Math.max(sampleCount - 1, 0), ); const motionCount = Number(state.programExecution?.summary?.motionEventCount || state.programExecution?.motion?.length || 0); const motionIndex = clampNumber( Number(state.programExecutionMotionIndex ?? feedback.motionIndex ?? 0), 0, Math.max(motionCount - 1, 0), ); const sourceLine = Number(feedback.line || state.activeLine || 0); const activeGcode = uiExecution?.statement || (sourceLine > 0 ? state.programLines?.[sourceLine - Number(state.programStartLine || 1)] || "" : ""); const currentVelocity = Number(feedback.currentVelocityMmPerMin ?? state.feed?.currentVelocity ?? ui.currentVelocity ?? 0); const requestedVelocity = Number(feedback.requestedVelocityMmPerMin ?? motion.requestedVel * 60 ?? currentVelocity); const feedRate = Number(state.feed?.feedRate || currentVelocity || 0); const spindleCommandRpm = Number(state.spindle?.rpm || 0); const spindleActualRpm = state.spindle?.enabled ? spindleCommandRpm * (Number(state.spindle?.override || 100) / 100) : 0; const spindleHalPins = state.spindle?.halPins || {}; const toolChange = { toolInSpindle: Number(toolRuntime.toolInSpindle || toolDb.toolInSpindle || 0), toolFromPocket: Number(toolRuntime.toolFromPocket || toolDb.toolFromPocket || 0), currentPocket: Number(toolRuntime.currentPocket || toolDb.currentPocket || 0), preparedTool: Number(toolDb.preparedTool || 0), preparedPocket: Number(toolDb.preparedPocket || 0), activeToolNumber: Number(toolRuntime.activeToolNumber || pathTool?.id || currentTool?.toolNumber || 0), activePocket: Number(toolRuntime.activePocket || pathTool?.pocket || currentTool?.pocket || 0), diameter: Number(pathTool?.diameter ?? currentTool?.diameter ?? 0), lengthOffsetZ: Number(toolRuntime.kinematics?.toolOffsetZ ?? currentTool?.offset?.z ?? pathTool?.length ?? 0), activeOffsetApplied: Boolean(toolRuntime.activeOffsetApplied), iocontrol: { toolPrepare: Boolean(toolDb.iocontrol?.toolPrepare), toolPrepared: Boolean(toolDb.iocontrol?.toolPrepared), toolChange: Boolean(toolDb.iocontrol?.toolChange), toolChanged: Boolean(toolDb.iocontrol?.toolChanged), toolPrepNumber: Number(toolDb.iocontrol?.toolPrepNumber || 0), toolPrepPocket: Number(toolDb.iocontrol?.toolPrepPocket || 0), }, emcioStatus: toolDb.emcioStatus?.status || "UNKNOWN", }; return { apiName: "web-rtcp-5axis-linuxcnc-process-monitor", semanticBoundary: "linuxcnc_task_motion_hal_interpreter_tool_status_monitor", sourceReferences: { task: "linuxcnc/src/emc/task/emctaskmain.cc", motion: "linuxcnc/src/emc/motion/control.c", interpreter: "linuxcnc/src/emc/rs274ngc", axisGui: "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", halPins: "linuxcnc/src/emc/usr_intf/halui.cc + src/emc/iotask/ioControl.cc", toolChange: "linuxcnc/src/emc/task/taskclass.cc + src/emc/tooldata/tooldata_common.cc", }, control: { powerOn: Boolean(state.machine?.powerOn), estopActive: Boolean(state.machine?.estopActive), taskState: state.machine?.taskState || "estop-reset", taskMode: state.machine?.mode || "manual", interpState: state.machine?.interpState || "idle", runState: state.runState || "idle", allHomed: Boolean(state.machine?.allHomed), taskPaused: Boolean(state.machine?.taskPaused), buttonParityCount: 57, policy: state.linuxCncTaskPolicy || null, }, path: { activeProgram: state.activeProgram || null, programSourceRel: state.programSourceRel || state.machineFileStaging?.selectedGcodeSourceRel || null, previewSource: path?.source || state.programValidation?.previewSource || state.programExecutionSourceMode, executionSource: feedback.sourceMode || state.programExecutionSourceMode, activeLine: sourceLine, sourceFile: uiExecution?.sourceFile || null, sourceLine: Number(uiExecution?.line || sourceLine || 0), activeGcode, uiExecution, motionIndex, motionCount, sampleIndex, sampleCount, elapsedSeconds: Number(state.programElapsedSeconds || feedback.timeSeconds || 0), remainingSeconds: Number(state.programRemainingSeconds || 0), queueDepth: Number(feedback.queueDepth || ui.motionQueueDepth || 0), activeDepth: Number(feedback.activeDepth || 0), lineExecution: state.programLineExecution?.[sourceLine] || null, }, axes: { linearUnits: state.profile?.traj?.linearUnits || feedback.linearUnits || "mm", rotaryUnits: "deg", positionSource: feedback.sourceMode || state.frameSourceMode || "ui-state", dro: { ...(state.dro || {}) }, joint: { ...pickExecutionAxes(state.axisPose || {}) }, tcp: { x: Number(state.tcpPose?.x || 0), y: Number(state.tcpPose?.y || 0), z: Number(state.tcpPose?.z || 0), }, distanceToGo: { x: Number(state.dro?.dtgX || 0), y: Number(state.dro?.dtgY || 0), z: Number(state.dro?.dtgZ || 0), scalar: Number(feedback.distanceToGo || 0), }, toolAxisVector: { ...(state.toolAxisVector || {}) }, kinsType: state.kinsType || "identity", rtcpState: state.rtcpState || "off", }, spindle: { enabled: Boolean(state.spindle?.enabled), direction: state.spindle?.direction || "stop", commandRpm: spindleCommandRpm, actualRpm: spindleActualRpm, overridePercent: Number(state.spindle?.override || 0), halPins: { on: Number(spindleHalPins.on ?? halPins["spindle.0.on"]?.value ?? (state.spindle?.enabled ? 1 : 0)), forward: Number(spindleHalPins.forward ?? halPins["spindle.0.forward"]?.value ?? (state.spindle?.direction === "forward" ? 1 : 0)), reverse: Number(spindleHalPins.reverse ?? halPins["spindle.0.reverse"]?.value ?? (state.spindle?.direction === "reverse" ? 1 : 0)), speedOut: Number(spindleHalPins.speedOut ?? halPins["spindle.0.speed-out"]?.value ?? spindleActualRpm), atSpeed: Number(spindleHalPins.atSpeed ?? halPins["spindle.0.at-speed"]?.value ?? (state.spindle?.enabled ? 1 : 0)), }, }, feed: { currentVelocityMmPerMin: currentVelocity, requestedVelocityMmPerMin: requestedVelocity, cuttingVelocityMmPerMin: isCuttingMotion(feedback) ? currentVelocity : 0, feedRate, feedOverridePercent: Number(state.feed?.feedOverride || 0), rapidOverridePercent: Number(state.feed?.rapidOverride || 0), feedMode: feedback.feedMode || "units-per-minute", }, coolant: { flood: Boolean(state.coolant?.flood), mist: Boolean(state.coolant?.mist), halPins: { flood: Number(halPins["iocontrol.0.coolant-flood"]?.value ?? (state.coolant?.flood ? 1 : 0)), mist: Number(halPins["iocontrol.0.coolant-mist"]?.value ?? (state.coolant?.mist ? 1 : 0)), }, }, toolChange, runtime: { interpreterReady: state.interpreterRuntimeReadiness?.loaded === true, kinematicsReady: state.kinematicsRuntimeReadiness?.loaded === true, taskHalReady: state.taskHalRuntimeReadiness?.taskRuntimeReady === true && state.taskHalRuntimeReadiness?.motionRuntimeReady === true && state.taskHalRuntimeReadiness?.halRuntimeReady === true, taskHalLoopActive: state.taskHalStatusLoop?.active === true, taskHalTickCount: Number(state.taskHalStatusLoop?.tickCount || 0), taskCycle: Number(feedback.taskCycle || ui.taskCycle || 0), servoCycle: Number(feedback.cycle || ui.servoCycle || 0), halChangedPinCount: Number(feedback.halChangedPinCount || ui.halChangedPinCount || 0), fallbackReason: state.taskHalFallbackReason || null, }, }; } function isCuttingMotion(feedback = {}) { const type = String(feedback.type || "").toUpperCase(); return type.includes("FEED") || type.includes("ARC") || type === "TASK_MOTION"; } function createRightSidebarEntranceState(state = {}, taskPolicy = createLinuxCncTaskPolicyStatus(state)) { const manualActive = state.machine?.mode === "manual" && state.machine?.manualPanel !== "jog"; const jogActive = state.machine?.mode === "manual" && state.machine?.manualPanel === "jog"; const tcpKinsType = tcpKinsTypeForProfile(state.profile); const powerGate = gateLinuxCncTaskAction(state, { type: "TOGGLE_POWER" }); const autoGate = gateLinuxCncTaskAction(state, { type: "SET_MODE", mode: "auto" }); const manualGate = gateLinuxCncTaskAction(state, { type: "SET_MODE", mode: "manual" }); const jogGate = gateLinuxCncTaskAction(state, { type: "SET_MODE", mode: "jog" }); const mdiGate = gateLinuxCncTaskAction(state, { type: "SET_MODE", mode: "mdi" }); const identityGate = gateKinsTypeChange(state, "identity"); const tcpGate = tcpKinsType ? gateKinsTypeChange(state, tcpKinsType) : blockKinsChange(taskPolicy, `TCP blocked: ${state.profile?.id || "profile"} has no TCP switchkins type`); return [ sidebarEntry({ id: "estop", label: "E-STOP", active: Boolean(state.machine?.estopActive), gate: allowKinsChange(taskPolicy), tone: "emergency" }), sidebarEntry({ id: "power", label: "POWER", active: Boolean(state.machine?.powerOn), gate: powerGate, tone: "power" }), sidebarEntry({ id: "reset", label: "RESET", active: false, gate: allowKinsChange(taskPolicy), tone: state.machine?.estopActive ? "reset-required" : "reset" }), sidebarEntry({ id: "auto", label: "AUTO", active: state.machine?.mode === "auto", gate: autoGate, tone: "mode" }), sidebarEntry({ id: "manual", label: "MANUAL", active: manualActive, gate: manualGate, tone: "mode" }), sidebarEntry({ id: "jog", label: "JOG", active: jogActive, gate: jogGate, tone: "mode" }), sidebarEntry({ id: "mdi", label: "MDI", active: state.machine?.mode === "mdi", gate: mdiGate, tone: "mode" }), sidebarEntry({ id: "identity", label: "IDENTITY", active: state.kinsType === "identity", gate: identityGate, tone: "identity" }), sidebarEntry({ id: "tcp", label: "TCP", active: String(state.kinsType || "").startsWith("tcp-"), gate: tcpGate, tone: "tcp" }), ]; } function sidebarEntry({ id, label, active, gate, tone }) { const allowed = gate?.allowed !== false; const status = id === "estop" && active ? "emergency" : active && !allowed ? "active-blocked" : active ? "active" : allowed ? "ready" : "blocked"; return { apiName: "web-rtcp-5axis-right-sidebar-entry", id, label, active: Boolean(active), allowed, status, tone, operatorMessage: gate?.operatorMessage || null, colorRule: `${tone}:${status}`, semanticBoundary: "gmoccapy_right_vertical_button_task_state_mode_gate", }; } function gateKinsTypeChange(state = {}, requestedKinsType = "identity") { const taskPolicy = createLinuxCncTaskPolicyStatus(state); const target = String(requestedKinsType || ""); if (!target) { return blockKinsChange(taskPolicy, "kinematics blocked: missing switchkins target"); } if (target.startsWith("tcp-") && !profileSupportsTcp(state.profile)) { return blockKinsChange(taskPolicy, `kinematics ${target} blocked: ${state.profile?.id || "profile"} is not TCP capable`); } if (taskPolicy.taskState === "estop") { return blockKinsChange(taskPolicy, "kinematics blocked: reset estop first"); } if (taskPolicy.taskState !== "on") { return blockKinsChange(taskPolicy, "kinematics blocked: machine must be on"); } if (taskPolicy.interpState === "reading" || taskPolicy.interpState === "waiting" || state.runState === "running") { return blockKinsChange(taskPolicy, "kinematics blocked: interpreter must be idle"); } return allowKinsChange(taskPolicy); } function allowKinsChange(status) { return { allowed: true, status, operatorMessage: null, }; } function blockKinsChange(status, operatorMessage) { return { allowed: false, status, operatorMessage, }; } function projectFileDescriptor(file = {}) { return { sourceRel: file.sourceRel || null, filename: sourceBasename(file.sourceRel || file.path || file.wasmPath || ""), opfsPath: file.opfsPath || null, wasmPath: file.wasmPath || file.path || null, kind: file.kind || "asset", bytes: Number(file.bytes || (typeof file.text === "string" ? file.text.length : 0)), semanticBoundary: file.semanticBoundary || "linuxcnc_project_file_staged_without_web_rewrite", }; } function countProjectFileKinds(files = []) { return files.reduce((counts, file) => { const kind = file.kind || "asset"; counts[kind] = (counts[kind] || 0) + 1; return counts; }, {}); } function isLinuxCncFiveAxisDemoSource(sourceRel) { const value = String(sourceRel || ""); return ( value.startsWith("configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/") || value.startsWith("configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/") ) && value.endsWith(".ngc"); } function sourceBasename(path) { return String(path || "").split("/").filter(Boolean).at(-1) || ""; } function withTimeout(promise, timeoutMs, message) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); Promise.resolve(promise) .then((value) => { clearTimeout(timeoutId); resolve(value); }) .catch((error) => { clearTimeout(timeoutId); reject(error); }); }); } function isAsyncKinematicsRuntime(runtime) { return runtime?.executionContext === "worker"; } export function validateRunPreconditions(state = {}, { requireTaskHalRuntime = true, requireTaskHalSession = true, } = {}) { const profile = state.profile || {}; const profileId = profile.id || state.machineProfile || "unknown"; const supportedProfile = profile.rtcpProof !== false && Boolean(profile.kinematicsModuleId); const fail = (operatorMessage, detail = {}) => ({ apiName: "web-rtcp-5axis-run-preconditions", ok: false, operatorMessage, profileId, iniPath: profile.iniPath || null, coordinates: normalizeCoordinates(profile.traj?.coordinates), kinematicsModuleId: profile.kinematicsModuleId || state.machineProfile || null, ...detail, }); if (!supportedProfile) { return fail(`run blocked: unsupported five-axis profile ${profileId}`); } if (!profile.iniPath || state.iniConfigReadiness?.loaded !== true || state.iniConfigReadiness?.ready !== true) { return fail("run blocked: LinuxCNC INI not loaded"); } if (state.linuxCncIniConfig?.path && state.linuxCncIniConfig.path !== profile.iniPath) { return fail("run blocked: machine profile and INI path mismatch", { iniPath: state.linuxCncIniConfig.path, expectedIniPath: profile.iniPath, }); } const profileCoordinates = normalizeCoordinates(profile.traj?.coordinates); const iniCoordinates = normalizeCoordinates(state.iniConfigReadiness?.coordinates || state.linuxCncIniConfig?.traj?.coordinates); if (!profileCoordinates || !iniCoordinates || profileCoordinates !== iniCoordinates) { return fail("run blocked: machine profile and INI coordinates mismatch", { coordinates: iniCoordinates || null, expectedCoordinates: profileCoordinates || null, }); } const profileKinematicsModuleId = profile.kinematicsModuleId || state.machineProfile || null; const iniKinematicsModuleId = state.linuxCncIniConfig?.kinematicsModuleId || profileKinematicsModuleId; if (!profileKinematicsModuleId || profileKinematicsModuleId !== iniKinematicsModuleId) { return fail("run blocked: machine profile and INI kinematics mismatch", { kinematicsModuleId: profileKinematicsModuleId, expectedKinematicsModuleId: iniKinematicsModuleId, }); } const kinematicsReadiness = state.kinematicsRuntimeReadiness || {}; const runtimeKinematicsModuleId = kinematicsReadiness.moduleId || state.kinematicsRuntime?.moduleId || null; if (kinematicsReadiness.loaded !== true || state.kinematicsRuntime?.loaded !== true) { return fail("run blocked: LinuxCNC kinematics runtime not ready", { kinematicsModuleId: profileKinematicsModuleId, runtimeKinematicsModuleId, }); } if (runtimeKinematicsModuleId !== profileKinematicsModuleId) { return fail("run blocked: LinuxCNC kinematics module mismatch", { kinematicsModuleId: profileKinematicsModuleId, runtimeKinematicsModuleId, }); } if (state.rtcpFrame?.sourceMode !== "source-derived-kinematics-wasm") { return fail("run blocked: LinuxCNC kinematics frame not ready", { frameSourceMode: state.rtcpFrame?.sourceMode || state.frameSourceMode || null, }); } if (state.machineFileStaging?.status !== "staged" || !state.machineFileStaging?.save?.files?.length) { return fail("run blocked: LinuxCNC machine files not staged"); } if (!state.machineFileStaging?.selectedGcodeSourceRel) { return fail("run blocked: no machine-file G-code opened for task/HAL session"); } if (requireTaskHalRuntime) { const taskHalReadiness = state.taskHalRuntimeReadiness || {}; const taskHalReady = state.taskHalRuntime?.loaded === true && taskHalReadiness.taskRuntimeReady === true && taskHalReadiness.motionRuntimeReady === true && taskHalReadiness.halRuntimeReady === true; if (!taskHalReady) { return fail("run blocked: task/HAL runtime not ready"); } } const expectedProgramPath = expectedTaskHalProgramPathForState(state); if (requireTaskHalSession) { if (!state.taskHalSession?.programPath || !expectedProgramPath) { return fail("run blocked: no machine-file G-code opened for task/HAL session"); } if (state.taskHalSession.programPath !== expectedProgramPath) { return fail("run blocked: task/HAL session program mismatch", { programPath: state.taskHalSession.programPath, expectedProgramPath, }); } } return { apiName: "web-rtcp-5axis-run-preconditions", ok: true, operatorMessage: null, profileId, machineType: profile.title || null, iniPath: profile.iniPath, coordinates: profileCoordinates, kinematicsModuleId: profileKinematicsModuleId, selectedGcodeSourceRel: state.machineFileStaging.selectedGcodeSourceRel, programPath: expectedProgramPath, sourceMode: "linuxcnc-task-motion-hal-wasm", semanticBoundary: "linuxcnc_ini_profile_kinematics_task_hal_run_preconditions", }; } function expectedTaskHalProgramPathForState(state = {}) { const sourceRel = state.machineFileStaging?.selectedGcodeSourceRel; if (!sourceRel || !state.machineFileStaging?.plan || !state.machineFileStaging?.save) return null; try { return selectMachineFileProgram( state.machineFileStaging.plan, state.machineFileStaging.save, sourceRel, ).wasmProgramPath || null; } catch { return null; } } function deriveTaskHalCyclePeriods(state = {}) { const taskCycleTimeSeconds = Number(state.linuxCncIniConfig?.task?.cycleTimeSeconds); const iniTaskPeriodNs = Number.isFinite(taskCycleTimeSeconds) && taskCycleTimeSeconds > 0 ? Math.round(taskCycleTimeSeconds * 1_000_000_000) : null; const iniServoPeriodNs = Number(state.linuxCncIniConfig?.emcmot?.servoPeriodNs); return { taskPeriodNs: iniTaskPeriodNs || 10000000, servoPeriodNs: Number.isFinite(iniServoPeriodNs) && iniServoPeriodNs > 0 ? Math.round(iniServoPeriodNs) : 1000000, }; } function normalizeCoordinates(value) { return String(value || "").replace(/[^A-Za-z]/g, "").toUpperCase(); } function applyTaskHalStatusPatch(state, status, operatorMessage, { loopSequence = null, preserveMachine = null, preserveAxisPose = null, } = {}) { const ui = status?.ui || {}; const task = status?.task || {}; const motion = status?.motionStatus?.motion || {}; const statusInterpState = normalizeTaskHalInterpState(ui.interpState || task.interpState); const statusMotionPaused = ui.motionPaused === true || motion.paused === true; const statusTaskPaused = ui.taskPaused === true || task.taskPaused === true; const statusPaused = statusInterpState === "paused" || statusMotionPaused || statusTaskPaused; const rawTaskState = normalizeTaskHalTaskState(ui.taskState || task.state); const taskState = preserveMachine?.powerOn && rawTaskState === "estop-reset" ? "on" : rawTaskState; const taskMode = statusPaused ? programControlModeForMachine(state.machine) : normalizeLinuxCncTaskMode( preserveMachine?.mode || (preserveMachine?.allHomed && state.machine.mode !== "manual" ? state.machine.mode : null) || ui.taskMode || task.mode || state.machine.mode, ); const manualPanel = taskMode === "manual" ? (preserveMachine?.manualPanel || state.machine.manualPanel || "manual") : null; const interpState = statusPaused ? "paused" : Object.hasOwn(preserveMachine || {}, "interpState") ? normalizeTaskHalInterpState(preserveMachine.interpState) : statusInterpState; const allHomed = Boolean( task.allHomed === true || ui.allHomed === true || preserveMachine?.allHomed || state.machine.allHomed, ); const homed = normalizeHomedArrayForState( state, ui.homed || task.homed || preserveMachine?.homed || state.machine.homed, allHomed, ); const homing = Object.hasOwn(task, "homing") ? task.homing === true : Object.hasOwn(ui, "homing") ? ui.homing === true : Boolean(preserveMachine?.homing); const homeState = normalizeTaskHalHomeState( ui.homeState || task.homeState || preserveMachine?.homeState || state.machine.homeState || (allHomed ? "homed" : "unhomed"), ); const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0); const kinsType = resolveTaskHalKinsType(state, status, activeLine); const motionPaused = statusMotionPaused || preserveMachine?.motionPaused === true; const taskPaused = statusTaskPaused || preserveMachine?.taskPaused === true; const paused = interpState === "paused" || motionPaused || taskPaused; const singleStepping = ui.singleStepping === true || task.singleStepping === true || preserveMachine?.singleStepping === true; const motionStepping = ui.motionStepping === true || motion.stepping === true || preserveMachine?.motionStepping === true; const interpResumeState = normalizeTaskHalInterpState( ui.interpResumeState || task.interpResumeState || (paused ? state.machine.interpResumeState || "reading" : interpState), ); const displayPauseLock = paused && state.taskHalPauseLock?.active === true ? state.taskHalPauseLock : null; const axisPose = displayPauseLock?.axisPose ? clampAxisPoseToProfile(displayPauseLock.axisPose, state.profile) : preserveAxisPose ? clampAxisPoseToProfile({ ...state.axisPose, ...preserveAxisPose }, state.profile) : resolveTaskHalAxisPose(state, status, { paused, runState: state.runState }); const currentVelocity = paused ? 0 : Number.isFinite(ui.currentVelocity) ? Math.max(ui.currentVelocity, 0) : state.feed.currentVelocity; const aborted = motion.aborted === true; const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1); const programComplete = interpState === "idle" && Number(task.nextProgramLine || 0) >= openedProgramLineCount; const runState = aborted ? "stopped" : paused ? "paused" : taskMode === "mdi" ? "mdi" : interpState === "reading" ? "running" : programComplete ? "complete" : state.runState === "jogging" ? "jogging" : "idle"; const runtimeFeedback = createTaskHalRuntimeFeedback(state, status, axisPose, activeLine); const shouldTrackProgramPlayback = runState === "running" || runState === "mdi" || (runState === "complete" && (state.runState === "running" || state.runState === "mdi" || state.runState === "complete")); const taskHalSampleIndex = displayPauseLock ? clampNumber( displayPauseLock.sampleIndex, 0, Math.max(Number(state.programAxisPreviewPath?.samples?.length || state.programAxisPreviewPath?.sampleCount || 1) - 1, 0), ) : shouldTrackProgramPlayback ? resolveRuntimeSampleIndexForTaskHalPose(state, axisPose, activeLine, runtimeFeedback.sampleIndex) : clampNumber( state.programExecutionSampleIndex || 0, 0, Math.max(Number(state.programAxisPreviewPath?.samples?.length || state.programAxisPreviewPath?.sampleCount || 1) - 1, 0), ); const idleRuntimeFeedback = { ...runtimeFeedback, sampleIndex: taskHalSampleIndex, motionIndex: displayPauseLock ? Number(displayPauseLock.motionIndex || 0) : Number(state.programExecutionMotionIndex || runtimeFeedback.motionIndex || 0), ...(paused ? { axisPose, tcp: { x: Number(displayPauseLock?.tcpPose?.x ?? state.tcpPose?.x ?? axisPose.x ?? 0), y: Number(displayPauseLock?.tcpPose?.y ?? state.tcpPose?.y ?? axisPose.y ?? 0), z: Number(displayPauseLock?.tcpPose?.z ?? state.tcpPose?.z ?? axisPose.z ?? 0), }, } : {}), }; const taskHalPlaybackPatch = shouldTrackProgramPlayback ? applyProgramPlaybackUiPatch(state, { activeLine, axisPose, kinsType, rtcpState: rtcpStateFromKinsType(kinsType), motionIndex: displayPauseLock ? Number(displayPauseLock.motionIndex || 0) : runtimeFeedback.motionIndex, sampleIndex: taskHalSampleIndex, runtimeFeedback: { ...runtimeFeedback, sampleIndex: taskHalSampleIndex, motionIndex: displayPauseLock ? Number(displayPauseLock.motionIndex || 0) : runtimeFeedback.motionIndex, axisPose, currentVelocityMmPerMin: paused ? 0 : runtimeFeedback.currentVelocityMmPerMin, requestedVelocityMmPerMin: paused ? 0 : runtimeFeedback.requestedVelocityMmPerMin, }, preferRuntimeAxisPose: true, }) : { activeLine: state.activeLine, axisPose, kinsType: kinsType || state.kinsType, rtcpState: kinsType ? rtcpStateFromKinsType(kinsType) : state.rtcpState, toolAxisVector: state.toolAxisVector, programExecutionMotionIndex: Number(state.programExecutionMotionIndex || 0), programExecutionSampleIndex: taskHalSampleIndex, programRuntimeFeedback: idleRuntimeFeedback, programUiExecution: state.programUiExecution ? createProgramUiExecution({ ...state, axisPose, tcpPose: displayPauseLock?.tcpPose || state.tcpPose, rtcpFrame: displayPauseLock?.rtcpFrame || state.rtcpFrame, kinsType: kinsType || state.kinsType, programExecutionSampleIndex: taskHalSampleIndex, programRuntimeFeedback: idleRuntimeFeedback, }, { preferRuntimePose: true }) : state.programUiExecution, }; const loopActive = state.taskHalStatusLoop?.active === true && loopSequence !== null && Number(state.taskHalStatusLoop.sequence) === Number(loopSequence) && (runState === "running" || runState === "mdi" || runState === "paused"); const nextTickCount = loopActive ? Number(state.taskHalStatusLoop.tickCount || 0) + 1 : Number(state.taskHalStatusLoop?.tickCount || 0); const feedbackHistory = [taskHalPlaybackPatch.programRuntimeFeedback, ...(state.programRuntimeFeedbackHistory || [])].slice(0, 100); const lineStatus = runState === "complete" ? "done" : runState === "running" || runState === "mdi" ? "running" : runState; return { taskHalStatus: status, taskHalExecutionPending: false, taskHalPauseLock: displayPauseLock ? { ...displayPauseLock, lastStatusAt: new Date().toISOString() } : null, taskHalFallbackReason: null, pendingJogCommand: null, ...taskHalPlaybackPatch, ...(displayPauseLock ? { tcpPose: displayPauseLock.tcpPose, rtcpFrame: displayPauseLock.rtcpFrame, dro: displayPauseLock.dro, } : {}), programExecutionSourceMode: "linuxcnc-task-motion-hal-wasm", machine: { ...state.machine, powerOn: taskState === "on", estopActive: taskState === "estop", motionEnabled: taskState === "on", taskState, mode: taskMode, manualPanel, interpState, interpResumeState, taskPaused: paused, motionPaused, singleStepping, motionStepping, allHomed, homed, homing, homeState, noForceHoming: Boolean(state.machine.noForceHoming), }, runState, taskHalStatusLoop: loopSequence === null ? state.taskHalStatusLoop : { ...state.taskHalStatusLoop, active: loopActive, tickCount: nextTickCount, lastStatusAt: new Date().toISOString(), stopReason: loopActive ? null : runState, }, feed: { ...state.feed, currentVelocity, }, programRuntimeFeedbackHistory: feedbackHistory, programLineExecution: createProgramLineExecutionPatch(state.programLineExecution, taskHalPlaybackPatch.programRuntimeFeedback, { status: lineStatus, source: "linuxcnc-task-motion-hal-wasm", }), operatorMessage, }; } function shouldContinueTaskHalStatusLoop(state = {}, status = {}) { const ui = status?.ui || {}; const task = status?.task || {}; const motion = status?.motionStatus?.motion || {}; const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState); const taskMode = normalizeLinuxCncTaskMode(ui.taskMode || task.mode || state.machine?.mode); const aborted = motion.aborted === true; const paused = interpState === "paused" || ui.motionPaused === true || ui.taskPaused === true || motion.paused === true || task.taskPaused === true; const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1); const complete = interpState === "idle" && Number(task.nextProgramLine || 0) >= openedProgramLineCount; if (paused) { return state.runState === "paused" || state.machine?.interpState === "paused" || state.machine?.taskPaused === true || state.machine?.motionPaused === true; } return !aborted && !paused && !complete && (interpState === "reading" || taskMode === "mdi"); } function isTaskHalStatusPaused(status = {}) { const ui = status?.ui || {}; const task = status?.task || {}; const motion = status?.motionStatus?.motion || {}; return normalizeTaskHalInterpState(ui.interpState || task.interpState) === "paused" || ui.motionPaused === true || ui.taskPaused === true || motion.paused === true || task.taskPaused === true; } function isTaskHalRunPausedByOperator(state = {}) { return state.runState === "paused" || state.machine?.interpState === "paused" || state.machine?.taskPaused === true || state.machine?.motionPaused === true; } function programControlModeForMachine(machine = {}) { return machine.mode === "mdi" ? "mdi" : "auto"; } function createPowerToggleMachinePatch(machine = {}, turningOn = false) { const poweredOffHomed = Array.isArray(machine.homed) ? machine.homed.map(() => false) : [false, false, false, false, false]; return { ...machine, powerOn: turningOn, estopActive: false, motionEnabled: turningOn, taskState: turningOn ? "on" : "estop-reset", manualPanel: "manual", allHomed: turningOn ? Boolean(machine.allHomed) : false, homed: turningOn ? normalizeHomedArrayForMachine(machine, Boolean(machine.allHomed)) : poweredOffHomed, homing: false, homeState: turningOn ? (machine.allHomed ? "homed" : machine.homeState || "unhomed") : "unhomed", interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }; } function createTaskHalPauseLock(state = {}, source = "pause") { return { apiName: "web-rtcp-5axis-task-hal-pause-lock", active: true, source, createdAt: new Date().toISOString(), runState: "paused", interpState: "paused", sampleIndex: Number(state.programExecutionSampleIndex || 0), motionIndex: Number(state.programExecutionMotionIndex || 0), activeLine: Number(state.activeLine || state.programStartLine || 1), axisPose: clampAxisPoseToProfile(state.axisPose || initialAxisPose, state.profile), tcpPose: { x: Number(state.tcpPose?.x || 0), y: Number(state.tcpPose?.y || 0), z: Number(state.tcpPose?.z || 0), }, rtcpFrame: state.rtcpFrame, dro: state.dro, semanticBoundary: "linuxcnc_axis_task_pauseresume_freezes_task_hal_status_until_resume", }; } function zeroProgramRuntimeVelocity(feedback) { if (!feedback) return feedback; return { ...feedback, currentVelocityMmPerMin: 0, requestedVelocityMmPerMin: 0, distanceToGo: 0, dtg: feedback.dtg ? { ...feedback.dtg, x: 0, y: 0, z: 0 } : feedback.dtg, activeDepth: 0, }; } function createStoppedProgramStatePatch(state, { reason = "stopped", operatorMessage = "program stopped", } = {}) { return { machine: { ...state.machine, interpState: "idle", interpResumeState: "idle", taskPaused: false, motionPaused: false, singleStepping: false, motionStepping: false, }, runState: reason === "aborted" ? "stopped" : reason, taskHalPauseLock: null, taskHalStatusLoop: { ...state.taskHalStatusLoop, active: false, sequence: Number(state.taskHalStatusLoop?.sequence || 0) + 1, stopReason: reason, lastStatusAt: new Date().toISOString(), }, feed: { ...state.feed, currentVelocity: 0, }, programRuntimeFeedback: zeroProgramRuntimeVelocity(state.programRuntimeFeedback), operatorMessage, }; } function resolveTaskHalKinsType(state, status, activeLine) { const ui = status?.ui || {}; const numeric = Number(ui.switchkinsType); if (Number.isFinite(numeric) && numeric !== 0) { return kinsTypeFromSwitchkinsTypeValue(state, numeric); } const programKinsType = kinsTypeFromProgramActiveLine(state, activeLine); if (programKinsType) { return programKinsType; } return kinsTypeFromSwitchkinsTypeValue(state, ui.switchkinsType); } function kinsTypeFromProgramActiveLine(state, activeLine) { const motion = programMotionAtOrBeforeLine(state, activeLine) || state.programExecution?.motion?.[clampMotionIndex(state, state.programExecutionMotionIndex)]; return kinsTypeFromProgramMotion(state, motion); } function programMotionAtOrBeforeLine(state, activeLine) { const motion = state.programExecution?.motion; if (!Array.isArray(motion) || motion.length === 0) return null; const line = Number(activeLine); if (!Number.isFinite(line)) return null; let candidate = null; for (const item of motion) { const itemLine = Number(item?.line); if (!Number.isFinite(itemLine)) continue; if (itemLine > line) break; candidate = item; } return candidate; } function resolveTaskHalAxisPose(state, status, { paused = false, runState = state.runState } = {}) { const ui = status?.ui || {}; const axisPose = ui.axisPose; if (!axisPose || typeof axisPose !== "object") { return state.axisPose; } if (state.pendingJogCommand && (ui.axisPoseFrame === "task-local" || isJogStatus(status))) { const { axis, direction, increment, basePose } = state.pendingJogCommand; return clampAxisPoseToProfile({ ...basePose, [axis]: Number(basePose?.[axis] || 0) + direction * increment, }, state.profile); } if (ui.axisPoseFrame === "work") { return clampAxisPoseToProfile({ ...state.axisPose, ...axisPose }, state.profile); } if (ui.axisPoseDelta && typeof ui.axisPoseDelta === "object") { return addAxisDelta(state.axisPose, ui.axisPoseDelta, state.profile); } if (!paused && runState !== "running" && runState !== "mdi" && !isJogStatus(status)) { return state.axisPose; } if (!ui.axisPoseFrame && wouldResetNonZeroPoseToLocalZero(state.axisPose, axisPose)) { return state.axisPose; } return clampAxisPoseToProfile({ ...state.axisPose, ...axisPose }, state.profile); } function addAxisDelta(axisPose, delta, profile) { const next = { ...axisPose }; for (const axis of ["x", "y", "z", "a", "b", "c"]) { if (!Number.isFinite(Number(delta[axis]))) continue; next[axis] = Number(next[axis] || 0) + Number(delta[axis]); } return clampAxisPoseToProfile(next, profile); } function isJogStatus(status) { const motion = status?.motionStatus?.motion || {}; return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true; } function wouldResetNonZeroPoseToLocalZero(currentPose = {}, nextPose = {}) { const axes = ["x", "y", "z", "a", "b", "c"]; const currentHasNonZero = axes.some((axis) => Math.abs(Number(currentPose[axis] || 0)) > 0.001); const nextIsNearZero = axes.every((axis) => Math.abs(Number(nextPose[axis] || 0)) <= 0.001); return currentHasNonZero && nextIsNearZero; } function createTaskHalRuntimeFeedback(state, status, axisPose, activeLine) { const ui = status?.ui || {}; const motion = status?.motionStatus?.motion || {}; const halProgramLine = Number(status?.halSnapshot?.pins?.["motion.program-line"]?.value || 0); const motionProgramLine = Number(motion.programLine || 0); const interpState = normalizeTaskHalInterpState(ui.interpState || status?.task?.interpState); const paused = interpState === "paused" || ui.motionPaused === true || ui.taskPaused === true || motion.paused === true || status?.task?.taskPaused === true; const currentVelocity = paused ? 0 : Number(ui.currentVelocity || 0); const requestedVelocity = paused ? 0 : Number(motion.requestedVel || 0) * 60; return { apiName: "web-rtcp-5axis-program-runtime-feedback", sourceMode: "linuxcnc-task-motion-hal-wasm", semanticBoundary: status?.semanticBoundary || "linuxcnc_task_motion_hal_wasm_simulation_runtime", sampleIndex: Number(ui.servoCycle || 0), motionIndex: Math.max(Number(ui.activeLine || 1) - 1, 0), line: activeLine, motionProgramLine, halProgramLine, activeLineSource: ui.activeLineSource || (motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback"), activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0 && motionProgramLine === halProgramLine, type: Number(motion.motionType || 0) === 3 ? "JOG" : "TASK_MOTION", paused, timeSeconds: Number(ui.taskCycle || 0) * 0.01, axisPose, currentVelocityMmPerMin: currentVelocity, requestedVelocityMmPerMin: requestedVelocity, distanceToGo: motion.inPosition === true ? 0 : 1, dtg: { x: 0, y: 0, z: 0 }, queueDepth: Number(ui.motionQueueDepth || status?.motionStatus?.commandQueueDepth || 0), activeDepth: motion.inPosition === true ? 0 : 1, cycle: Number(ui.servoCycle || status?.servoCycle || 0), taskCycle: Number(ui.taskCycle || status?.task?.cycle || 0), halChangedPinCount: Number(ui.halChangedPinCount || 0), }; } function buildTaskHalProgramMotionPlanFromPreviewPath({ programPath = null, path = null, profile = defaultProfile, linearUnits = "mm", } = {}) { const samples = path?.samples; if (!Array.isArray(samples) || samples.length < 2) return null; const samplePeriodSeconds = Math.max(Number(path.samplePeriodMs || 50) / 1000, 0.001); const segments = []; for (let index = 1; index < samples.length; index += 1) { const startSample = samples[index - 1]; const endSample = samples[index]; const startAxes = taskHalPlanAxesFromPose(axisPoseFromProgramPathSample(startSample, initialAxisPose, profile)); const endAxes = taskHalPlanAxesFromPose(axisPoseFromProgramPathSample(endSample, startAxes, profile)); const startSeconds = Number.isFinite(Number(startSample?.timeMs)) ? Number(startSample.timeMs) / 1000 : (index - 1) * samplePeriodSeconds; const endSeconds = Number.isFinite(Number(endSample?.timeMs)) ? Number(endSample.timeMs) / 1000 : index * samplePeriodSeconds; const durationSeconds = Math.max(endSeconds - startSeconds, samplePeriodSeconds); const velocityMmPerMin = estimatePreviewSegmentVelocityMmPerMin(startAxes, endAxes, durationSeconds); const type = endSample?.motionType || startSample?.motionType || "STRAIGHT_FEED"; segments.push({ line: Number(endSample?.line || startSample?.line || 1), type, motionClass: String(type).includes("TRAVERSE") ? "rapid" : "feed", feedMode: "units-per-minute", startSeconds, durationSeconds, elapsedSeconds: startSeconds + durationSeconds, feedRate: velocityMmPerMin, linearUnits, velocityMmPerMin, requestedVelocityMmPerMin: velocityMmPerMin, startAxes, endAxes, }); } if (segments.length === 0) return null; return { apiName: "web-rtcp-5axis-task-hal-program-motion-plan", semanticBoundary: "linuxcnc_task_hal_plan_from_expanded_axis_preview_samples", programPath, linearUnits, totalSeconds: segments[segments.length - 1].elapsedSeconds, segmentCount: segments.length, segments, }; } function taskHalPlanAxesFromPose(pose = {}) { return { x: Number(pose.x || 0), y: Number(pose.y || 0), z: Number(pose.z || 0), a: Number(pose.a || 0), b: Number(pose.b || 0), c: Number(pose.c || 0), }; } function estimatePreviewSegmentVelocityMmPerMin(startAxes = {}, endAxes = {}, durationSeconds = 0.05) { const linearDistance = Math.hypot( Number(endAxes.x || 0) - Number(startAxes.x || 0), Number(endAxes.y || 0) - Number(startAxes.y || 0), Number(endAxes.z || 0) - Number(startAxes.z || 0), ); const rotaryDistance = Math.hypot( Number(endAxes.a || 0) - Number(startAxes.a || 0), Number(endAxes.b || 0) - Number(startAxes.b || 0), Number(endAxes.c || 0) - Number(startAxes.c || 0), ); const distance = Math.max(linearDistance, rotaryDistance * 0.1, 0.001); return durationSeconds > 0 ? (distance / durationSeconds) * 60 : 1; } function resolveRuntimeSampleIndexForLine(state = {}, activeLine, fallbackSampleIndex = 0) { const samples = state.programAxisPreviewPath?.samples; if (!Array.isArray(samples) || samples.length === 0) { return Number(fallbackSampleIndex) || 0; } const line = Number(activeLine); if (!Number.isFinite(line)) { return clampNumber(fallbackSampleIndex, 0, samples.length - 1); } const current = clampNumber(state.programExecutionSampleIndex || 0, 0, samples.length - 1); for (let index = current; index < samples.length; index += 1) { if (Number(samples[index]?.line) >= line) return index; } return samples.length - 1; } function resolveRuntimeSampleIndexForTaskHalPose(state = {}, axisPose = {}, activeLine, fallbackSampleIndex = 0) { const samples = state.programAxisPreviewPath?.samples; if (!Array.isArray(samples) || samples.length === 0) { return resolveRuntimeSampleIndexForLine(state, activeLine, fallbackSampleIndex); } const current = clampNumber(state.programExecutionSampleIndex || 0, 0, samples.length - 1); const previousCycle = Number(state.programRuntimeFeedback?.cycle || 0); const nextCycle = Number(fallbackSampleIndex || 0); const cycleDelta = Number.isFinite(nextCycle) && Number.isFinite(previousCycle) ? Math.max(nextCycle - previousCycle, 0) : 0; const adaptiveWindow = Math.max(320, Math.min(samples.length - 1, Math.ceil(cycleDelta * 8) + 240)); const windowStart = current; const windowEnd = Math.min(samples.length - 1, current + adaptiveWindow); const line = Number(activeLine); let bestIndex = current; let bestScore = Number.POSITIVE_INFINITY; for (let index = windowStart; index <= windowEnd; index += 1) { const sample = samples[index]; const samplePose = axisPoseFromProgramPathSample(sample, axisPose, state.profile); const linePenalty = Number.isFinite(line) && Number.isFinite(Number(sample?.line)) ? Math.min(Math.abs(Number(sample.line) - line), 12) * 0.25 : 0; const forwardPenalty = (index - current) * 0.000001; const score = taskHalPoseDistanceScore(axisPose, samplePose) + linePenalty + forwardPenalty; if (score < bestScore) { bestScore = score; bestIndex = index; } } return Math.max(current, bestIndex); } function taskHalPoseDistanceScore(a = {}, b = {}) { const linear = ["x", "y", "z"].reduce((sum, axis) => { const delta = Number(a[axis] || 0) - Number(b[axis] || 0); return sum + delta * delta; }, 0); const rotary = ["a", "b", "c"].reduce((sum, axis) => { const delta = Number(a[axis] || 0) - Number(b[axis] || 0); return sum + (delta * delta * 0.01); }, 0); return linear + rotary; } function applyProgramPlaybackUiPatch(state = {}, { activeLine, axisPose, kinsType, rtcpState, motionIndex, sampleIndex, runtimeFeedback, preferRuntimeAxisPose = false, } = {}) { const sample = currentProgramPathSample(state, sampleIndex); const runtimeAxisPose = clampAxisPoseToProfile(axisPose || runtimeFeedback?.axisPose || state.axisPose, state.profile); const sampleAxisPose = preferRuntimeAxisPose ? runtimeAxisPose : axisPoseFromProgramPathSample(sample, runtimeAxisPose, state.profile); const sampleKinsType = sample?.activeKinematics ? normalizeSampleKinsType(state, sample.activeKinematics) : kinsType; const sampleLine = Number(sample?.line || activeLine || state.activeLine || 0); const sampleMotionIndex = Number.isFinite(Number(sample?.segmentIndex)) ? Number(sample.segmentIndex) : Number(motionIndex || 0); const sampleSampleIndex = Number.isFinite(Number(sample?.sampleIndex)) ? Number(sample.sampleIndex) : Number(sampleIndex || 0); const toolAxisVector = toolAxisVectorFromSample(sample, state.toolAxisVector); const enrichedFeedback = enrichRuntimeFeedbackWithSample(runtimeFeedback, sample, { ...state, activeLine: sampleLine, axisPose: sampleAxisPose, kinsType: sampleKinsType || state.kinsType, toolAxisVector, }, { preferRuntimeAxisPose }); const patchState = { ...state, activeLine: sampleLine, axisPose: sampleAxisPose, kinsType: sampleKinsType || state.kinsType, rtcpState: sampleKinsType ? rtcpStateFromKinsType(sampleKinsType) : rtcpState || state.rtcpState, toolAxisVector, programExecutionMotionIndex: sampleMotionIndex, programExecutionSampleIndex: sampleSampleIndex, programRuntimeFeedback: enrichedFeedback, }; return { activeLine: patchState.activeLine, axisPose: patchState.axisPose, kinsType: patchState.kinsType, rtcpState: patchState.rtcpState, toolAxisVector, programExecutionMotionIndex: patchState.programExecutionMotionIndex, programExecutionSampleIndex: patchState.programExecutionSampleIndex, programRuntimeFeedback: enrichedFeedback, programUiExecution: createProgramUiExecution(patchState, { preferRuntimePose: preferRuntimeAxisPose }), }; } function createProgramUiExecution(state = {}, { preferRuntimePose = false, } = {}) { const sample = currentProgramPathSample(state, state.programExecutionSampleIndex); const feedback = state.programRuntimeFeedback || {}; const sampleCount = Number(state.programAxisPreviewPath?.sampleCount || state.programAxisPreviewPath?.samples?.length || 0); const lineExecutionTrace = state.programAxisPreviewPath?.lineExecutionTrace || []; const traceEntry = sample ? lineExecutionTrace.find((entry) => ( Number(entry.segmentIndex) === Number(sample.segmentIndex) && entry.sourceFile === sample.sourceFile && Number(entry.line) === Number(sample.line) )) || null : null; const gcodeSteps = state.programAxisPreviewPath?.gcodeExecutionProcess?.executionSteps || []; const gcodeStep = traceEntry ? gcodeSteps.find((step) => Number(step.result?.traceExecutionIndex) === Number(traceEntry.executionIndex)) || null : null; const sourceFile = sample?.sourceFile || traceEntry?.sourceFile || feedback.sourceFile || null; const sourceLine = Number(sample?.line || traceEntry?.line || feedback.line || state.activeLine || 0); const sampleIndex = Number(sample?.sampleIndex ?? state.programExecutionSampleIndex ?? 0); return { apiName: "web-rtcp-5axis-ui-gcode-live-execution", status: state.runState || "idle", source: sample ? "programAxisPreviewPath.samples" : feedback.sourceMode || state.programExecutionSourceMode || "ui-state", samplePeriodMs: Number(state.programAxisPreviewPath?.samplePeriodMs || 0), sampleIndex, sampleCount, executedSampleCount: sampleCount > 0 ? Math.min(sampleIndex + 1, sampleCount) : 0, timeMs: Number(sample?.timeMs ?? feedback.timeSeconds * 1000 ?? 0), activeProgram: state.activeProgram || null, sourceFile, line: sourceLine, statement: sample?.statement || traceEntry?.statement || gcodeStep?.statement || "", operation: traceEntry?.operation || gcodeStep?.result?.operation || null, executionIndex: traceEntry?.executionIndex ?? null, gcodeStepIndex: gcodeStep?.stepIndex ?? null, segmentIndex: sample?.segmentIndex ?? traceEntry?.segmentIndex ?? null, motionType: sample?.motionType || traceEntry?.motionType || feedback.motionType || feedback.type || null, activeKinematics: sample?.activeKinematics || traceEntry?.activeKinematicsAfter || state.kinsType, joint: preferRuntimePose ? pickExecutionAxes(feedback.axisPose || state.axisPose || {}) : sample?.joint || pickExecutionAxes(state.axisPose || {}), tcp: preferRuntimePose && feedback.tcp ? feedback.tcp : sample?.tcp || { x: Number(state.tcpPose?.x || state.axisPose?.x || 0), y: Number(state.tcpPose?.y || state.axisPose?.y || 0), z: Number(state.tcpPose?.z || state.axisPose?.z || 0), }, toolAxis: sample?.toolAxis || toolAxisSampleFromVector(state.toolAxisVector), tool: sample?.tool || currentPathTool(state), machineState: sample?.machineState || null, semanticBoundary: "ui_updates_from_real_expanded_linuxcnc_gcode_sample_stream", }; } function currentProgramPathSample(state = {}, sampleIndex = 0) { const samples = state.programAxisPreviewPath?.samples; if (!Array.isArray(samples) || samples.length === 0) return null; const index = clampNumber(sampleIndex, 0, samples.length - 1); return samples[index] || null; } function axisPoseFromProgramPathSample(sample, fallbackPose = {}, profile = defaultProfile) { const joint = sample?.joint || {}; return clampAxisPoseToProfile({ ...fallbackPose, x: numberOrFallback(joint.x, fallbackPose.x, 0), y: numberOrFallback(joint.y, fallbackPose.y, 0), z: numberOrFallback(joint.z, fallbackPose.z, 0), b: numberOrFallback(joint.b, fallbackPose.b, 0), c: numberOrFallback(joint.c, fallbackPose.c, 0), }, profile || defaultProfile); } function normalizeSampleKinsType(state = {}, value) { if (value === "tcp-xyzbc" || value === "tcp-xyzac" || value === "identity" || value === "userk") { return value; } if (value === "tcp") return tcpKinsTypeForProfile(state.profile) || value; return value || null; } function toolAxisVectorFromSample(sample, fallbackVector = {}) { const axis = sample?.toolAxis || {}; return { x: numberOrFallback(axis.x, axis.i, fallbackVector.x, 0), y: numberOrFallback(axis.y, axis.j, fallbackVector.y, 0), z: numberOrFallback(axis.z, axis.k, fallbackVector.z, 1), }; } function toolAxisSampleFromVector(vector = {}) { return { i: Number(vector.x || 0), j: Number(vector.y || 0), k: Number(vector.z ?? 1), }; } function enrichRuntimeFeedbackWithSample(feedback = {}, sample = null, state = {}, { preferRuntimeAxisPose = false, } = {}) { if (!sample) return feedback; const runtimeAxisPose = feedback?.axisPose || state.axisPose || {}; const tcp = preferRuntimeAxisPose ? { x: Number(runtimeAxisPose.x ?? 0), y: Number(runtimeAxisPose.y ?? 0), z: Number(runtimeAxisPose.z ?? 0), } : sample.tcp || { x: Number(state.tcpPose?.x ?? state.axisPose?.x ?? feedback?.axisPose?.x ?? 0), y: Number(state.tcpPose?.y ?? state.axisPose?.y ?? feedback?.axisPose?.y ?? 0), z: Number(state.tcpPose?.z ?? state.axisPose?.z ?? feedback?.axisPose?.z ?? 0), }; return { ...(feedback || {}), sampleIndex: Number(sample.sampleIndex || 0), motionIndex: Number(sample.segmentIndex || feedback?.motionIndex || 0), line: Number(sample.line || feedback?.line || 0), sourceFile: sample.sourceFile || feedback?.sourceFile || null, statement: sample.statement || feedback?.statement || "", motionType: sample.motionType || feedback?.motionType || null, activeKinematics: sample.activeKinematics || feedback?.activeKinematics || state.kinsType, axisPose: preferRuntimeAxisPose ? runtimeAxisPose : axisPoseFromProgramPathSample(sample, state.axisPose || feedback?.axisPose || {}, state.profile), tcp, toolAxis: sample.toolAxis || feedback?.toolAxis || null, machineState: sample.machineState || feedback?.machineState || null, }; } function createProgramLineExecutionPatch(previous = {}, feedback = null, { status = "running", source = null, } = {}) { const line = Number(feedback?.line || 0); if (!Number.isFinite(line) || line <= 0) { return previous || {}; } const axisPose = feedback.axisPose || {}; return { ...(previous || {}), [line]: { status, source: source || feedback.sourceMode || "unknown", line, feed: Number(feedback.currentVelocityMmPerMin || 0), requestedFeed: Number(feedback.requestedVelocityMmPerMin || 0), feedMode: feedback.feedMode || null, axisPose: pickExecutionAxes(axisPose), taskCycle: Number(feedback.taskCycle || 0), servoCycle: Number(feedback.cycle || 0), sampleIndex: Number(feedback.sampleIndex || 0), motionIndex: Number(feedback.motionIndex || 0), updatedAt: new Date().toISOString(), }, }; } function pickExecutionAxes(axisPose = {}) { return Object.fromEntries(["x", "y", "z", "a", "b", "c"].map((axis) => [ axis, Number(axisPose[axis] || 0), ])); } function normalizeTaskHalTaskState(value) { const state = String(value || "").toLowerCase().replaceAll("_", "-"); if (state === "on") return "on"; if (state === "estop") return "estop"; return "estop-reset"; } function normalizeTaskHalInterpState(value) { const state = String(value || "").toLowerCase(); if (state === "paused") return "paused"; if (state === "reading") return "reading"; if (state === "waiting") return "waiting"; return "idle"; } function normalizeTaskHalHomeState(value) { const state = String(value || "").toLowerCase().replaceAll("_", "-"); if (state === "homing") return "homing"; if (state === "homed") return "homed"; if (state === "no-force-homing") return "no-force-homing"; return "unhomed"; } function jointCountForState(state = {}) { return Math.max( Number(state.profile?.jointConfig?.length || 0), Number(state.profile?.joints?.length || 0), Array.isArray(state.machine?.homed) ? state.machine.homed.length : 0, 5, ); } function createHomedArrayForState(state = {}, value = false) { return Array.from({ length: jointCountForState(state) }, () => Boolean(value)); } function normalizeHomedArrayForState(state = {}, source = [], allHomed = false) { const count = jointCountForState(state); if (allHomed) return Array.from({ length: count }, () => true); if (!Array.isArray(source)) return Array.from({ length: count }, () => false); return Array.from({ length: count }, (_, index) => Boolean(source[index])); } function normalizeHomedArrayForMachine(machine = {}, allHomed = false, fallbackCount = 5) { const source = Array.isArray(machine.homed) ? machine.homed : []; const count = Math.max(source.length, fallbackCount); if (allHomed) return Array.from({ length: count }, () => true); return Array.from({ length: count }, (_, index) => Boolean(source[index])); } function kinsTypeFromSwitchkinsTypeValue(state, value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric === 0) return "identity"; return state.profile.kinematicsParameters.switchkinsTypes .find((type) => Number(type.value) === numeric)?.webKinsType || state.kinsType; } function canMoveMachine(state) { return createLinuxCncTaskPolicyStatus(state).canMove; } function normalizeMachineForLinuxCncTask(machine = {}, runState = "idle") { const baseMachine = { ...initialState.machine, ...machine }; const taskState = deriveLinuxCncTaskState(baseMachine); const mode = normalizeLinuxCncTaskMode(baseMachine.mode); const interpState = baseMachine.interpState || (runState === "running" ? "reading" : runState === "paused" || runState === "stepping" ? "paused" : "idle"); const manualPanel = mode === "manual" ? (baseMachine.manualPanel === "jog" ? "jog" : "manual") : null; const homed = normalizeHomedArrayForMachine(baseMachine, Boolean(baseMachine.allHomed)); const allHomed = Boolean(baseMachine.allHomed || homed.every(Boolean)); return { ...baseMachine, taskState, mode, manualPanel, interpState, interpResumeState: baseMachine.interpResumeState || (interpState === "paused" ? "reading" : interpState), taskPaused: Boolean(baseMachine.taskPaused || interpState === "paused"), motionPaused: Boolean(baseMachine.motionPaused || baseMachine.taskPaused || interpState === "paused"), singleStepping: Boolean(baseMachine.singleStepping), motionStepping: Boolean(baseMachine.motionStepping), resumeInhibit: Boolean(baseMachine.resumeInhibit), motionEnabled: taskState === "on", powerOn: taskState === "on", estopActive: taskState === "estop", allHomed, homed, noForceHoming: Boolean(baseMachine.noForceHoming), homing: Boolean(baseMachine.homing), homeState: allHomed ? "homed" : baseMachine.homeState || "unhomed", }; } function createIniConfigReadiness(iniConfig) { return { apiName: "web-rtcp-5axis-ini-config-readiness", loaded: true, ready: iniConfig.validation.ready, path: iniConfig.path, missing: iniConfig.validation.missing, machineName: iniConfig.machineName, coordinates: iniConfig.traj.coordinates, kinematics: iniConfig.kinematics.name, axisCount: iniConfig.validation.axisCount, jointCount: iniConfig.validation.jointCount, semanticBoundary: iniConfig.semanticBoundary, }; } function normalizeKinsTypeForProfile(kinsType, profile) { if (kinsType !== "tcp-xyzac" && kinsType !== "tcp-xyzbc") return kinsType; return tcpKinsTypeForProfile(profile) || "identity"; } function profileSupportsTcp(profile) { return Boolean(profile?.tcpCapable !== false && tcpKinsTypeForProfile(profile)); } function tcpKinsTypeForProfile(profile) { const types = profile?.kinematicsParameters?.switchkinsTypes || []; return types.find((type) => type.value === 1 && String(type.webKinsType || "").startsWith("tcp-")) ?.webKinsType || types.find((type) => String(type.webKinsType || "").startsWith("tcp-")) ?.webKinsType || null; } function defaultKinsTypeForProfile(profile) { return profile?.kinematicsParameters?.fixedTrtDefault ? tcpKinsTypeForProfile(profile) || "identity" : "identity"; } function clampAxisPoseToProfile(axisPose, profile = defaultProfile) { const next = { ...axisPose }; for (const [axis, limits] of Object.entries(profile.axisLimits || {})) { const key = axis.toLowerCase(); const value = Number(next[key] ?? 0); const min = Number.isFinite(limits.min) ? limits.min : -Infinity; const max = Number.isFinite(limits.max) ? limits.max : Infinity; next[key] = Math.min(Math.max(value, min), max); } return next; } function homeAxisPoseForState(state = {}) { if (state.profile?.id === "xyzac-trt" || state.profile?.id === "xyzbc-trt") { return clampAxisPoseToProfile({ ...initialAxisPose, x: 0, y: 0, z: 10, }, state.profile); } const pose = { ...initialAxisPose }; for (const joint of state.profile?.jointConfig || []) { const axis = String(joint.axis || "").toLowerCase(); if (!axis) continue; pose[axis] = Number(joint.home || 0); } return clampAxisPoseToProfile(pose, state.profile || defaultProfile); } function executeMdiCommand(state, rawCommand) { const command = normalizeMdiCommand(rawCommand); if (!command) { return { patch: { operatorMessage: "MDI blocked: empty command", }, }; } const parsed = parseMdiCommand(command); const parsedKinsType = resolveMdiKinsType(state, parsed.kinsType); const distanceMode = parsed.distanceMode || state.machine.mdiDistanceMode || "absolute"; const nextAxisPose = { ...state.axisPose }; for (const axis of ["x", "y", "z", "a", "b", "c"]) { if (!Number.isFinite(parsed.axes[axis])) continue; nextAxisPose[axis] = distanceMode === "relative" ? Number(nextAxisPose[axis] || 0) + parsed.axes[axis] : parsed.axes[axis]; } const nextMachine = { ...state.machine, mode: "mdi", manualPanel: null, mdiCommand: command, mdiDistanceMode: distanceMode, interpState: "idle", interpResumeState: "idle", taskPaused: false, }; const mdiExecution = createMdiProgramExecution(command, nextAxisPose, parsed.motionCode); const mdiFeed = parsed.feedRate !== null ? { ...state.feed, feedRate: parsed.feedRate, currentVelocity: parsed.feedRate } : state.feed; const mdiTiming = buildProgramExecutionTiming({ motion: mdiExecution.motion, profile: state.profile, feedOverride: mdiFeed.feedOverride, rapidOverride: mdiFeed.rapidOverride, defaultFeedRate: mdiFeed.feedRate, }); const mdiTimingSnapshot = timingAtMotionIndex(mdiTiming, 0); const patch = { machine: nextMachine, runState: "mdi", axisPose: nextAxisPose, activeProgram: "MDI", programSource: "operator-mdi", programStartLine: 1, activeLine: 1, lineCount: 1, fileSizeBytes: command.length, programLines: [command], programExecution: mdiExecution, programExecutionTiming: mdiTiming, programExecutionSourceMode: "operator-mdi", programExecutionMotionIndex: 0, programElapsedSeconds: mdiTimingSnapshot.elapsedSeconds, programRemainingSeconds: mdiTimingSnapshot.remainingSeconds, preview: { ...state.preview, pathPoints: hasMdiAxisWords(parsed) ? Math.max(state.preview.pathPoints, 2) : state.preview.pathPoints, }, feed: { ...mdiFeed, currentVelocity: mdiTimingSnapshot.currentVelocity || mdiFeed.currentVelocity }, spindle: parsed.spindleRpm !== null || parsed.spindleEnabled !== null ? { ...state.spindle, rpm: parsed.spindleRpm ?? state.spindle.rpm, enabled: parsed.spindleEnabled ?? state.spindle.enabled, direction: parsed.spindleDirection ?? state.spindle.direction, halPins: spindleHalPinsForState({ ...state.spindle, rpm: parsed.spindleRpm ?? state.spindle.rpm, enabled: parsed.spindleEnabled ?? state.spindle.enabled, direction: parsed.spindleDirection ?? state.spindle.direction, }), } : state.spindle, coolant: parsed.coolantPatch ? { ...state.coolant, ...parsed.coolantPatch } : state.coolant, kinsType: parsedKinsType || state.kinsType, rtcpState: parsedKinsType?.startsWith("tcp-") ? "on" : parsedKinsType ? "off" : state.rtcpState, mdiHistory: [command, ...(state.mdiHistory || []).filter((entry) => entry !== command)].slice(0, 8), operatorMessage: `MDI ${command}`, }; return { patch }; } function createManualTouchOffMdiPatch(state, patch, command) { return { ...patch, machine: { ...patch.machine, mode: "manual", manualPanel: state.machine.manualPanel || "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "idle", operatorMessage: `manual touch off ${command}`, }; } function normalizeMdiCommand(command) { return String(command || "") .replace(/\([^)]*\)/g, " ") .replace(/;.*$/g, " ") .trim() .replace(/\s+/g, " ") .toUpperCase(); } function parseMdiCommand(command) { const parsed = { axes: {}, feedRate: null, spindleRpm: null, spindleEnabled: null, spindleDirection: null, coolantPatch: null, distanceMode: null, motionCode: null, kinsType: null, }; const words = [...command.matchAll(/([A-Z])\s*([-+]?\d+(?:\.\d+)?)/g)] .map((match) => ({ letter: match[1], value: Number(match[2]) })); for (const word of words) { if (["X", "Y", "Z", "A", "B", "C"].includes(word.letter)) { parsed.axes[word.letter.toLowerCase()] = word.value; continue; } if (word.letter === "F") { parsed.feedRate = Math.max(0, word.value); continue; } if (word.letter === "S") { parsed.spindleRpm = Math.max(0, word.value); continue; } if (word.letter === "G") { if (word.value === 90) parsed.distanceMode = "absolute"; if (word.value === 91) parsed.distanceMode = "relative"; if ([0, 1, 2, 3].includes(word.value)) parsed.motionCode = `G${word.value}`; continue; } if (word.letter === "M") { applyMdiMCode(parsed, word.value); } } return parsed; } function applyMdiMCode(parsed, value) { if (value === 3 || value === 4) { parsed.spindleEnabled = true; parsed.spindleDirection = value === 4 ? "reverse" : "forward"; } else if (value === 5) { parsed.spindleEnabled = false; parsed.spindleDirection = "stop"; } else if (value === 7) { parsed.coolantPatch = { ...(parsed.coolantPatch || {}), mist: true }; } else if (value === 8) { parsed.coolantPatch = { ...(parsed.coolantPatch || {}), flood: true }; } else if (value === 9) { parsed.coolantPatch = { flood: false, mist: false }; } else if (value === 428) { parsed.kinsType = "tcp"; } else if (value === 429) { parsed.kinsType = "identity"; } else if (value === 430) { parsed.kinsType = "userk"; } } function spindleHalPinsForState(spindle = {}) { const enabled = Boolean(spindle.enabled) && spindle.direction !== "stop"; const speedOut = enabled ? Number(spindle.rpm || 0) * (Number(spindle.override || 100) / 100) : 0; return { on: enabled ? 1 : 0, forward: spindle.direction === "forward" ? 1 : 0, reverse: spindle.direction === "reverse" ? 1 : 0, speedOut, atSpeed: enabled ? 1 : 0, }; } function stoppedSpindleState(spindle = {}) { return { ...spindle, enabled: false, direction: "stop", halPins: spindleHalPinsForState({ ...spindle, enabled: false, direction: "stop", }), }; } function normalizeSpindleDirection(direction) { const value = String(direction || "stop").toLowerCase(); return value === "forward" || value === "reverse" ? value : "stop"; } function resolveMdiKinsType(state, kinsType) { if (kinsType !== "tcp") return kinsType; return tcpKinsTypeForProfile(state.profile); } function hasMdiAxisWords(parsed) { return Object.values(parsed.axes).some((value) => Number.isFinite(value)); } function createMdiProgramExecution(command, axisPose, motionCode) { const motionType = motionCode === "G0" ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED"; return { apiName: "web-rtcp-5axis-mdi-execution", sourceMode: "operator-mdi", semanticBoundary: "operator_mdi_lightweight_motion_words", resultText: `mdi_command=${command}`, motion: hasMdiAxisWords(parseMdiCommand(command)) ? [{ type: motionType, line: 1, statement: command, axes: { ...axisPose }, raw: `mdi_command=${command}`, }] : [], summary: { ready: true, programLineCount: 1, canonicalEventCount: 1, motionEventCount: hasMdiAxisWords(parseMdiCommand(command)) ? 1 : 0, motionTypes: hasMdiAxisWords(parseMdiCommand(command)) ? [motionType] : [], finalAxes: { ...axisPose }, remapRuntimeReady: false, plannerRuntimeReady: false, fullLinuxCncProgramExecutionReady: false, }, }; } function getProgramEndLine(state) { return state.programStartLine + Math.max(state.programLines.length - 1, 0); } function getNextProgramLine(state, step) { return Math.min(state.activeLine + step, getProgramEndLine(state)); } function nextProgramPlayback(state, step) { if (state.programExecution?.motion?.length > 0) { const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution); const motionIndex = Math.min( Number(state.programExecutionMotionIndex || 0) + step, state.programExecution.motion.length - 1, ); const motion = state.programExecution.motion[motionIndex]; const timingSnapshot = timingAtMotionIndex(timing, motionIndex); const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType; return { motionIndex, activeLine: motion.line || state.activeLine, axisPose: axisPoseFromCanonicalMotion(motion, state.axisPose), kinsType, rtcpState: rtcpStateFromKinsType(kinsType), timing: timingSnapshot, complete: motionIndex >= state.programExecution.motion.length - 1, }; } const activeLine = getNextProgramLine(state, step); return { motionIndex: state.programExecutionMotionIndex || 0, activeLine, axisPose: buildFixtureAxisPoseForLine(state.axisPose, activeLine), kinsType: state.kinsType, rtcpState: state.rtcpState, timing: { elapsedSeconds: 0, remainingSeconds: 0, currentVelocity: state.feed.currentVelocity, segmentDurationSeconds: 0, segmentDistanceMm: 0, }, complete: activeLine >= getProgramEndLine(state), }; } function nextProgramRuntimeSamplePlayback(state, step) { const timing = state.programExecutionTiming || buildTimingForState(state, state.programExecution); const samples = Array.isArray(timing?.samples) ? timing.samples : []; if (samples.length > 0) { const sampleIndex = Math.min( Number(state.programExecutionSampleIndex || 0) + Math.max(Number(step) || 1, 1), samples.length - 1, ); const sample = samples[sampleIndex]; const motionIndex = clampMotionIndex(state, sample.motionIndex); const motion = state.programExecution?.motion?.[motionIndex] || null; const sampleWithUnits = { ...sample, linearUnits: sample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits, }; const segment = timing?.segments?.[motionIndex] || null; const kinsType = kinsTypeFromProgramMotion(state, motion) || state.kinsType; const elapsedSeconds = Number(sample.timeSeconds) || Number(segment?.elapsedSeconds) || 0; const currentVelocity = Number(sample.currentVelocityMmPerMin) || Number(sample.currentVelocity) * 60 || Number(segment?.velocityMmPerMin) || 0; const runtimeFeedback = createProgramRuntimeFeedbackFromSample({ state, sample: sampleWithUnits, sampleIndex, motion, motionIndex, segment, currentVelocity, elapsedSeconds, }); return { motionIndex, sampleIndex, activeLine: sample.line || motion?.line || state.activeLine, axisPose: axisPoseFromRuntimeSample(sampleWithUnits, motion, state.axisPose), kinsType, rtcpState: rtcpStateFromKinsType(kinsType), timing: { elapsedSeconds, remainingSeconds: Math.max((timing?.totalSeconds || 0) - elapsedSeconds, 0), currentVelocity, segmentDurationSeconds: Number(segment?.durationSeconds) || 0, segmentDistanceMm: Number(segment?.linearDistanceMm) || 0, }, runtimeFeedback, complete: sampleIndex >= samples.length - 1, }; } const pathSamples = state.programAxisPreviewPath?.samples; if (Array.isArray(pathSamples) && pathSamples.length > 0) { const sampleIndex = Math.min( Number(state.programExecutionSampleIndex || 0) + Math.max(Number(step) || 1, 1), pathSamples.length - 1, ); const sample = pathSamples[sampleIndex]; const motionIndex = Number.isFinite(Number(sample?.segmentIndex)) ? Number(sample.segmentIndex) : 0; const axisPose = axisPoseFromProgramPathSample(sample, state.axisPose, state.profile); const kinsType = sample?.activeKinematics ? normalizeSampleKinsType(state, sample.activeKinematics) : state.kinsType; const elapsedSeconds = Number(sample?.timeMs || 0) / 1000; const currentVelocity = Number(sample?.machineState?.feed?.actualMmPerMin || sample?.feed || 0); const runtimeFeedback = { apiName: "web-rtcp-5axis-program-runtime-feedback", sourceMode: "web-axis-source-execution-expanded-ngcgui-subroutines", semanticBoundary: "linuxcnc_xyzbc_switchkins_sample_stream_feedback", sampleIndex, motionIndex, sourceFile: sample?.sourceFile || null, statement: sample?.statement || "", line: sample?.line || state.activeLine, type: sample?.motionType || null, motionType: sample?.motionType || null, linearUnits: state.profile?.traj?.linearUnits || "mm", timeSeconds: elapsedSeconds, axisPose, currentVelocityMmPerMin: currentVelocity, requestedVelocityMmPerMin: currentVelocity, distanceToGo: sampleIndex >= pathSamples.length - 1 ? 0 : 1, dtg: { x: 0, y: 0, z: 0 }, queueDepth: 0, activeDepth: sampleIndex >= pathSamples.length - 1 ? 0 : 1, cycle: sampleIndex, }; return { motionIndex, sampleIndex, activeLine: sample?.line || state.activeLine, axisPose, kinsType, rtcpState: rtcpStateFromKinsType(kinsType), timing: { elapsedSeconds, remainingSeconds: Math.max(((pathSamples.length - 1) * Number(state.programAxisPreviewPath?.samplePeriodMs || 0)) / 1000 - elapsedSeconds, 0), currentVelocity, segmentDurationSeconds: Number(state.programAxisPreviewPath?.samplePeriodMs || 0) / 1000, segmentDistanceMm: 0, }, runtimeFeedback, complete: sampleIndex >= pathSamples.length - 1, }; } const playback = nextProgramPlayback(state, step); return { ...playback, sampleIndex: playback.motionIndex, runtimeFeedback: createProgramRuntimeFeedbackFromMotion({ state, motion: state.programExecution?.motion?.[playback.motionIndex] || null, motionIndex: playback.motionIndex, timing: playback.timing, sourceMode: state.programExecution?.sourceMode === "linuxcnc-interpreter-wasm" ? "linuxcnc-canonical-motion" : "fixture-line-playback", }), }; } function createInitialProgramRuntimeFeedback({ state, timing, motion, timingSnapshot }) { const firstSample = timing?.samples?.[0] || null; if (firstSample) { const sampleWithUnits = { ...firstSample, linearUnits: firstSample.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits, }; return createProgramRuntimeFeedbackFromSample({ state, sample: sampleWithUnits, sampleIndex: 0, motion, motionIndex: clampMotionIndex(state, firstSample.motionIndex), segment: timing?.segments?.[0] || null, currentVelocity: Number(firstSample.currentVelocityMmPerMin) || Number(firstSample.currentVelocity) * 60 || 0, elapsedSeconds: Number(firstSample.timeSeconds) || 0, }); } return createProgramRuntimeFeedbackFromMotion({ state, motion, motionIndex: 0, timing: timingSnapshot, sourceMode: "linuxcnc-canonical-motion", }); } function clampMotionIndex(state, motionIndex) { const count = state.programExecution?.motion?.length || 0; if (count <= 0) return 0; const index = Number(motionIndex); return Number.isFinite(index) ? Math.min(Math.max(index, 0), count - 1) : 0; } function buildTimingForState(state, execution) { const motion = execution?.motion || []; const requiresFeedModeTiming = motion.some((event) => event?.feedMode === "inverse-time"); if (!requiresFeedModeTiming && execution?.plannerTiming?.plannerRuntimeReady === true) { return execution.plannerTiming; } return buildProgramExecutionTiming({ motion, profile: state.profile, feedOverride: state.feed.feedOverride, rapidOverride: state.feed.rapidOverride, defaultFeedRate: state.feed.feedRate, }); } function kinsTypeFromProgramMotion(state, motion) { if (!motion) return null; if (motion.kinsType) { return resolveProgramKinsType(state, motion.kinsType); } if (Number.isFinite(motion.switchkinsType)) { return kinsTypeFromSwitchkinsType(state, motion.switchkinsType); } return null; } function resolveProgramKinsType(state, requestedKinsType) { if (requestedKinsType === "tcp") { return kinsTypeFromSwitchkinsType(state, 1); } if (requestedKinsType === "identity") { return "identity"; } if (requestedKinsType === "userk") { return kinsTypeFromSwitchkinsType(state, 2); } return requestedKinsType || null; } function kinsTypeFromSwitchkinsType(state, switchkinsType) { return state.profile.kinematicsParameters.switchkinsTypes .find((type) => type.value === switchkinsType)?.webKinsType || null; } function switchkinsTypeFromKinsType(state) { const match = state.profile.kinematicsParameters.switchkinsTypes .find((type) => type.webKinsType === state.kinsType); return Number.isFinite(match?.value) ? match.value : 0; } function rtcpStateFromKinsType(kinsType) { return String(kinsType || "").startsWith("tcp-") ? "on" : "off"; } function switchKinematicsRuntimeForState(state) { if (!state.kinematicsRuntime?.loaded || typeof state.kinematicsRuntime.switchKinematics !== "function") { return null; } const switchkinsType = switchkinsTypeFromKinsType(state); if (state.kinematicsRuntime.switchkinsType === switchkinsType) { return state.kinematicsRuntime.switchRc ?? 0; } return state.kinematicsRuntime.switchKinematics(switchkinsType); } function buildLoadedProgram(action) { const content = String(action.content || ""); const lines = parseProgramLines(content); const filename = action.filename || "operator-program.ngc"; return { activeProgram: filename, programSource: action.programSource || "operator-file", programSourceRel: action.sourceRel || null, programWasmPath: action.wasmPath || null, programStartLine: 1, activeLine: 1, lineCount: lines.length, fileSizeBytes: content.length, programLines: lines, }; } function currentPathTool(state) { const pathTool = state.toolRuntimeState?.pathTool; if ( pathTool && ( Number(pathTool.id) > 0 || Number(pathTool.pocket) > 0 || Number(pathTool.length) > 0 || Number(pathTool.diameter) > 0 ) ) { return pathTool; } if (state.machineProfile === "xyzbc-trt") { return { id: 2, pocket: 2, length: 10, diameter: 8, }; } return { id: Number(state.toolPreview?.toolNumber) || 2, pocket: Number(state.toolPreview?.toolNumber) || 2, length: Number(state.toolPreview?.length) || 10, diameter: Number(state.toolPreview?.diameter) || 8, }; } function parseProgramLines(content) { const lines = content .split(/\r?\n/) .map((line) => line.trimEnd()) .filter((line) => line.trim().length > 0); return lines.length > 0 ? lines : ["(empty program)"]; } function clampPercent(value, min, max) { return Math.min(Math.max(value, min), max); } const GMOCAPY_OVERRIDE_TARGETS = { feed: { countPin: "gmoccapy.feed.feed-override.counts", countEnablePin: "gmoccapy.feed.feed-override.count-enable", analogEnablePin: "gmoccapy.feed.feed-override.analog-enable", directValuePin: "gmoccapy.feed.feed-override.direct-value", resetPin: "gmoccapy.feed.reset-feed-override", countStateKey: "feedOverrideCounts", countEnableStateKey: "feedOverrideCountEnabled", analogEnableStateKey: "feedOverrideAnalogEnabled", stateDomain: "feed", stateKey: "feedOverride", min: 0, max: 200, scale: 1, }, rapid: { countPin: "gmoccapy.rapid.rapid-override.counts", countEnablePin: "gmoccapy.rapid.rapid-override.count-enable", analogEnablePin: "gmoccapy.rapid.rapid-override.analog-enable", directValuePin: "gmoccapy.rapid.rapid-override.direct-value", resetPin: "gmoccapy.rapid.reset-rapid-override", countStateKey: "rapidOverrideCounts", countEnableStateKey: "rapidOverrideCountEnabled", analogEnableStateKey: "rapidOverrideAnalogEnabled", stateDomain: "feed", stateKey: "rapidOverride", min: 0, max: 200, scale: 1, }, spindle: { countPin: "gmoccapy.spindle.spindle-override.counts", countEnablePin: "gmoccapy.spindle.spindle-override.count-enable", analogEnablePin: "gmoccapy.spindle.spindle-override.analog-enable", directValuePin: "gmoccapy.spindle.spindle-override.direct-value", resetPin: "gmoccapy.spindle.reset-spindle-override", countStateKey: "spindleOverrideCounts", countEnableStateKey: "spindleOverrideCountEnabled", analogEnableStateKey: "spindleOverrideAnalogEnabled", stateDomain: "spindle", stateKey: "override", min: 0, max: 150, scale: 1, }, jogVelocity: { countPin: "gmoccapy.jog.jog-velocity.counts", countEnablePin: "gmoccapy.jog.jog-velocity.count-enable", analogEnablePin: "gmoccapy.jog.jog-velocity.analog-enable", directValuePin: "gmoccapy.jog.jog-velocity.direct-value", countStateKey: "jogVelocityCounts", countEnableStateKey: "jogVelocityCountEnabled", analogEnableStateKey: "jogVelocityAnalogEnabled", stateDomain: "gmoccapyGui", stateKey: "jogVelocity", min: 0, max: 14040, scale: 140.4, }, }; function applyGmoccapyHalPinPatch(state, action, model = gmoccapyHalModel) { const pin = normalizeGmoccapyHalPin(action.pin); const value = action.value; if (!pin) { return { applied: false, operatorMessage: "gmoccapy HAL pin blocked: missing pin" }; } if (!isKnownGmoccapyHalPin(pin, model)) { return { applied: false, operatorMessage: `gmoccapy HAL pin unmapped: ${pin}` }; } if (pin === "gmoccapy.ignore-limits") { const enabled = Boolean(value); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { ignoreLimits: enabled, lastHalPinEffect: enabled ? "chk_ignore_limits set active; command.override_limits() requested" : "chk_ignore_limits cleared", }), operatorMessage: enabled ? "gmoccapy HAL ignore-limits requested" : "gmoccapy HAL ignore-limits cleared", }, }; } if (pin === "gmoccapy.optional-stop") { const enabled = Boolean(value); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { optionalBlocks: enabled, lastHalPinEffect: "optional-stop pin drives tbtn_optional_blocks -> set_block_delete", }), operatorMessage: `gmoccapy HAL optional-stop -> block delete ${enabled ? "on" : "off"}`, }, }; } if (pin === "gmoccapy.blockdelete") { const enabled = Boolean(value); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { optionalStop: enabled, lastHalPinEffect: "blockdelete pin drives command.set_optional_stop", }), operatorMessage: `gmoccapy HAL blockdelete -> optional stop ${enabled ? "on" : "off"}`, }, }; } if (pin === "gmoccapy.unlock-settings") { const enabled = Boolean(value); const halUnlockActive = action.halUnlockMode === true || state.gmoccapyGui?.settingsUnlockMode === "hal"; if (!halUnlockActive) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { settingsUnlockPin: enabled, setupSensitive: true, lastHalPinEffect: "unlock-settings ignored because unlock_way is not hal", }), operatorMessage: "gmoccapy HAL unlock-settings ignored: unlock_way is use", }, }; } return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { settingsUnlockMode: "hal", settingsUnlockPin: enabled, setupSensitive: enabled, lastHalPinEffect: `unlock-settings ${enabled ? "enabled" : "disabled"} setup page sensitivity`, }), operatorMessage: `gmoccapy HAL unlock-settings ${enabled ? "enabled setup" : "disabled setup"}`, }, }; } const jogAxisPin = resolveGmoccapyJogAxisPin(pin, model); if (jogAxisPin) { return applyGmoccapyJogAxisHalPatch(state, pin, value, jogAxisPin); } const jogIncrement = resolveGmoccapyJogIncrementPin(pin, model); if (jogIncrement) { return applyGmoccapyJogIncrementHalPatch(state, pin, value, jogIncrement); } if (pin === "gmoccapy.jog.turtle-jog") { const enabled = Boolean(value); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { turtleJog: enabled, lastHalPinEffect: `turtle jog ${enabled ? "enabled" : "disabled"} by level-driven pin`, }), operatorMessage: `gmoccapy HAL turtle jog ${enabled ? "on" : "off"}`, }, }; } if (pin === "gmoccapy.delete-message") { if (!value) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: "delete-message falling edge ignored", }), operatorMessage: "gmoccapy HAL delete-message falling edge ignored", }, }; } const hadError = state.gmoccapyGui?.error === true || /error|blocked|warning/i.test(state.operatorMessage || ""); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { error: false, deletedMessageCount: Number(state.gmoccapyGui?.deletedMessageCount || 0) + 1, lastHalPinEffect: hadError ? "delete-message removed first alert and cleared gmoccapy.error" : "delete-message deleted last notification", }), operatorMessage: hadError ? "gmoccapy HAL delete-message cleared alert" : "gmoccapy HAL delete-message deleted last message", }, }; } if (pin === "gmoccapy.warning-confirm") { const enabled = Boolean(value); return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { warningConfirm: enabled, lastHalPinEffect: enabled ? "warning-confirm level would accept active warning dialog" : "warning-confirm level cleared", }), operatorMessage: enabled ? "gmoccapy HAL warning-confirm asserted" : "gmoccapy HAL warning-confirm cleared", }, }; } const toolMeasurementPin = resolveGmoccapyToolMeasurementPin(pin, model); if (toolMeasurementPin) { return applyGmoccapyToolMeasurementHalPatch(state, pin, value, toolMeasurementPin); } if (pin.startsWith("gmoccapy.messages.")) { return { applied: false, operatorMessage: "gmoccapy HAL user message pin unmapped: gmoccapy_XYZAB.ini defines no MESSAGE_* entries", }; } const overrideTarget = findOverrideTargetForPin(pin); if (overrideTarget) { return applyGmoccapyOverrideHalPatch(state, pin, value, overrideTarget); } return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: "diagnostic-only gmoccapy HAL pin", }), operatorMessage: `gmoccapy HAL diagnostic-only: ${pin}`, }, }; } function normalizeGmoccapyHalPin(pin) { const value = String(pin || "").trim(); if (!value) return ""; return value.startsWith("gmoccapy.") ? value : `gmoccapy.${value}`; } function isKnownGmoccapyHalPin(pin, model = gmoccapyHalModel) { if (model.nativePins.some((group) => group.pins.includes(pin))) return true; return pin.startsWith("gmoccapy.messages."); } function halGuiPatch(state, pin, value, patch = {}) { return { ...state.gmoccapyGui, ...patch, lastHalPin: pin, lastHalPinValue: value, }; } function applyGmoccapyNativePagePatch(state, action, model = gmoccapyHalModel) { const pageId = String(action.pageId || action.page || "").trim(); const page = model.nativePages?.implementationMatrix?.find((entry) => entry.pageId === pageId); if (!page) { return { operatorMessage: `gmoccapy native page unmapped: ${pageId || "unknown"}`, }; } return { gmoccapyGui: { ...state.gmoccapyGui, activeNativePage: page.pageId, nativePageMode: page.implementation, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: `${page.nativeWidget} ${page.implementation}`, }, operatorMessage: page.implementation === "diagnostic-only" || page.implementation === "native-only" ? `gmoccapy native page diagnostic-only: ${page.pageId}` : `gmoccapy native page ${page.pageId}`, }; } function applyGmoccapyPageActionPatch(state, action, model = gmoccapyHalModel) { const pageId = String(action.pageId || action.page || "").trim(); const actionId = String(action.actionId || action.action || "open").trim(); if (pageId === "file-load") { const running = state.machine?.interpState === "reading" || state.runState === "running"; const status = running ? "blocked-running" : actionId; return { gmoccapyGui: { ...state.gmoccapyGui, activeNativePage: "file-load", nativePageMode: model.nativePages.filePage.implementation, filePageStatus: status, filePageLastAction: actionId, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: running ? "file load blocked while interpreter is running" : "IconFileSelection native page represented by Web file/staged-source controls", }, operatorMessage: running ? "gmoccapy file page blocked: interpreter running" : `gmoccapy file page ${actionId}: native Gtk chooser diagnostic`, }; } return { operatorMessage: `gmoccapy page action unmapped: ${pageId || "unknown"}`, }; } function applyGmoccapyMacroPatch(state, action, model = gmoccapyHalModel) { const macroName = String(action.name || action.macro || "").trim(); const macro = model.nativePages?.macroPage?.macros?.find((entry) => entry.name === macroName); if (!macro) { return { applied: false, operatorMessage: `gmoccapy macro unmapped: ${macroName || "unknown"}`, }; } const gate = gateLinuxCncTaskAction(state, { type: "RUN_MDI" }); if (!gate.allowed) { return { applied: true, patch: { gmoccapyGui: { ...state.gmoccapyGui, macroButtonsEnabled: false, macroLastName: macro.name, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: `macro ${macro.name} blocked: ${gate.operatorMessage}`, }, operatorMessage: `gmoccapy macro blocked: ${gate.operatorMessage}`, }, }; } const command = buildGmoccapyMacroCommand(macro, action.args || action.parameters || {}); const mdiResult = executeMdiCommand(state, command); return { applied: true, patch: { ...mdiResult.patch, gmoccapyGui: { ...state.gmoccapyGui, activeNativePage: "mdi-macros", nativePageMode: "partial", macroButtonsEnabled: false, macroLastName: macro.name, macroLastCommand: command, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: `macro ${macro.name} dispatched as MDI O-word call`, }, operatorMessage: `gmoccapy macro MDI ${command}`, }, }; } function buildGmoccapyMacroCommand(macro, args = {}) { const values = Array.isArray(args) ? args : macro.args.map((name) => args[name] ?? args[String(name).toLowerCase()] ?? 0); return [ `O<${macro.name}> call`, ...macro.args.map((name, index) => `[${values[index] ?? 0}]`), ].join(" "); } function applyGmoccapyToolEditorPatch(state, action, model = gmoccapyHalModel) { const actionId = String(action.actionId || action.action || "open").trim(); const writableAction = ["save", "add", "delete", "touch-off"].includes(actionId); if (writableAction && model.nativePages?.toolEditorPage?.editableInWeb === false) { return { gmoccapyGui: { ...state.gmoccapyGui, activeNativePage: "tool-editor", nativePageMode: "diagnostic-only", toolEditorStatus: "writeback-blocked", toolEditorLastAction: actionId, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "tool editor writeback disabled in browser", }, operatorMessage: `gmoccapy tool editor diagnostic-only: ${actionId} does not write tool.tbl`, }; } return { gmoccapyGui: { ...state.gmoccapyGui, activeNativePage: "tool-editor", nativePageMode: "diagnostic-only", toolEditorStatus: actionId, toolEditorLastAction: actionId, lastHalPin: null, lastHalPinValue: null, lastHalPinEffect: "tooledit1 native page represented by diagnostics", }, operatorMessage: `gmoccapy tool editor ${actionId}: ${state.profile?.toolTable?.toolCount || 0} tools diagnostic-only`, }; } function findOverrideTargetForPin(pin) { return Object.entries(GMOCAPY_OVERRIDE_TARGETS) .find(([, target]) => [ target.countPin, target.countEnablePin, target.analogEnablePin, target.directValuePin, target.resetPin, ].includes(pin)) || null; } function resolveGmoccapyToolMeasurementPin(pin, model = gmoccapyHalModel) { return model.halPinActions?.toolMeasurementPins?.pins?.find((entry) => entry.pin === pin) || null; } function resolveGmoccapyJogAxisPin(pin, model = gmoccapyHalModel) { for (const entry of model.halPinActions?.jogPins?.axes || []) { if (pin === entry.plus) return { axis: entry.axis, direction: 1 }; if (pin === entry.minus) return { axis: entry.axis, direction: -1 }; } return null; } function resolveGmoccapyJogIncrementPin(pin, model = gmoccapyHalModel) { return (model.halPinActions?.jogPins?.increments || []).find((entry) => entry.pin === pin) || null; } function applyGmoccapyJogAxisHalPatch(state, pin, value, jogAxisPin) { const axis = String(jogAxisPin.axis || "").toLowerCase(); const direction = Number(jogAxisPin.direction || 1); const axisLabel = axis.toUpperCase(); const sign = direction > 0 ? "+" : "-"; if (!value) { const isActivePin = state.gmoccapyGui?.activeJogPin === pin; return { applied: true, patch: { runState: isActivePin && state.runState === "jogging" ? "idle" : state.runState, gmoccapyGui: halGuiPatch(state, pin, value, { activeJogPin: isActivePin ? null : state.gmoccapyGui?.activeJogPin || null, lastHalPinEffect: `jog ${axisLabel}${sign} released`, }), operatorMessage: `gmoccapy HAL jog ${axisLabel}${sign} released`, }, }; } const gate = gateLinuxCncTaskAction(state, { type: "JOG", axis, direction }); if (!gate.allowed) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: `jog ${axisLabel}${sign} ignored: ${gate.operatorMessage}`, }), operatorMessage: `gmoccapy HAL jog blocked: ${gate.operatorMessage}`, }, }; } const increment = Number(state.gmoccapyGui?.jogIncrementOutput ?? state.machine?.jogIncrement ?? 0); const continuous = Math.abs(increment) <= 0; const nextAxisPose = continuous ? state.axisPose : { ...state.axisPose, [axis]: Number(state.axisPose?.[axis] || 0) + direction * increment, }; return { applied: true, patch: { machine: { ...state.machine, mode: "manual", jogAxis: axis, jogIncrement: increment, }, axisPose: nextAxisPose, runState: "jogging", gmoccapyGui: halGuiPatch(state, pin, value, { activeJogPin: pin, lastHalPinEffect: continuous ? `jog ${axisLabel}${sign} continuous press` : `jog ${axisLabel}${sign} incremental ${increment}`, }), operatorMessage: continuous ? `gmoccapy HAL jog ${axisLabel}${sign} continuous` : `gmoccapy HAL jog ${axisLabel}${sign} ${increment}`, }, }; } function applyGmoccapyJogIncrementHalPatch(state, pin, value, jogIncrement) { if (!value) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: `jog increment ${jogIncrement.index} falling edge ignored`, }), operatorMessage: "gmoccapy HAL jog increment falling edge ignored", }, }; } const distance = Number(jogIncrement.distance || 0); const label = String(jogIncrement.label || `increment ${jogIncrement.index}`); return { applied: true, patch: { machine: { ...state.machine, jogIncrement: distance, }, runState: state.machine.mode === "manual" && state.runState === "jogging" ? "idle" : state.runState, gmoccapyGui: halGuiPatch(state, pin, value, { jogIncrementIndex: Number(jogIncrement.index), jogIncrementLabel: label, jogIncrementOutput: distance, activeJogPin: null, lastHalPinEffect: `jog increment selected ${label}; gmoccapy.jog.jog-increment=${distance}`, }), operatorMessage: `gmoccapy HAL jog increment ${label}`, }, }; } function applyGmoccapyToolMeasurementHalPatch(state, pin, value, toolMeasurementPin) { const nextValue = toolMeasurementPin.pin === "gmoccapy.toolmeasurement" ? Boolean(value) : Number(value || 0); const patch = { lastHalPinEffect: "tool measurement HAL OUT pin recorded as diagnostic-only in Web", }; if (toolMeasurementPin.pin === "gmoccapy.probeheight") patch.probeHeight = nextValue; if (toolMeasurementPin.pin === "gmoccapy.blockheight") patch.blockHeight = nextValue; if (toolMeasurementPin.pin === "gmoccapy.toolmeasurement") patch.toolMeasurement = nextValue; if (toolMeasurementPin.pin === "gmoccapy.searchvel") patch.searchVelocity = nextValue; if (toolMeasurementPin.pin === "gmoccapy.probevel") patch.probeVelocity = nextValue; return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, patch), operatorMessage: "gmoccapy HAL tool measurement output recorded; XYZAB has no [TOOLSENSOR]", }, }; } function applyGmoccapyOverrideHalPatch(state, pin, value, [targetName, target]) { if (pin === target.countEnablePin || pin === target.analogEnablePin) { const enabled = Boolean(value); const key = pin === target.countEnablePin ? target.countEnableStateKey : target.analogEnableStateKey; return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { [key]: enabled, lastHalPinEffect: `${targetName} ${pin === target.countEnablePin ? "count" : "analog"} input ${enabled ? "enabled" : "disabled"}`, }), operatorMessage: `gmoccapy HAL ${targetName} ${pin === target.countEnablePin ? "counts" : "analog"} ${enabled ? "enabled" : "disabled"}`, }, }; } if (pin === target.resetPin) { if (!value) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: `${targetName} reset falling edge ignored`, }), operatorMessage: `gmoccapy HAL ${targetName} reset falling edge ignored`, }, }; } return { applied: true, patch: resetOverridePatchForTarget(state, targetName, { pin, value }), }; } if (pin === target.directValuePin) { if (!state.gmoccapyGui?.[target.analogEnableStateKey]) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { lastHalPinEffect: `${targetName} direct-value ignored until analog-enable is true`, }), operatorMessage: `gmoccapy HAL ${targetName} direct-value ignored: analog disabled`, }, }; } const normalized = clampNumber(Number(value), 0, 1); const nextValue = target.min + (target.max - target.min) * normalized; return overrideValuePatch(state, targetName, target, nextValue, { pin, value, effect: `${targetName} direct-value ${normalized}`, operatorMessage: `gmoccapy HAL ${targetName} direct-value ${Math.round(nextValue)}`, }); } if (pin === target.countPin) { const counts = Number(value); if (!Number.isFinite(counts)) { return { applied: false, operatorMessage: `gmoccapy HAL ${targetName} counts invalid: ${value}` }; } if (!state.gmoccapyGui?.[target.countEnableStateKey]) { return { applied: true, patch: { gmoccapyGui: halGuiPatch(state, pin, value, { [target.countStateKey]: counts, lastHalPinEffect: `${targetName} counts synchronized while count-enable is false`, }), operatorMessage: `gmoccapy HAL ${targetName} counts synchronized`, }, }; } const previousCounts = Number(state.gmoccapyGui?.[target.countStateKey] || 0); const delta = (counts - previousCounts) * target.scale; const currentValue = currentOverrideValue(state, target); return overrideValuePatch(state, targetName, target, currentValue + delta, { pin, value, countPatch: { [target.countStateKey]: counts }, effect: `${targetName} counts delta ${delta}`, operatorMessage: `gmoccapy HAL ${targetName} counts adjusted`, }); } return { applied: false, operatorMessage: `gmoccapy HAL override pin unmapped: ${pin}` }; } function overrideValuePatch(state, targetName, target, rawValue, { pin, value, countPatch = {}, effect, operatorMessage, } = {}) { const nextValue = clampPercent(rawValue, target.min, target.max); const gmoccapyGui = halGuiPatch(state, pin, value, { ...countPatch, lastHalPinEffect: effect, }); if (target.stateDomain === "feed") { return { applied: true, patch: { feed: { ...state.feed, [target.stateKey]: Math.round(nextValue), }, gmoccapyGui, operatorMessage, }, }; } if (target.stateDomain === "spindle") { return { applied: true, patch: { spindle: { ...state.spindle, [target.stateKey]: Math.round(nextValue), }, gmoccapyGui, operatorMessage, }, }; } return { applied: true, patch: { gmoccapyGui: { ...gmoccapyGui, [target.stateKey]: Number(nextValue.toFixed(3)), }, operatorMessage, }, }; } function currentOverrideValue(state, target) { if (target.stateDomain === "feed") return Number(state.feed?.[target.stateKey] || 0); if (target.stateDomain === "spindle") return Number(state.spindle?.[target.stateKey] || 0); return Number(state.gmoccapyGui?.[target.stateKey] || 0); } function resetOverridePatchForTarget(state, targetName, hal = {}) { const target = GMOCAPY_OVERRIDE_TARGETS[targetName]; if (!target || targetName === "jogVelocity") { return { gmoccapyGui: halGuiPatch(state, hal.pin || null, hal.value ?? null, { lastHalPinEffect: `reset unsupported for ${targetName}`, }), operatorMessage: `override reset unsupported: ${targetName}`, }; } const effect = `${targetName} override reset to 100`; const gmoccapyGui = halGuiPatch(state, hal.pin || null, hal.value ?? null, { lastHalPinEffect: hal.pin ? `HAL ${effect}` : effect, }); if (target.stateDomain === "spindle") { return { spindle: { ...state.spindle, [target.stateKey]: 100, }, gmoccapyGui, operatorMessage: hal.pin ? `gmoccapy HAL ${targetName} reset to 100` : `${targetName} override reset`, }; } return { feed: { ...state.feed, [target.stateKey]: 100, }, gmoccapyGui, operatorMessage: hal.pin ? `gmoccapy HAL ${targetName} reset to 100` : `${targetName} override reset`, }; } function clampNumber(value, min, max) { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); } function buildDroFromFrame(frame, runtimeFeedback = null) { const dtg = runtimeFeedback?.dtg || null; return { x: frame.axisPose.x, y: frame.axisPose.y, z: frame.axisPose.z, a: frame.axisPose.a, b: frame.axisPose.b, c: frame.axisPose.c, tcpX: frame.tcpPose.x, tcpY: frame.tcpPose.y, tcpZ: frame.tcpPose.z, dtgX: Number.isFinite(dtg?.x) ? dtg.x : frame.rtcpEnabled ? Math.abs(frame.compensation.x) : 0, dtgY: Number.isFinite(dtg?.y) ? dtg.y : frame.rtcpEnabled ? Math.abs(frame.compensation.y) : 0.01, dtgZ: Number.isFinite(dtg?.z) ? dtg.z : frame.rtcpEnabled ? Math.abs(frame.compensation.z) : 2.25, }; } function jointsFromAxisPose(axisPose, profile = defaultProfile) { const fourthAxis = profile.traj?.coordinates?.includes("B") ? "b" : "a"; return [ Number(axisPose.x || 0), Number(axisPose.y || 0), Number(axisPose.z || 0), Number(axisPose[fourthAxis] || 0), Number(axisPose.c || 0), ]; } function buildFixtureAxisPoseForLine(axisPose, line) { const phase = (line - 496) * 0.17; return { ...axisPose, x: 43 + Math.sin(phase) * 4, y: -32.15 + Math.cos(phase) * 2.5, z: -11.306 + Math.sin(phase * 0.7) * 1.2, a: Math.sin(phase * 0.45) * 18, c: Math.cos(phase * 0.33) * 32, }; } function axisPoseFromCanonicalMotion(motion, fallbackPose) { const axes = motion?.axes || {}; return { x: Number(axes.x ?? fallbackPose.x ?? 0), y: Number(axes.y ?? fallbackPose.y ?? 0), z: Number(axes.z ?? fallbackPose.z ?? 0), a: Number(axes.a ?? fallbackPose.a ?? 0), b: Number(axes.b ?? fallbackPose.b ?? 0), c: Number(axes.c ?? fallbackPose.c ?? 0), }; } function axisPoseFromRuntimeSample(sample, motion, fallbackPose) { const sampleAxes = sample?.axes || {}; const canonicalAxes = motion?.axes || {}; return { x: numberOrFallback(sampleAxes.x, canonicalAxes.x, fallbackPose.x, 0), y: numberOrFallback(sampleAxes.y, canonicalAxes.y, fallbackPose.y, 0), z: numberOrFallback(sampleAxes.z, canonicalAxes.z, fallbackPose.z, 0), a: numberOrFallback(sampleAxes.a, canonicalAxes.a, fallbackPose.a, 0), b: numberOrFallback(sampleAxes.b, canonicalAxes.b, fallbackPose.b, 0), c: numberOrFallback(sampleAxes.c, canonicalAxes.c, fallbackPose.c, 0), }; } function createProgramRuntimeFeedbackFromSample({ state, sample, sampleIndex, motion, motionIndex, segment, currentVelocity, elapsedSeconds, }) { const axisPose = axisPoseFromRuntimeSample(sample, motion, state.axisPose); return { apiName: "web-rtcp-5axis-program-runtime-feedback", sourceMode: "linuxcnc-tp-runtime-sample", semanticBoundary: "linuxcnc_tp_run_cycle_feedback_without_hardware", sampleIndex, motionIndex, line: sample?.line || motion?.line || null, type: sample?.type || motion?.type || null, linearUnits: sample?.linearUnits || motion?.linearUnits || state.profile.traj?.linearUnits || "mm", timeSeconds: elapsedSeconds, axisPose, currentVelocityMmPerMin: currentVelocity, requestedVelocityMmPerMin: Number(sample?.requestedVelocityMmPerMin) || Number(sample?.requestedVelocity) * 60 || Number(segment?.velocityMmPerMin) || 0, distanceToGo: Number(sample?.distanceToGo) || 0, dtg: { x: Number(sample?.dtg?.x) || 0, y: Number(sample?.dtg?.y) || 0, z: Number(sample?.dtg?.z) || 0, }, queueDepth: Number(sample?.queueDepth) || 0, activeDepth: Number(sample?.activeDepth) || 0, cycle: Number(sample?.cycle) || 0, }; } function createProgramRuntimeFeedbackFromMotion({ state, motion, motionIndex, timing, sourceMode, }) { const axisPose = axisPoseFromCanonicalMotion(motion, state.axisPose); return { apiName: "web-rtcp-5axis-program-runtime-feedback", sourceMode, semanticBoundary: sourceMode === "fixture-line-playback" ? "fixture_line_playback_feedback" : "linuxcnc_canonical_motion_feedback_without_tp_sample", sampleIndex: motionIndex, motionIndex, line: motion?.line || null, type: motion?.type || null, linearUnits: motion?.linearUnits || state.profile.traj?.linearUnits || "mm", timeSeconds: Number(timing?.elapsedSeconds) || 0, axisPose, currentVelocityMmPerMin: Number(timing?.currentVelocity) || 0, requestedVelocityMmPerMin: Number(timing?.currentVelocity) || 0, distanceToGo: 0, dtg: { x: 0, y: 0, z: 0 }, queueDepth: 0, activeDepth: 0, cycle: 0, }; } function numberOrFallback(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number)) return number; } return 0; }