import { useState, useEffect, useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; import { api } from '../../api/client'; import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client'; import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react'; import { AMSHumidityModal } from './AMSHumidityModal'; import { AMSMaterialsModal } from './AMSMaterialsModal'; // Filament change stages from MQTT stg_cur const STAGE_HEATING_NOZZLE = 7; const STAGE_FILAMENT_UNLOADING = 22; const STAGE_FILAMENT_LOADING = 24; const STAGE_CHANGING_FILAMENT = 4; interface AMSSectionDualProps { printerId: number; printerModel: string; status: PrinterStatus | null | undefined; nozzleCount: number; } function hexToRgb(hex: string | null): string { if (!hex) return 'rgb(128, 128, 128)'; const cleanHex = hex.replace('#', '').substring(0, 6); const r = parseInt(cleanHex.substring(0, 2), 16) || 128; const g = parseInt(cleanHex.substring(2, 4), 16) || 128; const b = parseInt(cleanHex.substring(4, 6), 16) || 128; return `rgb(${r}, ${g}, ${b})`; } function isLightColor(hex: string | null): boolean { if (!hex) return false; const cleanHex = hex.replace('#', '').substring(0, 6); const r = parseInt(cleanHex.substring(0, 2), 16) || 0; const g = parseInt(cleanHex.substring(2, 4), 16) || 0; const b = parseInt(cleanHex.substring(4, 6), 16) || 0; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.5; } // Single humidity icon that fills based on level // <25% = empty (dry/good) // <40% = half filled // >=40% = full (wet/bad) function HumidityIcon({ humidity }: { humidity: number }) { const getIconSrc = (): string => { if (humidity < 25) return '/icons/humidity-empty.svg'; if (humidity < 40) return '/icons/humidity-half.svg'; return '/icons/humidity-full.svg'; }; return ( ); } // Filament change progress card - appears during load/unload operations interface FilamentChangeCardProps { isLoading: boolean; // true = loading, false = unloading currentStage: number; onRetry?: () => void; } interface StepInfo { label: string; status: 'completed' | 'in_progress' | 'pending'; stepNumber: number; } function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) { const [isCollapsed, setIsCollapsed] = useState(false); // Determine step status based on current stage // When stage is -1 (initial/waiting), show first step as in_progress const getLoadingSteps = (): StepInfo[] => { // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete) let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending'; if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { // Initial state or heating - step 1 is active step1Status = 'in_progress'; } else if (currentStage === STAGE_FILAMENT_LOADING) { // Loading filament - step 1 done, step 2 active step1Status = 'completed'; step2Status = 'in_progress'; } return [ { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status }, { label: 'Purge old filament', stepNumber: 3, status: step3Status }, ]; }; const getUnloadingSteps = (): StepInfo[] => { let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { // Initial state or heating - step 1 is active step1Status = 'in_progress'; } else if (currentStage === STAGE_FILAMENT_UNLOADING) { // Unloading filament - step 1 done, step 2 active step1Status = 'completed'; step2Status = 'in_progress'; } return [ { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, { label: 'Retract filament from extruder', stepNumber: 2, status: step2Status }, ]; }; const steps = isLoading ? getLoadingSteps() : getUnloadingSteps(); const title = isLoading ? 'Loading' : 'Unloading'; const headerText = isLoading ? 'Filament loading...' : 'Filament unloading...'; return (
{/* Collapsible header */} {/* Content */} {!isCollapsed && (
{/* Steps list */}

{title}

{steps.map((step) => (
{/* Step indicator */} {step.status === 'completed' ? (
) : step.status === 'in_progress' ? (
{step.stepNumber}
) : (
{step.stepNumber}
)} {/* Step label */} {step.label}
))}
{/* Extruder image */}
Extruder
{/* Retry button */} {onRetry && ( )}
)}
); } interface AMSPanelContentProps { units: AMSUnit[]; side: 'left' | 'right'; isPrinting: boolean; selectedAmsIndex: number; onSelectAms: (index: number) => void; selectedTray: number | null; onSelectTray: (trayId: number | null) => void; onHumidityClick: (humidity: number, temp: number) => void; onSlotRefresh: (amsId: number, slotId: number) => void; onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void; } // Panel content - NO wiring, just slots and info // Get slot label based on AMS unit ID and tray index // Regular AMS (ID 0-3): A1, A2, A3, A4 / B1, B2, B3, B4 / etc. // AMS-HT (ID >= 128): HT-A, HT-B (for first HT unit), HT2-A, HT2-B (for second), etc. function getSlotLabel(amsId: number, trayIndex: number): string { if (amsId >= 128) { // AMS-HT unit - uses HT-A, HT-B naming const htUnitNumber = amsId - 128; // 0 for first HT, 1 for second, etc. const slotLetter = String.fromCharCode(65 + trayIndex); // A, B if (htUnitNumber === 0) { return `HT-${slotLetter}`; } return `HT${htUnitNumber + 1}-${slotLetter}`; } // Regular AMS - uses A1, B2, etc. naming const prefix = String.fromCharCode(65 + amsId); // 65 is ASCII for 'A' return `${prefix}${trayIndex + 1}`; } // Check if AMS unit is an AMS-HT (ID >= 128) function isAmsHT(amsId: number): boolean { return amsId >= 128; } function AMSPanelContent({ units, side, isPrinting, selectedAmsIndex, onSelectAms, selectedTray, onSelectTray, onHumidityClick, onSlotRefresh, onEyeClick, }: AMSPanelContentProps) { const selectedUnit = units[selectedAmsIndex]; const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false; return (
{/* AMS Tab Selectors */}
{units.map((unit, index) => ( ))}
{/* AMS Content */} {selectedUnit && (
{/* AMS Header - Humidity & Temp - Centered - Clickable */} {/* Slot Labels */}
{selectedUnit.tray.map((tray, index) => { const slotLabel = getSlotLabel(selectedUnit.id, index); return ( ); })}
{/* AMS Slots - NO wiring here */}
{selectedUnit.tray.map((tray, index) => { const globalTrayId = selectedUnit.id * 4 + tray.id; const isSelected = selectedTray === globalTrayId; const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE'; const isLight = isLightColor(tray.tray_color); const slotLabel = getSlotLabel(selectedUnit.id, index); return (
{ console.log(`[AMSSectionDual] Slot clicked: AMS ${selectedUnit.id}, tray ${tray.id}, globalTrayId: ${globalTrayId}, isEmpty: ${isEmpty}, isPrinting: ${isPrinting}, isSelected: ${isSelected}`); if (!isEmpty && !isPrinting) { onSelectTray(isSelected ? null : globalTrayId); } }} className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark relative ${ isSelected ? 'border-bambu-green' : 'border-bambu-dark-tertiary hover:border-bambu-gray' } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`} > {/* Fill level indicator - only for Bambu filaments with valid remain data */} {!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
)} {/* Full color background for non-Bambu filaments or no remain data */} {!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
)} {/* Striped pattern for empty slots */} {isEmpty && (
)} {/* Content overlay */}
{isEmpty ? '--' : tray.tray_type} {!isEmpty && ( )}
); })}
)} {/* No AMS message */} {units.length === 0 && (
No AMS connected to {side} nozzle
)}
); } // Unified wiring layer - draws ALL wiring in one place interface WiringLayerProps { isDualNozzle: boolean; leftSlotCount: number; // Number of slots on left panel (4 for regular AMS, 1-2 for AMS-HT) rightSlotCount: number; // Number of slots on right panel leftIsHT: boolean; // Is left panel an AMS-HT rightIsHT: boolean; // Is right panel an AMS-HT leftActiveSlot?: number | null; // Currently active slot index on left panel (0-3) rightActiveSlot?: number | null; // Currently active slot index on right panel (0-3) leftFilamentColor?: string | null; // Filament color for left active path rightFilamentColor?: string | null; // Filament color for right active path } function WiringLayer({ isDualNozzle, leftSlotCount, rightSlotCount, leftIsHT, rightIsHT, leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor, }: WiringLayerProps) { if (!isDualNozzle) return null; // All measurements relative to this container // Container spans full width between panels // Regular AMS: slots → hub → down → toward center → down to extruder // AMS-HT: single slot on left → direct line down to extruder // Regular AMS: Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel // Left panel center ~150, slots start at 150 - 124 = 26 // Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 // AMS-HT: Left aligned with pl-2 (8px), slot starts at 8px + 28px = 36px center // For 2 slots: 36, 100 (36 + 64) // Right panel calculations for regular AMS: // Right panel center ~450, slots start at 450 - 124 = 326 // Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 // Right panel AMS-HT: Left aligned, starts at ~308 (300 panel offset + 8px padding) // Slot center: 308 + 28 = 336 // Determine colors for wiring paths const defaultColor = '#909090'; const leftActiveColor = leftFilamentColor ? hexToRgb(leftFilamentColor) : null; const rightActiveColor = rightFilamentColor ? hexToRgb(rightFilamentColor) : null; // Slot X positions for regular AMS (4 slots) const leftSlotX = [54, 118, 182, 246]; // Right slot positions const rightSlotX = [354, 418, 482, 546]; return (
{/* Left panel wiring */} {leftIsHT ? ( <> {/* AMS-HT: Simple direct line from slot to extruder */} {/* Slot vertical lines - highlight active slot */} {leftSlotCount > 1 && ( )} {leftSlotCount > 1 && ( )} {/* Path to extruder - always colored if filament loaded */} 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} /> ) : ( <> {/* Regular AMS: 4 slots with hub */} {/* Vertical lines from 4 slots - highlight active slot */} {leftSlotX.map((x, i) => ( ))} {/* Horizontal bar connecting left slots - highlight from active slot to hub */} {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? ( <> {/* Background bar */} {/* Highlight segment from active slot to hub (center at 150) */} ) : ( )} {/* Left hub */} {/* Vertical from left hub down */} {/* Horizontal from left hub toward center */} {/* Vertical down to left extruder inlet */} )} {/* Right panel wiring */} {rightIsHT ? ( <> {/* AMS-HT: Simple direct line from slot to extruder */} {rightSlotCount > 1 && ( )} {rightSlotCount > 1 && ( )} 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} /> ) : ( <> {/* Regular AMS: 4 slots with hub */} {/* Vertical lines from 4 slots - highlight active slot */} {rightSlotX.map((x, i) => ( ))} {/* Horizontal bar connecting right slots - highlight from active slot to hub */} {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? ( <> {/* Background bar */} {/* Highlight segment from active slot to hub (center at 450) */} ) : ( )} {/* Right hub */} {/* Vertical from right hub down */} {/* Horizontal from right hub toward center */} {/* Vertical down to right extruder inlet */} )} {/* Extruder image container - positioned at bottom center */} {/* Image is 56x71 pixels, scaled to h=50px = width ~39px */} {/* Scale factor: 50/71 = 0.704 */} {/* Green circles in original image: left center ~(15.2,34.2), right center ~(41.0,33.9) */} {/* Scaled positions: left x≈10.7, right x≈28.9, y≈24 from top */}
Extruder {/* Extruder inlet indicator circles - overlay on extruder image */} {/* Left inlet (extruder 1) - left side of extruder */}
{/* Right inlet (extruder 0) - right side of extruder */}
); } export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) { const isConnected = status?.connected ?? false; const isPrinting = status?.state === 'RUNNING'; const isDualNozzle = nozzleCount > 1; const amsUnits: AMSUnit[] = status?.ams ?? []; // Per-AMS extruder map: {ams_id: extruder_id} where extruder 0=right, 1=left // This is extracted from each AMS unit's info field bit 8 in the backend // Note: JSON keys are always strings, so we use Record const amsExtruderMap: Record = status?.ams_extruder_map ?? {}; // Distribute AMS units based on ams_extruder_map // Each AMS unit's info field tells us which extruder it's connected to: // extruder 0 = right nozzle, extruder 1 = left nozzle const leftUnits = (() => { if (!isDualNozzle) return amsUnits; if (Object.keys(amsExtruderMap).length > 0) { // Filter AMS units assigned to extruder 1 (left nozzle) // JSON keys are strings, so convert unit.id to string return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1); } // Fallback: odd indices go to left (extruder 1) return amsUnits.filter((_, i) => i % 2 === 1); })(); const rightUnits = (() => { if (!isDualNozzle) return []; if (Object.keys(amsExtruderMap).length > 0) { // Filter AMS units assigned to extruder 0 (right nozzle) // JSON keys are strings, so convert unit.id to string return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0); } // Fallback: even indices go to right (extruder 0) return amsUnits.filter((_, i) => i % 2 === 0); })(); const [leftAmsIndex, setLeftAmsIndex] = useState(0); const [rightAmsIndex, setRightAmsIndex] = useState(0); const [selectedTray, setSelectedTray] = useState(null); // Track if load has been triggered (to disable Load button until unload or slot change) const [loadTriggered, setLoadTriggered] = useState(false); // Modal states const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null); const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null); // Track user-initiated filament change operations (for showing progress card immediately) const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null); // Track the previous stage for detecting when operation completes const prevStageRef = useRef(-1); // Track if we've done initial sync from tray_now const initialSyncDone = useRef(false); // Sync selectedTray and loadTriggered from status.tray_now on initial load // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool useEffect(() => { if (initialSyncDone.current) return; const trayNow = status?.tray_now; if (trayNow !== undefined && trayNow !== null) { initialSyncDone.current = true; if (trayNow !== 255 && trayNow !== 254) { // Valid AMS tray is loaded - select it and set loadTriggered console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`); setSelectedTray(trayNow); setLoadTriggered(true); } else { // No filament loaded or external spool console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`); } } }, [status?.tray_now]); const loadMutation = useMutation({ mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) => api.amsLoadFilament(printerId, trayId, extruderId), onSuccess: (data, { trayId, extruderId }) => { console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data); // Disable Load button after successful load setLoadTriggered(true); }, onError: (error, { trayId, extruderId }) => { console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error); }, }); const unloadMutation = useMutation({ mutationFn: () => api.amsUnloadFilament(printerId), onSuccess: (data) => { console.log(`[AMSSectionDual] Unload filament success:`, data); // Re-enable Load button after unload setLoadTriggered(false); }, onError: (error) => { console.error(`[AMSSectionDual] Unload filament error:`, error); }, }); // Handle tray selection - also re-enables Load button when changing slot const handleTraySelect = (trayId: number | null) => { if (trayId !== selectedTray) { // Slot changed - re-enable Load button setLoadTriggered(false); } setSelectedTray(trayId); }; // Helper to get extruder ID for a given tray const getExtruderIdForTray = (trayId: number): number | undefined => { // For dual-nozzle printers, calculate which AMS unit the tray belongs to // and look up which extruder it's connected to if (!isDualNozzle) return undefined; // Find which AMS unit contains this tray // Global tray ID format: amsId * 4 + slotIndex (for regular AMS) // For AMS-HT (id >= 128): amsId * 4 + slotIndex (but only 2 slots) for (const unit of amsUnits) { const slotsInUnit = unit.id >= 128 ? 2 : 4; // AMS-HT has 2 slots const baseSlotId = unit.id * 4; if (trayId >= baseSlotId && trayId < baseSlotId + slotsInUnit) { // Found the AMS unit - look up its extruder const extruderId = amsExtruderMap[String(unit.id)]; console.log(`[AMSSectionDual] Tray ${trayId} belongs to AMS ${unit.id}, extruder: ${extruderId}`); return extruderId; } } return undefined; }; const handleLoad = () => { console.log(`[AMSSectionDual] handleLoad called, selectedTray: ${selectedTray}`); if (selectedTray !== null) { const extruderId = getExtruderIdForTray(selectedTray); console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`); // Show filament change card immediately setUserFilamentChange({ isLoading: true }); loadMutation.mutate({ trayId: selectedTray, extruderId }); } }; const handleUnload = () => { // Show filament change card immediately setUserFilamentChange({ isLoading: false }); unloadMutation.mutate(); }; const isLoading = loadMutation.isPending || unloadMutation.isPending; // Handlers for modals and actions const handleHumidityClick = (humidity: number, temp: number) => { setHumidityModal({ humidity, temp }); }; const refreshMutation = useMutation({ mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) => api.refreshAmsTray(printerId, amsId, trayId), onSuccess: (data, variables) => { console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data); }, onError: (error, variables) => { console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error); }, }); const handleSlotRefresh = (amsId: number, slotId: number) => { // Trigger RFID re-read for the specific tray console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`); refreshMutation.mutate({ amsId, trayId: slotId }); }; const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => { setMaterialsModal({ tray, slotLabel, amsId }); }; // Determine if we're in a filament change stage (from MQTT) const currentStage = status?.stg_cur ?? -1; const isMqttFilamentChangeActive = [ STAGE_HEATING_NOZZLE, STAGE_FILAMENT_UNLOADING, STAGE_FILAMENT_LOADING, STAGE_CHANGING_FILAMENT, ].includes(currentStage); // Auto-close card when operation completes // Track when we transition from an active filament change stage back to -1 useEffect(() => { const wasInFilamentChange = [ STAGE_HEATING_NOZZLE, STAGE_FILAMENT_UNLOADING, STAGE_FILAMENT_LOADING, STAGE_CHANGING_FILAMENT, ].includes(prevStageRef.current); if (isMqttFilamentChangeActive) { // MQTT is now reporting a stage, clear user-triggered state // Card will continue showing because isMqttFilamentChangeActive is true setUserFilamentChange(null); } else if (wasInFilamentChange && currentStage === -1) { // Transition from active stage to idle - operation completed // Close the card by clearing user state setUserFilamentChange(null); } // Update previous stage for next comparison prevStageRef.current = currentStage; }, [isMqttFilamentChangeActive, currentStage]); // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null; // Determine if loading or unloading for the card display const isFilamentLoading = userFilamentChange !== null ? userFilamentChange.isLoading : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE); // Get the loaded tray info for wire coloring // Wire coloring should show the path from the currently loaded filament to the extruder // But ONLY if the currently displayed AMS panel is the one with the loaded filament const trayNow = status?.tray_now ?? 255; const getLoadedTrayInfo = (): { leftActiveSlot: number | null; rightActiveSlot: number | null; leftFilamentColor: string | null; rightFilamentColor: string | null; } => { // tray_now: 255 = no filament, 254 = external spool, 0-253 = valid tray ID if (trayNow === 255 || trayNow === 254) { return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; } // Find which AMS and slot contains the loaded tray for (const unit of amsUnits) { const slotsInUnit = unit.id >= 128 ? 2 : 4; const baseSlotId = unit.id * 4; if (trayNow >= baseSlotId && trayNow < baseSlotId + slotsInUnit) { const slotIndex = trayNow - baseSlotId; const tray = unit.tray[slotIndex]; const color = tray?.tray_color ?? null; // Determine if this AMS is on left or right side const extruderId = amsExtruderMap[String(unit.id)]; // Check if this AMS unit is the one currently displayed in the panel const currentLeftUnit = leftUnits[leftAmsIndex]; const currentRightUnit = rightUnits[rightAmsIndex]; if (extruderId === 1) { // Left side (extruder 1) // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament const isDisplayed = currentLeftUnit?.id === unit.id; return { leftActiveSlot: isDisplayed ? slotIndex : null, rightActiveSlot: null, leftFilamentColor: isDisplayed ? color : null, // Hide color if different AMS is selected rightFilamentColor: null }; } else { // Right side (extruder 0) const isDisplayed = currentRightUnit?.id === unit.id; return { leftActiveSlot: null, rightActiveSlot: isDisplayed ? slotIndex : null, leftFilamentColor: null, rightFilamentColor: isDisplayed ? color : null // Hide color if different AMS is selected }; } } } return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; }; const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo(); return (
{/* Dual Panel Layout - just the panels, no wiring */}
{isDualNozzle && ( )}
{/* Unified Wiring Layer - ALL wiring drawn here */} {/* Action Buttons Row - aligned with extruder */}
{/* Error messages */} {(loadMutation.error || unloadMutation.error) && (

{(loadMutation.error || unloadMutation.error)?.message}

)} {/* Filament Change Progress Card - appears during load/unload operations */} {showFilamentChangeCard && ( )} {/* Humidity Modal */} {humidityModal && ( setHumidityModal(null)} /> )} {/* Materials Settings Modal */} {materialsModal && ( setMaterialsModal(null)} /> )}
); }