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, 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, gateLinuxCncTaskAction, normalizeLinuxCncTaskMode, } from "./linuxcnc-task-policy.js"; import { buildProgramExecutionTiming, timingAtMotionIndex } from "../runtime/execution-timing.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: 43.0, y: -32.15, z: -11.306, 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; } 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, taskState: "estop-reset", mode: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, manualPanel: "manual", allHomed: false, noForceHoming: false, 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: 43.0, y: -32.15, z: -11.306, 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, programElapsedSeconds: 0, programRemainingSeconds: 0, programExecutionSourceMode: "fixture-line-playback", programExecutionMotionIndex: 0, programExecutionSampleIndex: 0, programRuntimeFeedback: null, programRuntimeFeedbackHistory: [], programLineExecution: {}, taskHalRuntime: null, taskHalRuntimeReadiness: null, taskHalStatus: null, taskHalSession: null, taskHalExecutionPending: false, taskHalExecutionSequence: 0, taskHalStatusLoop: createTaskHalStatusLoopState(), taskHalFallbackReason: null, pendingJogCommand: null, interpreterExecutionPending: false, interpreterExecutionSequence: 0, machineFileExecution: null, toolDbSimulation: null, toolDbReadiness: createToolDbReadiness(null), 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: 43.0, y: -32.15, z: -11.306, 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", }, 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); 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, }; state.linuxCncTaskPolicy = createLinuxCncTaskPolicyStatus(state); state.machineProject = createMachineProjectState(state); state.programValidation = createProgramValidationState(state); state.rightSidebarEntrances = createRightSidebarEntranceState(state, state.linuxCncTaskPolicy); state.linuxCncParityMatrix = createLinuxCncParityMatrix(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), 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 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), ...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, }); setState({ programExecution: execution, programExecutionTiming: timing, programExecutionSourceMode: execution.sourceMode, machineFileExecution: execution.machineFilePlan ? execution : state.machineFileExecution, programExecutionMotionIndex: 0, programExecutionSampleIndex: 0, programRuntimeFeedback: firstFeedback, programLineExecution: createProgramLineExecutionPatch(state.programLineExecution, firstFeedback, { status: "ready", source: execution.sourceMode, }), programElapsedSeconds: firstTiming.elapsedSeconds, programRemainingSeconds: firstTiming.remainingSeconds, interpreterExecutionPending: false, activeLine: firstMotion?.line || state.programStartLine, axisPose: axisPoseFromCanonicalMotion(firstMotion, state.axisPose), kinsType: firstKinsType, rtcpState: rtcpStateFromKinsType(firstKinsType), preview: { ...state.preview, pathPoints: 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, 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, }); setState({ ...loadedProgram, ...toolUserPatch, machineFileStaging: { ...state.machineFileStaging, plan: selectedPlan, selectedGcodeSourceRel: sourceRel, }, machine: { ...state.machine, mode: state.machine.mode, }, axisPose: initialAxisPose, runState: "idle", programRuntimeFeedback: null, programLineExecution: {}, preview: { ...state.preview, pathPoints: 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, 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, 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 "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 turningOff = state.machine.taskState === "on" || state.machine.powerOn; if (state.taskHalRuntime?.loaded) { setState({ machine: { ...state.machine, powerOn: !turningOff, estopActive: false, taskState: turningOff ? "estop-reset" : "on", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: turningOff ? "powered-off" : "idle", kinsType: turningOff ? "identity" : state.kinsType, rtcpState: turningOff ? "off" : state.rtcpState, feed: turningOff ? { ...state.feed, currentVelocity: 0 } : state.feed, coolant: turningOff ? { ...state.coolant, flood: false, mist: false } : state.coolant, spindle: turningOff ? { ...state.spindle, enabled: false, direction: "stop" } : state.spindle, operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on", }); runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: turningOff ? "ESTOP_RESET" : "ON" }, ], { operatorMessage: turningOff ? "task/HAL machine power off" : "task/HAL machine power on" }).catch(() => {}); break; } setState({ machine: { ...state.machine, powerOn: !turningOff, estopActive: false, taskState: turningOff ? "estop-reset" : "on", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: turningOff ? "powered-off" : "idle", kinsType: turningOff ? "identity" : state.kinsType, rtcpState: turningOff ? "off" : state.rtcpState, feed: turningOff ? { ...state.feed, currentVelocity: 0 } : state.feed, coolant: turningOff ? { ...state.coolant, flood: false, mist: false } : state.coolant, spindle: turningOff ? { ...state.spindle, enabled: false, direction: "stop" } : state.spindle, operatorMessage: turningOff ? "machine power off" : "machine power on", }); } break; case "ESTOP": setState({ machine: { ...state.machine, powerOn: false, estopActive: true, taskState: "estop", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "estopped", kinsType: "identity", rtcpState: "off", feed: { ...state.feed, currentVelocity: 0, }, coolant: { ...state.coolant, flood: false, mist: false, }, spindle: { ...state.spindle, enabled: false, direction: "stop", }, operatorMessage: "emergency stop active", }); break; case "RESET": setState({ machine: { ...state.machine, powerOn: false, estopActive: false, taskState: "estop-reset", manualPanel: "manual", interpState: "idle", interpResumeState: "idle", taskPaused: false, resetCount: state.machine.resetCount + 1, }, runState: "idle", kinsType: "identity", rtcpState: "off", coolant: { ...state.coolant, flood: false, mist: false, }, spindle: { ...state.spindle, enabled: false, direction: "stop", }, operatorMessage: "estop reset; machine off", }); 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) { runTaskHalCommandSequence([ { type: "EMC_TASK_SET_MODE", mode: mode.toUpperCase() }, ], { operatorMessage: `task/HAL mode ${mode}`, preserveMachine: { ...state.machine, mode, manualPanel, }, }).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, }, 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, }, 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, }, 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 gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { const command = normalizeMdiCommand(action.command ?? state.machine.mdiCommand); const mdiResult = executeMdiCommand(state, command); setState(mdiResult.patch); runTaskHalCommandSequence([ { type: "EMC_TASK_SET_MODE", mode: "MDI" }, { type: "EMC_TASK_PLAN_EXECUTE", mdi: command }, ], { operatorMessage: `task/HAL MDI ${command}`, preserveAxisPose: mdiResult.patch.axisPose, preserveMachine: mdiResult.patch.machine, }).catch(() => {}); break; } const mdiResult = executeMdiCommand(state, action.command ?? state.machine.mdiCommand); setState(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; setState({ ...loadedProgram, toolDbSimulation, toolDbReadiness: createToolDbReadiness(toolDbSimulation), machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, axisPose: initialAxisPose, runState: "idle", programRuntimeFeedback: null, programLineExecution: {}, preview: { ...state.preview, pathPoints: 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; } runValidatedTaskHalProgramRun().catch(() => {}); break; } const playback = nextProgramRuntimeSamplePlayback(state, 5); setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpState: playback.complete ? "idle" : "reading", interpResumeState: playback.complete ? "idle" : "reading", taskPaused: false, }, runState: playback.complete ? "complete" : "running", activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, programExecutionMotionIndex: playback.motionIndex, programExecutionSampleIndex: playback.sampleIndex, programRuntimeFeedback: playback.runtimeFeedback, 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, }, }) .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, }, runState: "stopped", 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); setState({ spindle: { ...state.spindle, enabled: direction !== "stop", direction, }, 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" }); runTaskHalCommandSequence([ { type: "EMC_TASK_PLAN_PAUSE" }, ], { operatorMessage: "task/HAL program paused" }).catch(() => {}); break; } setState({ machine: { ...state.machine, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState : state.machine.interpState, interpState: "paused", taskPaused: true, }, runState: "paused", operatorMessage: "program paused", }); } break; case "RESUME": { const gate = gateLinuxCncTaskAction(state, action); if (!gate.allowed) { setState({ operatorMessage: gate.operatorMessage }); break; } if (state.taskHalRuntime?.loaded) { 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, interpState: resumeState, interpResumeState: resumeState, taskPaused: false, }, runState: resumeState === "reading" ? "running" : "idle", 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" }); runTaskHalCommandSequence([ { type: "EMC_TASK_PLAN_STEP" }, ], { taskCycles: 1, operatorMessage: "task/HAL stepped one cycle", }).catch(() => {}); break; } const playback = nextProgramRuntimeSamplePlayback(state, 1); setState({ machine: { ...state.machine, mode: "auto", manualPanel: null, interpResumeState: state.machine.interpState === "paused" ? state.machine.interpResumeState : state.machine.interpState, interpState: "paused", taskPaused: true, }, runState: "stepping", activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, programExecutionMotionIndex: playback.motionIndex, programExecutionSampleIndex: playback.sampleIndex, programRuntimeFeedback: playback.runtimeFeedback, 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); setState({ machine: { ...state.machine, interpState: playback.complete ? "idle" : "reading", interpResumeState: playback.complete ? "idle" : "reading", taskPaused: false, }, runState: "running", activeLine: playback.activeLine, axisPose: playback.axisPose, kinsType: playback.kinsType, rtcpState: playback.rtcpState, programExecutionMotionIndex: playback.motionIndex, programExecutionSampleIndex: playback.sampleIndex, programRuntimeFeedback: playback.runtimeFeedback, 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; } if (state.taskHalRuntime?.loaded) { setState({ machine: { ...state.machine, mode: "manual", manualPanel: "manual", allHomed: true, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "idle", axisPose: initialAxisPose, programRuntimeFeedback: null, operatorMessage: "task/HAL home requested", }); runTaskHalCommandSequence([ { type: "EMC_JOINT_HOME", joint: -1 }, ], { operatorMessage: "task/HAL machine homed", preserveMachine: { ...state.machine, manualPanel: "manual", allHomed: true, }, }).catch(() => {}); break; } setState({ machine: { ...state.machine, mode: "manual", manualPanel: "manual", allHomed: true, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "idle", axisPose: initialAxisPose, programRuntimeFeedback: null, operatorMessage: "machine homed to fixture origin", }); } break; case "UNHOME": setState({ machine: { ...state.machine, allHomed: false, }, 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), }, 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.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; } 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), 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), 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), 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(); dispatch({ type: "TASK_HAL_STATUS_APPLIED", status, preserveMachine, 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 }); } const loadedMotionPlan = await loadTaskHalMotionPlanForSession(state.taskHalSession); 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; } stopTaskHalStatusLoop("restarted", { operatorMessage: "task/HAL status loop restarting", }); 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 (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 }); await runTaskHalCommandSequence([ { type: "EMC_TASK_SET_STATE", state: "ON" }, { type: "EMC_JOINT_HOME", joint: -1 }, { type: "EMC_TASK_SET_MODE", mode: "AUTO" }, ], { taskCycles: 3, operatorMessage: "RUN ready: power on, homed, auto mode", allowFixtureSession: false, preserveMachine: { powerOn: true, estopActive: false, taskState: "on", mode: "auto", manualPanel: null, allHomed: true, }, }); const tcpKinsType = tcpKinsTypeForProfile(state.profile); if (tcpKinsType) { dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType }); } return state; } const tcpKinsType = tcpKinsTypeForProfile(state.profile); setState({ machine: { ...state.machine, powerOn: true, estopActive: false, taskState: "on", mode: "auto", manualPanel: null, allHomed: true, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: "idle", kinsType: tcpKinsType || "identity", rtcpState: tcpKinsType ? "on" : "off", operatorMessage: tcpKinsType ? "RUN ready: power on, homed, auto mode" : "RUN ready: power on, homed, auto mode; TCP unavailable for this reference profile", }); return state; }; const loadTaskHalMotionPlanForSession = async (session = state.taskHalSession) => { if (!state.taskHalRuntime?.loaded || typeof state.taskHalRuntime.loadProgramMotionPlan !== "function") { return null; } 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 plan = buildTaskHalProgramMotionPlan({ programPath: session.programPath, motion, timing, programLines: state.programLines, linearUnits: timing.linearUnits || state.profile?.traj?.linearUnits || "mm", }); if (plan.segmentCount <= 0) { return null; } await state.taskHalRuntime.loadProgramMotionPlan(plan); return plan; }; 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) { 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.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), }; } 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), }; } 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), 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 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 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 rawTaskState = normalizeTaskHalTaskState(ui.taskState || task.state); const taskState = preserveMachine?.powerOn && rawTaskState === "estop-reset" ? "on" : rawTaskState; const taskMode = normalizeLinuxCncTaskMode(preserveMachine?.mode || ui.taskMode || task.mode || state.machine.mode); const manualPanel = taskMode === "manual" ? (preserveMachine?.manualPanel || state.machine.manualPanel || "manual") : null; const interpState = Object.hasOwn(preserveMachine || {}, "interpState") ? normalizeTaskHalInterpState(preserveMachine.interpState) : normalizeTaskHalInterpState(ui.interpState || task.interpState); const allHomed = Boolean(preserveMachine?.allHomed ?? state.machine.allHomed); const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0); const kinsType = resolveTaskHalKinsType(state, status, activeLine); const axisPose = preserveAxisPose ? clampAxisPoseToProfile({ ...state.axisPose, ...preserveAxisPose }, state.profile) : resolveTaskHalAxisPose(state, status); const currentVelocity = Number.isFinite(ui.currentVelocity) ? Math.max(ui.currentVelocity, 0) : state.feed.currentVelocity; const paused = interpState === "paused" || motion.paused === true; 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 loopActive = state.taskHalStatusLoop?.active === true && loopSequence !== null && Number(state.taskHalStatusLoop.sequence) === Number(loopSequence) && (runState === "running" || runState === "mdi"); const nextTickCount = loopActive ? Number(state.taskHalStatusLoop.tickCount || 0) + 1 : Number(state.taskHalStatusLoop?.tickCount || 0); const feedbackHistory = [runtimeFeedback, ...(state.programRuntimeFeedbackHistory || [])].slice(0, 100); const lineStatus = runState === "complete" ? "done" : runState === "running" || runState === "mdi" ? "running" : runState; return { taskHalStatus: status, taskHalExecutionPending: false, taskHalFallbackReason: null, pendingJogCommand: null, activeLine, axisPose, kinsType, rtcpState: rtcpStateFromKinsType(kinsType), programExecutionSourceMode: "linuxcnc-task-motion-hal-wasm", machine: { ...state.machine, powerOn: taskState === "on", estopActive: taskState === "estop", taskState, mode: taskMode, manualPanel, interpState, interpResumeState: paused ? state.machine.interpResumeState || "reading" : interpState, taskPaused: paused, allHomed, 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, }, programRuntimeFeedback: runtimeFeedback, programRuntimeFeedbackHistory: feedbackHistory, programLineExecution: createProgramLineExecutionPatch(state.programLineExecution, runtimeFeedback, { 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" || motion.paused === true; const openedProgramLineCount = Number(task.openedSourceLineCount || task.openedLineCount || 1); const complete = interpState === "idle" && Number(task.nextProgramLine || 0) >= openedProgramLineCount; return !aborted && !paused && !complete && (interpState === "reading" || taskMode === "mdi"); } function createStoppedProgramStatePatch(state, { reason = "stopped", operatorMessage = "program stopped", } = {}) { return { machine: { ...state.machine, interpState: "idle", interpResumeState: "idle", taskPaused: false, }, runState: reason === "aborted" ? "stopped" : reason, taskHalStatusLoop: { ...state.taskHalStatusLoop, active: false, sequence: Number(state.taskHalStatusLoop?.sequence || 0) + 1, stopReason: reason, lastStatusAt: new Date().toISOString(), }, feed: { ...state.feed, currentVelocity: 0, }, 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) { 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 (!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); 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", timeSeconds: Number(ui.taskCycle || 0) * 0.01, axisPose, currentVelocityMmPerMin: Number(ui.currentVelocity || 0), requestedVelocityMmPerMin: Number(motion.requestedVel || 0) * 60, 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 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"; if (state === "off") return "off"; return "estop-reset"; } function normalizeTaskHalInterpState(value) { const state = String(value || "").toLowerCase(); if (state === "paused") return "paused"; if (state === "reading") return "reading"; return "idle"; } 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 taskState = machine.estopActive ? "estop" : machine.taskState === "on" || machine.powerOn ? "on" : machine.taskState === "off" ? "off" : "estop-reset"; const mode = normalizeLinuxCncTaskMode(machine.mode); const interpState = machine.interpState || (runState === "running" ? "reading" : runState === "paused" || runState === "stepping" ? "paused" : "idle"); const manualPanel = mode === "manual" ? (machine.manualPanel === "jog" ? "jog" : "manual") : null; return { ...machine, taskState, mode, manualPanel, interpState, interpResumeState: machine.interpResumeState || (interpState === "paused" ? "reading" : interpState), taskPaused: Boolean(machine.taskPaused || interpState === "paused"), powerOn: taskState === "on", estopActive: taskState === "estop", allHomed: Boolean(machine.allHomed), noForceHoming: Boolean(machine.noForceHoming), }; } 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 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, } : 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 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 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 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 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; }