diff --git a/apps/web/src/routes/Monitor/index.tsx b/apps/web/src/routes/Monitor/index.tsx index 7cbb30478..b3c064428 100644 --- a/apps/web/src/routes/Monitor/index.tsx +++ b/apps/web/src/routes/Monitor/index.tsx @@ -36,6 +36,7 @@ import { usePlannerQueue, useRxBufferSize, useFeedrate, + useAvailableAxes, } from '@/store/hooks' import { machineStateSync } from '@/services/machineStateSync' import { Vector3 } from 'three' @@ -600,6 +601,7 @@ function ProgressPanel({ const { machinePosition = { x: 0, y: 0, z: 0 }, workPosition = { x: 0, y: 0, z: 0 }, + availableAxes = ['x', 'y', 'z'], spindleState = 'M5', spindleSpeed = 0, senderState, @@ -611,12 +613,24 @@ function ProgressPanel({ const isOn = spindleState === 'M3' || spindleState === 'M4' const direction = spindleState === 'M4' ? 'CCW' : 'CW' - // Axis data - const axes = [ - { axis: 'X' as const, color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/30', mpos: machinePosition.x, wpos: workPosition.x }, - { axis: 'Y' as const, color: 'text-green-500', bgColor: 'bg-green-500/10', borderColor: 'border-green-500/30', mpos: machinePosition.y, wpos: workPosition.y }, - { axis: 'Z' as const, color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/30', mpos: machinePosition.z, wpos: workPosition.z }, - ] + // Axis color/style configuration + const AXIS_STYLES: Record = { + X: { color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/30' }, + Y: { color: 'text-green-500', bgColor: 'bg-green-500/10', borderColor: 'border-green-500/30' }, + Z: { color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/30' }, + A: { color: 'text-orange-500', bgColor: 'bg-orange-500/10', borderColor: 'border-orange-500/30' }, + B: { color: 'text-purple-500', bgColor: 'bg-purple-500/10', borderColor: 'border-purple-500/30' }, + C: { color: 'text-cyan-500', bgColor: 'bg-cyan-500/10', borderColor: 'border-cyan-500/30' }, + } + + // Axis data - dynamically built from available axes + const axes = (availableAxes as string[]).map(a => { + const upper = a.toUpperCase() + const style = AXIS_STYLES[upper] || AXIS_STYLES.X + const mpos = (machinePosition as Record)[a] ?? 0 + const wpos = (workPosition as Record)[a] ?? 0 + return { axis: upper, ...style, mpos, wpos } + }) // Time data from backend (in milliseconds) const elapsedMs = senderState?.elapsedTime ?? 0 @@ -1002,6 +1016,7 @@ export default function Monitor() { const workflowState = useWorkflowState() const machinePosition = useMachinePosition() const workPosition = useWorkPosition() + const availableAxes = useAvailableAxes() const spindleState = useSpindleState() const spindleSpeed = useSpindleSpeed() const maxSpindleSpeed = machineState.maxSpindleSpeed @@ -1196,6 +1211,7 @@ export default function Monitor() { onFlashStatus: flashStatus, machinePosition, workPosition, + availableAxes, spindleState, spindleSpeed, senderState: jobState, // Use jobState from Redux diff --git a/apps/web/src/routes/Monitor/panels/CurrentStatsPanel.tsx b/apps/web/src/routes/Monitor/panels/CurrentStatsPanel.tsx index 723474b15..1a3c7bc8a 100644 --- a/apps/web/src/routes/Monitor/panels/CurrentStatsPanel.tsx +++ b/apps/web/src/routes/Monitor/panels/CurrentStatsPanel.tsx @@ -1,59 +1,66 @@ import { useTranslation } from 'react-i18next' import type { PanelProps } from '../../Setup/types' +// Axis color mapping for distance display +const AXIS_COLORS: Record = { + X: 'text-red-500', + Y: 'text-green-500', + Z: 'text-blue-500', + A: 'text-orange-500', + B: 'text-purple-500', + C: 'text-cyan-500', +} + export function CurrentStatsPanel(props: PanelProps) { const { t } = useTranslation() const stats = props.senderState?.stats - + const availableAxes = props.availableAxes || ['x', 'y', 'z'] + // Get distances from stats const totalDistance = stats?.totalDistance || { x: 0, y: 0, z: 0, total: 0 } const cuttingDistance = stats?.cuttingDistance || { x: 0, y: 0, z: 0, total: 0 } const transitionDistance = stats?.transitionDistance || { x: 0, y: 0, z: 0, total: 0 } - const retractDistance = stats?.retractDistance || { x: 0, y: 0, z: 0, total: 0 } - + // Calculate operation type breakdown (for pie chart) const totalDistanceTotal = totalDistance.total || 1 // Avoid division by zero const cuttingPercent = totalDistanceTotal > 0 ? (cuttingDistance.total / totalDistanceTotal) * 100 : 0 const transitionPercent = totalDistanceTotal > 0 ? (transitionDistance.total / totalDistanceTotal) * 100 : 0 - const retractPercent = totalDistanceTotal > 0 ? (retractDistance.total / totalDistanceTotal) * 100 : 0 - + const retractPercent = 100 - cuttingPercent - transitionPercent + const operationTypes = [ { type: t('Cutting'), percent: cuttingPercent, color: 'rgb(59 130 246)', bgColor: 'bg-blue-500', distance: cuttingDistance.total }, { type: t('Transition'), percent: transitionPercent, color: 'rgb(34 197 94)', bgColor: 'bg-green-500', distance: transitionDistance.total }, - { type: t('Retract'), percent: retractPercent, color: 'rgb(249 115 22)', bgColor: 'bg-orange-500', distance: retractDistance.total }, + { type: t('Retract'), percent: retractPercent > 0 ? retractPercent : 0, color: 'rgb(249 115 22)', bgColor: 'bg-orange-500', distance: (totalDistance.total || 0) - cuttingDistance.total - transitionDistance.total }, ].filter(op => op.percent > 0) // Only show operations with distance - - // Use real travel distances from stats - const totalTravelX = totalDistance.x || 0 - const totalTravelY = totalDistance.y || 0 - const totalTravelZ = totalDistance.z || 0 - const totalDistanceSum = totalTravelX + totalTravelY + totalTravelZ - + + // Build per-axis distance data dynamically + const axisDistances = availableAxes.map(a => { + const dist = (totalDistance as Record)[a] || 0 + const upper = a.toUpperCase() + const isRotary = a === 'a' || a === 'b' || a === 'c' + return { axis: upper, distance: dist, unit: isRotary ? t('deg') : t('mm'), color: AXIS_COLORS[upper] || 'text-muted-foreground' } + }) + const totalDistanceSum = axisDistances.reduce((sum, a) => sum + a.distance, 0) + return (
{/* Total distance traveled */}
{t('Total Distance')}
-
- X: - {totalTravelX.toFixed(1)} {t('mm')} -
-
- Y: - {totalTravelY.toFixed(1)} {t('mm')} -
-
- Z: - {totalTravelZ.toFixed(1)} {t('mm')} -
+ {axisDistances.map(({ axis, distance, unit, color }) => ( +
+ {axis}: + {distance.toFixed(1)} {unit} +
+ ))}
{t('Total:')} {totalDistanceSum.toFixed(1)} {t('mm')}
- + {/* Operation type pie chart */}
{t('Operation Types')}
diff --git a/apps/web/src/routes/Setup/index.tsx b/apps/web/src/routes/Setup/index.tsx index a98275a4f..01bc0f752 100644 --- a/apps/web/src/routes/Setup/index.tsx +++ b/apps/web/src/routes/Setup/index.tsx @@ -22,6 +22,7 @@ import { useWorkPosition, useSpindleState, useSpindleSpeed, + useAvailableAxes, } from '@/store/hooks' import { machineStateSync } from '@/services/machineStateSync' import { setConnecting, setFlashing } from '@/store/machineSlice' @@ -443,6 +444,7 @@ export default function Setup() { const workflowState = useWorkflowState() const machinePosition = useMachinePosition() const workPosition = useWorkPosition() + const availableAxes = useAvailableAxes() const spindleState = useSpindleState() const spindleSpeed = useSpindleSpeed() @@ -1069,6 +1071,7 @@ export default function Setup() { machinePosition, workPosition, currentWCS, + availableAxes, isJobRunning, spindleState, spindleSpeed, @@ -1096,7 +1099,8 @@ export default function Setup() { onFlashStatus: flashStatus, machinePosition, workPosition, - currentWCS + currentWCS, + availableAxes, }} /> ) : null} diff --git a/apps/web/src/routes/Setup/panels/DROPanel.tsx b/apps/web/src/routes/Setup/panels/DROPanel.tsx index a58aa03bb..95f297f1f 100644 --- a/apps/web/src/routes/Setup/panels/DROPanel.tsx +++ b/apps/web/src/routes/Setup/panels/DROPanel.tsx @@ -26,14 +26,25 @@ const DEFAULT_WORKSPACE_NAMES: Record = { 'G55': 'Fixture 2', } -export function DROPanel({ - isConnected, - connectedPort, - machineStatus, - onFlashStatus, - machinePosition = { x: 0, y: 0, z: 0 }, - workPosition = { x: 0, y: 0, z: 0 }, - currentWCS = 'G54' +// Axis color/style configuration +const AXIS_STYLES: Record = { + X: { color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/30' }, + Y: { color: 'text-green-500', bgColor: 'bg-green-500/10', borderColor: 'border-green-500/30' }, + Z: { color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/30' }, + A: { color: 'text-orange-500', bgColor: 'bg-orange-500/10', borderColor: 'border-orange-500/30' }, + B: { color: 'text-purple-500', bgColor: 'bg-purple-500/10', borderColor: 'border-purple-500/30' }, + C: { color: 'text-cyan-500', bgColor: 'bg-cyan-500/10', borderColor: 'border-cyan-500/30' }, +} + +export function DROPanel({ + isConnected, + connectedPort, + machineStatus, + onFlashStatus, + machinePosition = { x: 0, y: 0, z: 0 }, + workPosition = { x: 0, y: 0, z: 0 }, + currentWCS = 'G54', + availableAxes = ['x', 'y', 'z'], }: PanelProps) { const { t } = useTranslation() const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -63,13 +74,13 @@ export function DROPanel({ const { clearBitsetterReference } = useBitsetterReference() // Handle zero out work offset for a single axis - const handleZeroAxis = useCallback(async (axis: 'X' | 'Y' | 'Z') => { + const handleZeroAxis = useCallback(async (axis: string) => { // Clear bitsetter reference if Z zero is being set (bitsetter reference becomes invalid) if (axis === 'Z') { await clearBitsetterReference(workspace) } - - const axisLower = axis.toLowerCase() as 'x' | 'y' | 'z' + + const axisLower = axis.toLowerCase() const gcode = buildSetZeroCommand(workspace, axisLower) if (gcode) { sendGcode(gcode) @@ -80,28 +91,30 @@ export function DROPanel({ const handleZeroAll = useCallback(async () => { // Clear bitsetter reference when zeroing all axes (includes Z) await clearBitsetterReference(workspace) - - const gcode = buildSetZeroCommand(workspace, 'xyz') + + const allAxes = availableAxes.join('') + const gcode = buildSetZeroCommand(workspace, allAxes) if (gcode) { sendGcode(gcode) } - }, [workspace, clearBitsetterReference, sendGcode]) + }, [workspace, availableAxes, clearBitsetterReference, sendGcode]) // Handle go to work zero for a single axis - const handleGoToZeroAxis = useCallback((axis: 'X' | 'Y' | 'Z') => { + const handleGoToZeroAxis = useCallback((axis: string) => { const gcode = buildGoToZeroCommand(axis) if (gcode) { sendGcode(gcode) } }, [sendGcode]) - + // Handle go to work zero for all axes const handleGoToZeroAll = useCallback(() => { - const gcode = buildGoToZeroCommand('XYZ') + const allAxes = availableAxes.map(a => a.toUpperCase()).join('') + const gcode = buildGoToZeroCommand(allAxes) if (gcode) { sendGcode(gcode) } - }, [sendGcode]) + }, [availableAxes, sendGcode]) const handleEditClick = () => { setEditDialogOpen(true) @@ -126,11 +139,15 @@ export function DROPanel({ } }, [workspace, savedWorkspaces, setExtensions]) - const axes = [ - { axis: 'X' as const, color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/30', mpos: machinePosition.x, wpos: workPosition.x }, - { axis: 'Y' as const, color: 'text-green-500', bgColor: 'bg-green-500/10', borderColor: 'border-green-500/30', mpos: machinePosition.y, wpos: workPosition.y }, - { axis: 'Z' as const, color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/30', mpos: machinePosition.z, wpos: workPosition.z }, - ] + const axes = useMemo(() => { + return availableAxes.map(a => { + const upper = a.toUpperCase() + const style = AXIS_STYLES[upper] || AXIS_STYLES.X + const mpos = (machinePosition as Record)[a] ?? 0 + const wpos = (workPosition as Record)[a] ?? 0 + return { axis: upper, ...style, mpos, wpos } + }) + }, [availableAxes, machinePosition, workPosition]) return (
diff --git a/apps/web/src/routes/Setup/panels/JogPanel.tsx b/apps/web/src/routes/Setup/panels/JogPanel.tsx index 5ec046f84..8b9f2138e 100644 --- a/apps/web/src/routes/Setup/panels/JogPanel.tsx +++ b/apps/web/src/routes/Setup/panels/JogPanel.tsx @@ -14,7 +14,14 @@ import { useGetExtensionsQuery } from '@/services/api' import { trackFeatureUsed } from '@/services/analytics' import type { PanelProps } from '../types' -export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashStatus }: PanelProps) { +// Rotary axis color mapping +const ROTARY_AXIS_COLORS: Record = { + A: 'text-orange-500', + B: 'text-purple-500', + C: 'text-cyan-500', +} + +export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashStatus, availableAxes = ['x', 'y', 'z'] }: PanelProps) { const { t } = useTranslation() // Load mode from localStorage or use default const [mode, setMode] = useState<'steps' | 'analog'>(() => { @@ -37,29 +44,41 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta // G-code command hook const { sendGcode } = useGcodeCommand(connectedPort) - // Handle jog command - const handleJog = useCallback((x: number, y: number, z: number) => { + // Determine which rotary axes are available + const rotaryAxes = availableAxes.filter(a => a === 'a' || a === 'b' || a === 'c') + + // Handle jog command - supports any combination of axes + const handleJog = useCallback((moves: Record) => { const distance = currentDistance // Build the movement command const parts: string[] = [] - if (x !== 0) parts.push(`X${x * distance}`) - if (y !== 0) parts.push(`Y${y * distance}`) - if (z !== 0) parts.push(`Z${z * distance}`) - + for (const [axis, dir] of Object.entries(moves)) { + if (dir !== 0) parts.push(`${axis.toUpperCase()}${dir * distance}`) + } + if (parts.length === 0) return - + const command = parts.join(' ') - + // Track feature usage - const axis = x !== 0 ? 'x' : y !== 0 ? 'y' : 'z' - trackFeatureUsed('jog', 'JogPanel', `jog_${axis}`, distance) - + const firstAxis = Object.keys(moves).find(a => moves[a] !== 0) || 'x' + trackFeatureUsed('jog', 'JogPanel', `jog_${firstAxis}`, distance) + // Send jog commands: G91 (relative), G0 (rapid move), G90 (absolute) sendGcode('G91') // relative mode sendGcode(`G0 ${command}`) // rapid move sendGcode('G90') // absolute mode }, [currentDistance, sendGcode]) + + // Handle go to zero for a rotary axis + const handleGoToZeroRotary = useCallback((axis: string) => { + trackFeatureUsed('jog', 'JogPanel', `go_to_zero_${axis.toLowerCase()}`) + const gcode = buildGoToZeroCommand(axis.toUpperCase()) + if (gcode) { + sendGcode(gcode) + } + }, [sendGcode]) // Handle go to zero for XY axes const handleGoToZeroXY = useCallback(() => { @@ -261,7 +280,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(-1, 1, 0)} + onAction={() => handleJog({ x: -1, y: 1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -273,7 +292,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(0, 1, 0)} + onAction={() => handleJog({ y: 1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -285,7 +304,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(1, 1, 0)} + onAction={() => handleJog({ x: 1, y: 1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -298,7 +317,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(-1, 0, 0)} + onAction={() => handleJog({ x: -1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -323,7 +342,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(1, 0, 0)} + onAction={() => handleJog({ x: 1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -336,7 +355,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(-1, -1, 0)} + onAction={() => handleJog({ x: -1, y: -1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -348,7 +367,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(0, -1, 0)} + onAction={() => handleJog({ y: -1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -360,7 +379,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(1, -1, 0)} + onAction={() => handleJog({ x: 1, y: -1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -376,7 +395,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(0, 0, 1)} + onAction={() => handleJog({ z: 1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -401,7 +420,7 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta connectedPort={connectedPort} machineStatus={machineStatus} onFlashStatus={onFlashStatus} - onAction={() => handleJog(0, 0, -1)} + onAction={() => handleJog({ z: -1 })} requirements={ActionRequirements.jog} variant="secondary" className="aspect-square p-0" @@ -410,13 +429,67 @@ export function JogPanel({ isConnected, connectedPort, machineStatus, onFlashSta
- + + {/* Rotary axis controls - shown conditionally */} + {rotaryAxes.length > 0 && ( +
+ {rotaryAxes.map(axis => { + const upper = axis.toUpperCase() + const colorClass = ROTARY_AXIS_COLORS[upper] || 'text-muted-foreground' + return ( +
+ handleJog({ [axis]: -1 })} + requirements={ActionRequirements.jog} + variant="secondary" + size="sm" + className="w-9 h-9 p-0" + > + + + handleGoToZeroRotary(upper)} + requirements={ActionRequirements.jog} + variant="outline" + size="sm" + className={`w-9 h-9 p-0 text-xs font-bold ${colorClass}`} + title={t('Go to {{axis}} zero', { axis: upper })} + > + {upper} 0 + + handleJog({ [axis]: 1 })} + requirements={ActionRequirements.jog} + variant="secondary" + size="sm" + className="w-9 h-9 p-0" + > + + +
+ ) + })} +
+ )} + {/* Distance selector */}
{t('Distance')} - {currentDistance} {t('mm')} + {currentDistance} {rotaryAxes.length > 0 ? t('mm / deg') : t('mm')}
void - machinePosition?: { x: number; y: number; z: number } - workPosition?: { x: number; y: number; z: number } + machinePosition?: { x: number; y: number; z: number; a?: number; b?: number; c?: number } + workPosition?: { x: number; y: number; z: number; a?: number; b?: number; c?: number } + availableAxes?: ('x' | 'y' | 'z' | 'a' | 'b' | 'c')[] currentWCS?: string isJobRunning?: boolean spindleState?: 'M3' | 'M4' | 'M5' diff --git a/apps/web/src/routes/Stats/index.tsx b/apps/web/src/routes/Stats/index.tsx index 39bb56222..c5c313c2f 100644 --- a/apps/web/src/routes/Stats/index.tsx +++ b/apps/web/src/routes/Stats/index.tsx @@ -45,14 +45,21 @@ interface OperationType { bgColor: string } -// Mock data types +// Axis color mapping for distance display +const AXIS_COLORS: Record = { + X: 'text-red-500', + Y: 'text-green-500', + Z: 'text-blue-500', + A: 'text-orange-500', + B: 'text-purple-500', + C: 'text-cyan-500', +} + interface CumulativeStats { totalJobs: number totalRuntime: number // milliseconds totalDistance: number // mm - distanceX: number // mm - distanceY: number // mm - distanceZ: number // mm + distanceByAxis: Record successfulJobs: number failedJobs: number cancelledJobs: number @@ -88,9 +95,7 @@ interface JobStats { linesProcessed: number totalLines: number distance: number // mm (total) - distanceX: number // mm - distanceY: number // mm - distanceZ: number // mm + distanceByAxis: Record gcode?: string // G-code content for visualization operationTypes?: OperationType[] // Operation type breakdown for this job } @@ -159,9 +164,7 @@ export default function Stats() { totalJobs: 0, totalRuntime: 0, totalDistance: 0, - distanceX: 0, - distanceY: 0, - distanceZ: 0, + distanceByAxis: {}, successfulJobs: 0, failedJobs: 0, cancelledJobs: 0, @@ -170,14 +173,12 @@ export default function Stats() { } // Calculate cumulative axis distances from all jobs - let cumulativeDistanceX = 0 - let cumulativeDistanceY = 0 - let cumulativeDistanceZ = 0 + const cumulativeDistanceByAxis: Record = {} let cumulativeTotalDistance = 0 let cumulativeCuttingDistance = 0 let cumulativeTransitionDistance = 0 let cumulativeRetractDistance = 0 - + if (jobHistoryData && jobHistoryData.length > 0) { jobHistoryData.forEach(job => { // Extract distance data from nested structure @@ -185,10 +186,13 @@ export default function Stats() { const cuttingDist = job.stats?.cuttingDistance || { x: 0, y: 0, z: 0, total: 0 } const transitionDist = job.stats?.transitionDistance || { x: 0, y: 0, z: 0, total: 0 } const retractDist = job.stats?.retractDistance || { x: 0, y: 0, z: 0, total: 0 } - - cumulativeDistanceX += totalDist.x || 0 - cumulativeDistanceY += totalDist.y || 0 - cumulativeDistanceZ += totalDist.z || 0 + + // Accumulate per-axis distances dynamically + for (const [key, val] of Object.entries(totalDist)) { + if (key !== 'total' && typeof val === 'number') { + cumulativeDistanceByAxis[key] = (cumulativeDistanceByAxis[key] || 0) + val + } + } cumulativeTotalDistance += totalDist.total || 0 cumulativeCuttingDistance += cuttingDist.total || 0 cumulativeTransitionDistance += transitionDist.total || 0 @@ -231,9 +235,7 @@ export default function Stats() { totalJobs: statsData?.totalJobs || 0, totalRuntime: statsData?.totalTime || 0, totalDistance: statsData?.totalDistance || cumulativeTotalDistance, - distanceX: cumulativeDistanceX, - distanceY: cumulativeDistanceY, - distanceZ: cumulativeDistanceZ, + distanceByAxis: cumulativeDistanceByAxis, successfulJobs: statsData?.successfulJobs || 0, failedJobs: statsData?.failedJobs || 0, cancelledJobs: statsData?.stoppedJobs || 0, // Map stopped to cancelled @@ -428,9 +430,9 @@ export default function Stats() { linesProcessed: job.stats?.received || 0, totalLines: job.stats?.total || 0, distance: totalDist.total || 0, - distanceX: totalDist.x || 0, - distanceY: totalDist.y || 0, - distanceZ: totalDist.z || 0, + distanceByAxis: Object.fromEntries( + Object.entries(totalDist).filter(([k]) => k !== 'total').map(([k, v]) => [k, v || 0]) + ), gcode: job.gcode, operationTypes, } @@ -476,9 +478,9 @@ export default function Stats() { linesProcessed: selectedJobData.stats?.received || 0, totalLines: selectedJobData.stats?.total || 0, distance: totalDist.total || 0, - distanceX: totalDist.x || 0, - distanceY: totalDist.y || 0, - distanceZ: totalDist.z || 0, + distanceByAxis: Object.fromEntries( + Object.entries(totalDist).filter(([k]) => k !== 'total').map(([k, v]) => [k, v || 0]) + ), gcode: selectedJobData.gcode, operationTypes, } @@ -768,18 +770,12 @@ export default function Stats() {
{t('Total Distance')}
{formatDistance(cumulativeStats.totalDistance)}
-
- X: - {formatDistance(cumulativeStats.distanceX)} -
-
- Y: - {formatDistance(cumulativeStats.distanceY)} -
-
- Z: - {formatDistance(cumulativeStats.distanceZ)} -
+ {Object.entries(cumulativeStats.distanceByAxis).map(([axis, dist]) => ( +
+ {axis.toUpperCase()}: + {formatDistance(dist)} +
+ ))}
@@ -980,18 +976,12 @@ export default function Stats() {
{t('Distance')}
{formatDistance(selectedJob.distance)}
-
- X: - {formatDistance(selectedJob.distanceX)} -
-
- Y: - {formatDistance(selectedJob.distanceY)} -
-
- Z: - {formatDistance(selectedJob.distanceZ)} -
+ {Object.entries(selectedJob.distanceByAxis).map(([axis, dist]) => ( +
+ {axis.toUpperCase()}: + {formatDistance(dist)} +
+ ))}
diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index bafc2de72..ceaf30dbb 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -38,8 +38,8 @@ export interface MachineStatus { homingInProgress: boolean controllerState: { activeState: string - mpos: { x: string; y: string; z: string } | null - wpos: { x: string; y: string; z: string } | null + mpos: { x: string; y: string; z: string; a?: string; b?: string; c?: string } | null + wpos: { x: string; y: string; z: string; a?: string; b?: string; c?: string } | null pinState?: string | null // Grbl v1.1: input pin state ('XYZPDHRS' indicates triggered pins) accessoryState?: string | null // Grbl v1.1: accessory state ('SCFM' indicates spindle/coolant state) ov?: number[] | null // Grbl v1.1: override values [feed%, rapid%, spindle%] diff --git a/apps/web/src/store/hooks.ts b/apps/web/src/store/hooks.ts index 45be4f0a5..1ec2c6a24 100644 --- a/apps/web/src/store/hooks.ts +++ b/apps/web/src/store/hooks.ts @@ -1,164 +1,220 @@ -import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux' -import type { RootState, AppDispatch } from './index' -import { createSelector } from '@reduxjs/toolkit' +import { createSelector } from "@reduxjs/toolkit"; +import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import type { AppDispatch, RootState } from "./index"; // Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch = () => useDispatch() -export const useAppSelector: TypedUseSelectorHook = useSelector +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; // Selectors for computed values from backendStatus -const selectBackendStatus = (state: RootState) => state.machine.backendStatus +const selectBackendStatus = (state: RootState) => state.machine.backendStatus; // Computed connection state export const selectIsConnected = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.connected ?? false -) + (backendStatus) => backendStatus?.connected ?? false, +); export const selectConnectedPort = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.port ?? null -) + (backendStatus) => backendStatus?.port ?? null, +); // Computed position values (parsed from strings to numbers) +// Includes optional A, B, C axes when reported by the controller export const selectMachinePosition = createSelector( [selectBackendStatus], (backendStatus) => { - const mpos = backendStatus?.controllerState?.mpos - if (!mpos) return { x: 0, y: 0, z: 0 } - return { - x: parseFloat(mpos.x || '0'), - y: parseFloat(mpos.y || '0'), - z: parseFloat(mpos.z || '0'), - } - } -) + const mpos = backendStatus?.controllerState?.mpos; + if (!mpos) + return { x: 0, y: 0, z: 0 } as { + x: number; + y: number; + z: number; + a?: number; + b?: number; + c?: number; + }; + const result: { + x: number; + y: number; + z: number; + a?: number; + b?: number; + c?: number; + } = { + x: parseFloat(mpos.x || "0"), + y: parseFloat(mpos.y || "0"), + z: parseFloat(mpos.z || "0"), + }; + if (mpos.a !== undefined) result.a = parseFloat(mpos.a); + if (mpos.b !== undefined) result.b = parseFloat(mpos.b); + if (mpos.c !== undefined) result.c = parseFloat(mpos.c); + return result; + }, +); export const selectWorkPosition = createSelector( [selectBackendStatus], (backendStatus) => { - const wpos = backendStatus?.controllerState?.wpos - if (!wpos) return { x: 0, y: 0, z: 0 } - return { - x: parseFloat(wpos.x || '0'), - y: parseFloat(wpos.y || '0'), - z: parseFloat(wpos.z || '0'), + const wpos = backendStatus?.controllerState?.wpos; + if (!wpos) + return { x: 0, y: 0, z: 0 } as { + x: number; + y: number; + z: number; + a?: number; + b?: number; + c?: number; + }; + const result: { + x: number; + y: number; + z: number; + a?: number; + b?: number; + c?: number; + } = { + x: parseFloat(wpos.x || "0"), + y: parseFloat(wpos.y || "0"), + z: parseFloat(wpos.z || "0"), + }; + if (wpos.a !== undefined) result.a = parseFloat(wpos.a); + if (wpos.b !== undefined) result.b = parseFloat(wpos.b); + if (wpos.c !== undefined) result.c = parseFloat(wpos.c); + return result; + }, +); + +// Detect which axes are available based on controller position reports +// X, Y, Z are always available; A, B, C appear only when reported by the controller +export const selectAvailableAxes = createSelector( + [selectBackendStatus], + (backendStatus): ("x" | "y" | "z" | "a" | "b" | "c")[] => { + const axes: ("x" | "y" | "z" | "a" | "b" | "c")[] = ["x", "y", "z"]; + const mpos = backendStatus?.controllerState?.mpos; + if (mpos) { + if (mpos.a !== undefined) axes.push("a"); + if (mpos.b !== undefined) axes.push("b"); + if (mpos.c !== undefined) axes.push("c"); } - } -) + return axes; + }, +); // Computed spindle state export const selectSpindleState = createSelector( [selectBackendStatus], (backendStatus) => { - const spindle = backendStatus?.parserstate?.modal?.spindle - if (spindle === 'M3' || spindle === 'M4' || spindle === 'M5') { - return spindle + const spindle = backendStatus?.parserstate?.modal?.spindle; + if (spindle === "M3" || spindle === "M4" || spindle === "M5") { + return spindle; } - return 'M5' as const - } -) + return "M5" as const; + }, +); export const selectSpindleSpeed = createSelector( [selectBackendStatus], (backendStatus) => { - const speed = backendStatus?.parserstate?.spindle - return speed ? parseFloat(speed || '0') : 0 - } -) + const speed = backendStatus?.parserstate?.spindle; + return speed ? parseFloat(speed || "0") : 0; + }, +); // Computed tool export const selectCurrentTool = createSelector( [selectBackendStatus], (backendStatus) => { - const tool = backendStatus?.parserstate?.tool - const toolNum = tool ? parseFloat(tool || '0') : 0 - return toolNum > 0 ? toolNum : undefined - } -) + const tool = backendStatus?.parserstate?.tool; + const toolNum = tool ? parseFloat(tool || "0") : 0; + return toolNum > 0 ? toolNum : undefined; + }, +); // Computed feedrate export const selectFeedrate = createSelector( [selectBackendStatus], (backendStatus) => { - const feedrate = backendStatus?.parserstate?.feedrate - return feedrate ? parseFloat(feedrate || '0') : 0 - } -) + const feedrate = backendStatus?.parserstate?.feedrate; + return feedrate ? parseFloat(feedrate || "0") : 0; + }, +); // Computed override values (feed%, rapid%, spindle%) export const selectOverrideValues = createSelector( [selectBackendStatus], (backendStatus) => { - const ov = backendStatus?.controllerState?.ov - if (!ov || ov.length < 3) return { feed: 100, rapid: 100, spindle: 100 } + const ov = backendStatus?.controllerState?.ov; + if (!ov || ov.length < 3) return { feed: 100, rapid: 100, spindle: 100 }; return { feed: ov[0], rapid: ov[1], spindle: ov[2], - } - } -) + }; + }, +); // Computed buffer state export const selectRxBufferSize = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.status?.buf?.rx ?? 0 -) + (backendStatus) => backendStatus?.status?.buf?.rx ?? 0, +); export const selectPlannerQueue = createSelector( [selectBackendStatus], (backendStatus) => { - const availableBlocks = backendStatus?.status?.buf?.planner ?? 0 - const maxBlocks = 15 - const usedBlocks = Math.max(0, maxBlocks - availableBlocks) - return { depth: usedBlocks, max: maxBlocks } - } -) + const availableBlocks = backendStatus?.status?.buf?.planner ?? 0; + const maxBlocks = 15; + const usedBlocks = Math.max(0, maxBlocks - availableBlocks); + return { depth: usedBlocks, max: maxBlocks }; + }, +); // Computed workflow state export const selectWorkflowState = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.workflowState ?? null -) + (backendStatus) => backendStatus?.workflowState ?? null, +); // Computed work coordinate system export const selectCurrentWCS = createSelector( [selectBackendStatus], (backendStatus) => { - const wcs = backendStatus?.parserstate?.modal?.wcs - return wcs || 'G54' - } -) + const wcs = backendStatus?.parserstate?.modal?.wcs; + return wcs || "G54"; + }, +); export const selectIsJobRunning = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.isJobRunning ?? false -) + (backendStatus) => backendStatus?.isJobRunning ?? false, +); // Computed homed state export const selectIsHomed = createSelector( [selectBackendStatus], - (backendStatus) => backendStatus?.isHomed ?? false -) + (backendStatus) => backendStatus?.isHomed ?? false, +); // Convenience hooks for machine state -export const useMachineState = () => useAppSelector((state) => state.machine) -export const useJobState = () => useAppSelector((state) => state.job) +export const useMachineState = () => useAppSelector((state) => state.machine); +export const useJobState = () => useAppSelector((state) => state.job); // Convenience hooks for computed values -export const useIsConnected = () => useAppSelector(selectIsConnected) -export const useConnectedPort = () => useAppSelector(selectConnectedPort) -export const useMachinePosition = () => useAppSelector(selectMachinePosition) -export const useWorkPosition = () => useAppSelector(selectWorkPosition) -export const useSpindleState = () => useAppSelector(selectSpindleState) -export const useSpindleSpeed = () => useAppSelector(selectSpindleSpeed) -export const useCurrentTool = () => useAppSelector(selectCurrentTool) -export const useFeedrate = () => useAppSelector(selectFeedrate) -export const useRxBufferSize = () => useAppSelector(selectRxBufferSize) -export const usePlannerQueue = () => useAppSelector(selectPlannerQueue) -export const useWorkflowState = () => useAppSelector(selectWorkflowState) -export const useIsJobRunning = () => useAppSelector(selectIsJobRunning) -export const useIsHomed = () => useAppSelector(selectIsHomed) -export const useCurrentWCS = () => useAppSelector(selectCurrentWCS) -export const useOverrideValues = () => useAppSelector(selectOverrideValues) \ No newline at end of file +export const useIsConnected = () => useAppSelector(selectIsConnected); +export const useConnectedPort = () => useAppSelector(selectConnectedPort); +export const useMachinePosition = () => useAppSelector(selectMachinePosition); +export const useWorkPosition = () => useAppSelector(selectWorkPosition); +export const useSpindleState = () => useAppSelector(selectSpindleState); +export const useSpindleSpeed = () => useAppSelector(selectSpindleSpeed); +export const useCurrentTool = () => useAppSelector(selectCurrentTool); +export const useFeedrate = () => useAppSelector(selectFeedrate); +export const useRxBufferSize = () => useAppSelector(selectRxBufferSize); +export const usePlannerQueue = () => useAppSelector(selectPlannerQueue); +export const useWorkflowState = () => useAppSelector(selectWorkflowState); +export const useIsJobRunning = () => useAppSelector(selectIsJobRunning); +export const useIsHomed = () => useAppSelector(selectIsHomed); +export const useCurrentWCS = () => useAppSelector(selectCurrentWCS); +export const useOverrideValues = () => useAppSelector(selectOverrideValues); +export const useAvailableAxes = () => useAppSelector(selectAvailableAxes); diff --git a/apps/web/src/utils/gcode.ts b/apps/web/src/utils/gcode.ts index 66e823be4..dcbc3fe61 100644 --- a/apps/web/src/utils/gcode.ts +++ b/apps/web/src/utils/gcode.ts @@ -31,15 +31,19 @@ export function getWCSPNumber(wcs: string): number { */ export function buildSetZeroCommand( wcs: string, - axes: 'x' | 'y' | 'z' | 'xy' | 'xz' | 'yz' | 'xyz' + axes: string ): string { const p = getWCSPNumber(wcs) const axisParts: string[] = [] - - if (axes.includes('x')) axisParts.push('X0') - if (axes.includes('y')) axisParts.push('Y0') - if (axes.includes('z')) axisParts.push('Z0') - + const lower = axes.toLowerCase() + + if (lower.includes('x')) axisParts.push('X0') + if (lower.includes('y')) axisParts.push('Y0') + if (lower.includes('z')) axisParts.push('Z0') + if (lower.includes('a')) axisParts.push('A0') + if (lower.includes('b')) axisParts.push('B0') + if (lower.includes('c')) axisParts.push('C0') + return `G10 L20 P${p} ${axisParts.join(' ')}` } @@ -52,7 +56,7 @@ export function buildSetZeroCommand( */ export function buildSetZeroWithOffsetCommand( wcs: string, - axis: 'X' | 'Y' | 'Z', + axis: 'X' | 'Y' | 'Z' | 'A' | 'B' | 'C', value: number ): string { const p = getWCSPNumber(wcs) @@ -67,13 +71,16 @@ export function buildSetZeroWithOffsetCommand( * buildRapidMoveCommand({ z: 10 }) // 'G0 Z10' */ export function buildRapidMoveCommand( - position: Partial<{ x: number; y: number; z: number }> + position: Partial<{ x: number; y: number; z: number; a: number; b: number; c: number }> ): string { const parts: string[] = [] if (position.x !== undefined) parts.push(`X${position.x}`) if (position.y !== undefined) parts.push(`Y${position.y}`) if (position.z !== undefined) parts.push(`Z${position.z}`) - + if (position.a !== undefined) parts.push(`A${position.a}`) + if (position.b !== undefined) parts.push(`B${position.b}`) + if (position.c !== undefined) parts.push(`C${position.c}`) + if (parts.length === 0) return '' return `G0 ${parts.join(' ')}` } @@ -85,12 +92,15 @@ export function buildRapidMoveCommand( * buildGoToZeroCommand('X') // 'G0 X0' * buildGoToZeroCommand('xy') // 'G0 X0 Y0' */ -export function buildGoToZeroCommand(axes: 'X' | 'Y' | 'Z' | 'XY' | 'XYZ'): string { +export function buildGoToZeroCommand(axes: string): string { const parts: string[] = [] if (axes.includes('X')) parts.push('X0') if (axes.includes('Y')) parts.push('Y0') if (axes.includes('Z')) parts.push('Z0') - + if (axes.includes('A')) parts.push('A0') + if (axes.includes('B')) parts.push('B0') + if (axes.includes('C')) parts.push('C0') + return `G0 ${parts.join(' ')}` }