diff --git a/BlocksScreen/devices/amu/models.py b/BlocksScreen/devices/amu/models.py index 804d6925..e83f0052 100644 --- a/BlocksScreen/devices/amu/models.py +++ b/BlocksScreen/devices/amu/models.py @@ -67,6 +67,7 @@ class MMUState: filament: str # "Loaded" | "Unloaded" | "Unknown" filament_pos: FilamentPos action: str + bowden_progress: float print_state: str reason_for_pause: str has_bypass: bool @@ -142,6 +143,7 @@ def from_status(cls, data: dict) -> "MMUState": filament=data.get("filament", "Unknown"), filament_pos=FilamentPos(data.get("filament_pos", FilamentPos.UNKNOWN)), action=data.get("action", ""), + bowden_progress=data.get("bowden_progress", -1), print_state=data.get("print_state", ""), reason_for_pause=data.get("reason_for_pause", ""), has_bypass=data.get("has_bypass", False), diff --git a/BlocksScreen/lib/panels/filamentTab.py b/BlocksScreen/lib/panels/filamentTab.py index ae9453fa..f78c26ef 100644 --- a/BlocksScreen/lib/panels/filamentTab.py +++ b/BlocksScreen/lib/panels/filamentTab.py @@ -1,6 +1,5 @@ import logging from collections import deque -from typing import Deque from devices.amu import AMUManager from devices.amu.models import GateStatus @@ -20,6 +19,7 @@ from lib.utils.blocks_linedit import BlocksCustomLinEdit from lib.utils.icon_button import IconButton from lib.utils.list_model import EntryDelegate, EntryListModel, ListItem +from lib.utils.toolmap import MmuToolmapWidget from PyQt6 import QtCore, QtGui, QtWidgets logger = logging.getLogger(__name__) @@ -52,14 +52,19 @@ def __init__( self._previous_gate_states: dict[int, bool] = {} self.pre_gate_idx = {} - self.popup_gates: Deque = deque() + self.popup_gates: deque = deque() self._spool_id_map: dict[str, dict] = {} self._current_field: QtWidgets.QLineEdit | None = None self._color_target_field = None self.moonraker_run = True - self.amu_manager.mmu_state_changed.connect(self.on_mmu_state_changed) self._setup_pre_gate_popup() + self._setup_load_popup() + self.amu_manager.mmu_state_changed.connect(self.on_mmu_state_changed) + + self._extruder_current_temp = 0.0 + self._extruder_target_temp = 0.0 + self.printer.extruder_update.connect(self.on_extruder_update) self.spoolmanPanel = SpoolmanPage(self) self.addWidget(self.spoolmanPanel) @@ -97,7 +102,9 @@ def __init__( ) ) - self._basic_panel = BasicFilamentPanel(self.printer, self.cfg, parent=self) + self._basic_panel = BasicFilamentPanel( + self.printer, self.cfg, parent=self, load_popup=self.load_popup + ) self._basic_panel.run_gcode.connect(self.run_gcode) self._basic_panel.call_load_panel.connect(self.call_load_panel) self._basic_panel.request_back.connect(self.request_back) @@ -116,6 +123,7 @@ def __init__( self.run_gcode.connect(self.ws.api.run_gcode) def handle_moonraker_components(self): + """Build the pre-gate popup pages once, choosing spoolman vs. manual-entry order.""" if self.moonraker_run: components = self.ws._moonRest.get_server_info() if "spoolman" not in components["result"].get("components", []): @@ -137,6 +145,43 @@ def change_page(self, index: int) -> None: """ self.request_change_page.emit(1, index) + def _setup_load_popup(self) -> None: + self.load_popup = BasePopup(self, floating=False, dialog=False) + load_container = QtWidgets.QWidget(self.load_popup) + load_layout = QtWidgets.QVBoxLayout(load_container) + + self.load_status_label = QtWidgets.QLabel(load_container) + self.load_status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + label_font = QtGui.QFont() + label_font.setPointSize(20) + self.load_status_label.setFont(label_font) + self.load_status_label.setStyleSheet("color: #ffffff; background: transparent;") + self.load_status_label.setMaximumHeight(100) + load_layout.addWidget(self.load_status_label) + + self.load_status_widget = MmuToolmapWidget(load_container) + self.load_status_widget.set_left_text("Auxiliar Extruder") + load_layout.addWidget(self.load_status_widget) + + self.load_popup.add_widget(load_container) + + @QtCore.pyqtSlot(str, str, float, name="on-extruder-update") + def on_extruder_update( + self, extruder_name: str, field: str, new_value: float + ) -> None: + """Track the extruder's current/target temp and feed it to the load status widget.""" + if extruder_name != "extruder": + return + if field == "temperature": + self._extruder_current_temp = new_value + elif field == "target": + self._extruder_target_temp = new_value + else: + return + self.load_status_widget.set_temps( + self._extruder_current_temp, self._extruder_target_temp + ) + def _setup_pre_gate_popup(self) -> None: self._numpad = CustomNumpad(self) self._numpad.hide() @@ -511,6 +556,7 @@ def _add_row(title_text: str) -> QtWidgets.QLabel: return page def handle_skip_button(self): + """Map the pending pre-gate gate to an empty spool and dismiss the popup.""" gate = self.pre_gate_idx.get("gate", 0) self.popup.hide() self._reset_popup() @@ -520,7 +566,7 @@ def handle_skip_button(self): if self._popup_callback is not None: try: self._popup_callback() - except Exception as e: + except Exception as e: # noqa: BLE001 - arbitrary caller-supplied callback logger.error(f"Error executing pre-gate accept callback: {e}") finally: self._popup_callback = None @@ -557,6 +603,7 @@ def handle_popup(self, force=False): @QtCore.pyqtSlot(int, str, str, "PyQt_PyObject", name="open-pregate-popup") def open_pregate_popup(self, temp, material, name, callback=None): + """Open the pre-gate popup pre-filled with a detected filament's info and *callback*.""" self._popup_name.setText(name) self._popup_material.setText(material) self._popup_temp.setText(str(temp)) @@ -595,7 +642,7 @@ def on_popup_accept(self): if self._popup_callback is not None: try: self._popup_callback() - except Exception as e: + except Exception as e: # noqa: BLE001 - arbitrary caller-supplied callback logger.error(f"Error executing pre-gate accept callback: {e}") finally: self._popup_callback = None @@ -708,12 +755,13 @@ def _on_spool_selected(self) -> None: if self._popup_callback is not None: try: self._popup_callback() - except Exception as e: + except Exception as e: # noqa: BLE001 - arbitrary caller-supplied callback logger.error(f"Error executing pre-gate accept callback: {e}") finally: self._popup_callback = None def reset_spool_info(self): + """Clear the selected-spool detail labels back to their placeholder state.""" self.filament_name_label.setText("N/A") self.material_label.setText("N/A") self.weight_label.setText("N/A") @@ -836,7 +884,10 @@ def on_mmu_state_changed(self, mmu_state): if not self.amu_configured: if len(mmu_state.gates) > 1: - self.amupage = AMUpage(self.amu_manager, parent=self) + self.load_status_widget.set_left_text("AMU") + self.amupage = AMUpage( + self.amu_manager, parent=self, load_popup=self.load_popup + ) self.addWidget(self.amupage) try: self.removeWidget(self._basic_panel) @@ -862,24 +913,43 @@ def on_mmu_state_changed(self, mmu_state): self.printer.print_stats_update[str, float].connect( self.amupage.on_print_stats_update ) - self.amupage.call_load_panel.connect(self.call_load_panel) self.amupage.request_keyboard.connect(self._on_show_keyboard) self.amupage.request_color_wheel.connect(self._open_color_wheel) - + else: + self.load_status_widget.set_left_text("Auxiliar Extruder") self.amu_configured = True + self.load_status_widget.set_filament_pos( + mmu_state.filament_pos, mmu_state.bowden_progress + ) + for sensor_name in ("mmu_pre_gate", "mmu_gate", "toolhead"): + self.load_status_widget.set_sensor( + sensor_name, bool(mmu_state.sensors.get(sensor_name)) + ) + self.load_status_widget.set_action(mmu_state.action) + + gate_info = mmu_state.current_gate_info + raw_color = ( + str(gate_info.color).lstrip("#")[:6] + if gate_info and gate_info.color + else "" + ) + parsed_color = QtGui.QColor("#" + raw_color) if raw_color else QtGui.QColor() + if parsed_color.isValid(): + self.load_status_widget.set_gate_color(parsed_color) + + self.load_status_label.setText(mmu_state.action) + if self.load_state: if mmu_state.action == "Idle": self.load_state = False - self.call_load_panel.emit(False, "", True) - return - self.call_load_panel.emit(True, mmu_state.action, True) - - if mmu_state.action == "Loading" or mmu_state.action == "Unloading": + self.load_popup.hide() + elif mmu_state.action in ("Loading", "Unloading"): self.load_state = True - self.call_load_panel.emit(True, mmu_state.action, True) + self.load_popup.show() def setupUi(self): + """Build the tab's landing page (title + Filament Control / Spoolman buttons).""" self.resize(710, 410) self.setLayoutDirection(QtCore.Qt.LayoutDirection.LeftToRight) widget = QtWidgets.QWidget() diff --git a/BlocksScreen/lib/panels/widgets/amuPage.py b/BlocksScreen/lib/panels/widgets/amuPage.py index e11729a8..5823d6f3 100644 --- a/BlocksScreen/lib/panels/widgets/amuPage.py +++ b/BlocksScreen/lib/panels/widgets/amuPage.py @@ -3,6 +3,7 @@ from devices.amu import AMUManager from devices.amu.models import GateInfo from lib.panels.widgets.amuWidgets import SpoolCarousel, SpoolInfoPanel +from lib.panels.widgets.basePopup import BasePopup from lib.utils.blocks_frame import BlocksCustomFrame from lib.utils.icon_button import IconButton from PyQt6 import QtCore, QtGui, QtWidgets @@ -29,13 +30,13 @@ class AMUpage(QtWidgets.QStackedWidget): request_change_tab: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( int, name="request_change_tab" ) - call_load_panel = QtCore.pyqtSignal(bool, str, bool, name="call-load-panel") - def __init__(self, amu_manager, parent=None): + def __init__(self, amu_manager, parent=None, load_popup: BasePopup | None = None): super().__init__(parent) self.current_index = -1 self.amu_manager: AMUManager = amu_manager self._build_ui() + self.load_popup = load_popup self.main_back_button.clicked.connect(self.request_back) @@ -84,13 +85,13 @@ def __init__(self, amu_manager, parent=None): self.info_panel.loadRequested.connect( lambda: { self.amu_manager.load_gate(), - self.call_load_panel.emit(True, "Loading", True), + self.load_popup.show(), } ) self.info_panel.unloadRequested.connect( lambda: { self.amu_manager.unload(), - self.call_load_panel.emit(True, "Unloading", True), + self.load_popup.show(), } ) self.info_panel.ejectRequested.connect(self.amu_manager.eject_gate) @@ -102,29 +103,28 @@ def __init__(self, amu_manager, parent=None): @QtCore.pyqtSlot(str, float, name="on_print_stats_update") @QtCore.pyqtSlot(str, str, name="on_print_stats_update") def on_print_stats_update(self, field: str, value: dict | float | str) -> None: - if isinstance(value, str): - if "state" in field: - self.state = value - if value in ("printing", "pausing", "paused", "resuming"): - try: - self.main_back_button.clicked.disconnect() - except TypeError: - pass - - self.main_back_button.clicked.connect( - lambda: self.request_change_tab.emit(0) - ) - - else: - try: - self.main_back_button.clicked.disconnect() - except TypeError: - pass - self.main_back_button.clicked.connect( - lambda: self.request_back.emit() - ) + """Rewire the back button between "request_back" and "change to tab 0" based on print state.""" + if isinstance(value, str) and "state" in field: + self.state = value + if value in ("printing", "pausing", "paused", "resuming"): + try: + self.main_back_button.clicked.disconnect() + except TypeError: + pass + + self.main_back_button.clicked.connect( + lambda: self.request_change_tab.emit(0) + ) + + else: + try: + self.main_back_button.clicked.disconnect() + except TypeError: + pass + self.main_back_button.clicked.connect(lambda: self.request_back.emit()) def on_mmu_state_changed(self, mmu_state): + """Refresh the carousel and the info panel's selected gate from live MMU state.""" if mmu_state is None: return self.status = mmu_state @@ -134,6 +134,7 @@ def on_mmu_state_changed(self, mmu_state): self._on_selection(mmu_state.gate) def addSpool(self, gate_info: GateInfo): + """Add or refresh a gate's carousel button from its GateInfo.""" self.carousel.addSpool( QtGui.QColor("#" + str(gate_info.color)[:6]), gate_info.index, diff --git a/BlocksScreen/lib/panels/widgets/basicFilamentPanel.py b/BlocksScreen/lib/panels/widgets/basicFilamentPanel.py index 60c68da0..47cf4520 100644 --- a/BlocksScreen/lib/panels/widgets/basicFilamentPanel.py +++ b/BlocksScreen/lib/panels/widgets/basicFilamentPanel.py @@ -3,6 +3,7 @@ from functools import partial from lib.filament import Filament +from lib.panels.widgets.basePopup import BasePopup from lib.panels.widgets.popupDialogWidget import Popup from lib.printer import Printer from lib.utils.blocks_button import BlocksCustomButton @@ -38,9 +39,11 @@ class FilamentStates(enum.Enum): UNLOADED = enum.auto() def __repr__(self) -> str: - return "<%s.%s>" % (self.__class__.__name__, self._name_) + return f"<{self.__class__.__name__}.{self._name_}>" - def __init__(self, printer: Printer, cfg, parent=None) -> None: + def __init__( + self, printer: Printer, cfg, parent=None, load_popup: BasePopup | None = None + ) -> None: super().__init__(parent) self.printer = printer self.cfg = cfg @@ -51,6 +54,7 @@ def __init__(self, printer: Printer, cfg, parent=None) -> None: self.has_load_unload_objects = None self.filament_buttons_list = [] self.mmu_configured = False + self.load_popup = load_popup self._setupUi() self.filament_state = self.FilamentStates.UNKNOWN @@ -152,13 +156,12 @@ def on_filament_sensor_update(self, sensor_name: str, parameter: str, value: boo @QtCore.pyqtSlot(dict, name="on_load_filament") def on_load_filament(self, status: dict): """slot to handle load macro status updates""" - if "state" in status.keys(): - if not status["state"]: - self.target_temp = 0 - self.call_load_panel.emit(False, "", False) - if self.state == "paused": - self.request_change_tab.emit(0) - return + if "state" in status and not status["state"]: + self.target_temp = 0 + self.call_load_panel.emit(False, "", False) + if self.state == "paused": + self.request_change_tab.emit(0) + return self.call_load_panel.emit( True, f"Loading Filament\n{status['step'].capitalize()}", False ) @@ -166,11 +169,10 @@ def on_load_filament(self, status: dict): @QtCore.pyqtSlot(dict, name="on_unload_filament") def on_unload_filament(self, status: dict): """slot to handle unload macro status updates""" - if "state" in status.keys(): - if not status["state"]: - self.target_temp = 0 - self.call_load_panel.emit(False, "", False) - return + if "state" in status and not status["state"]: + self.target_temp = 0 + self.call_load_panel.emit(False, "", False) + return self.call_load_panel.emit( True, f"Unloading Filament\n{status['step'].capitalize()}", False ) @@ -197,10 +199,11 @@ def load_filament( self.run_gcode.emit( f"""SAVE_VARIABLE VARIABLE=filament_type VALUE='"{filament.value.name}"'""" ) - self.call_load_panel.emit(True, "Loading", True) if not self.mmu_configured: + self.call_load_panel.emit(True, "Loading", True) self.run_gcode.emit("LOAD_FILAMENT") return + self.load_popup.show() self.run_gcode.emit("MMU_LOAD") @QtCore.pyqtSlot(str, int, name="unload_filament") @@ -224,13 +227,15 @@ def unload_filament(self, toolhead: int = 0, temp: int = 220) -> None: f"""SAVE_VARIABLE VARIABLE=filament_type VALUE='"{FilamentTypes.UNKNOWN.value.name}"'""" ) - self.call_load_panel.emit(True, "Unloading", True) if not self.mmu_configured: + self.call_load_panel.emit(True, "Unloading", True) self.run_gcode.emit("UNLOAD_FILAMENT") return + self.load_popup.show() self.run_gcode.emit("MMU_UNLOAD") def open_pre_gate_popup(self, filament_type: FilamentTypes): + """Emit filament_selected so the pre-gate popup can confirm the gate before loading.""" callback_action = partial(self.load_filament, 0, filament_type) self.filament_selected.emit( @@ -241,6 +246,7 @@ def open_pre_gate_popup(self, filament_type: FilamentTypes): ) def on_mmu_state_changed(self, mmu_state): + """Wire load buttons to the pre-gate flow on first MMU state, and track filament state.""" if mmu_state is None: return @@ -277,26 +283,27 @@ def filament_state(self, update: FilamentStates) -> None: self.filament_page_unload_btn.setEnabled(True) def change_page(self, index: int) -> None: + """Switch this stacked widget to the page at *index*.""" self.setCurrentIndex(index) def back_button(self) -> None: + """Emit request_back to signal the parent tab to navigate away.""" self.request_back.emit() def find_routine_objects(self): + """Return whether load/unload gcode macros are available on the printer.""" if not self.printer: return _available_objects = self.printer.available_objects - if "load_filament" in _available_objects.keys(): + if "load_filament" in _available_objects: self.has_load_unload_objects = True return True - if "unload_filament" in _available_objects.keys(): + if "unload_filament" in _available_objects: self.has_load_unload_objects = True return True - if "gcode_macro LOAD_FILAMENT" in _available_objects.keys(): - return True - if "gcode_macro UNLOAD_FILAMENT" in _available_objects.keys(): + if "gcode_macro LOAD_FILAMENT" in _available_objects: return True - return False + return "gcode_macro UNLOAD_FILAMENT" in _available_objects def _setupInfoBox(self): root = BlocksCustomFrame(parent=self.filament_control_page) diff --git a/BlocksScreen/lib/utils/toolmap.py b/BlocksScreen/lib/utils/toolmap.py index d253e626..43820309 100644 --- a/BlocksScreen/lib/utils/toolmap.py +++ b/BlocksScreen/lib/utils/toolmap.py @@ -1,210 +1,320 @@ -from PyQt6 import QtWidgets, QtGui, QtCore -from enum import IntEnum +import typing +from devices.amu.models import FilamentPos +from PyQt6 import QtCore, QtGui, QtWidgets -class FilamentPos(IntEnum): - """State-machine position of the filament inside the MMU/extruder path.""" - UNKNOWN = -1 - UNLOADED = 0 - HOMED_GATE = 1 - START_BOWDEN = 2 - IN_BOWDEN = 3 - END_BOWDEN = 4 - HOMED_ENTRY = 5 - HOMED_EXTRUDER = 6 - EXTRUDER_ENTRY = 7 - HOMED_TS = 8 - IN_EXTRUDER = 9 - LOADED = 10 +class MmuToolmapWidget(QtWidgets.QWidget): + _POS: typing.ClassVar[dict[str, int]] = { + "BEFORE_PRE_GATE": 40, + "AFTER_GATE": 70, + "START_BOWDEN": 135, + "MID_BOWDEN": 221, + "END_BOWDEN": 290, + "EXTRUDER": 295, + "TOOLHEAD": 325, + "NOZZLE_START": 371, + } + _H_VIEW_W = 440 + _H_VIEW_H = 130 -class FilamentPathWidget(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) + _TUBE_Y = 58 + _TUBE_THICKNESS = 14 + _TUBE_START_X = 25 + _TAPER_START_X = 400 + _NOZZLE_TIP_X = 408 + _NOZZLE_FILL_START_X = 380 - self.filament_position = FilamentPos.UNLOADED - self.gate_name = "T0" + _GATE_ZONE = QtCore.QRectF(10, 8, 150, _H_VIEW_H - 16) + _TOOLHEAD_ZONE = QtCore.QRectF(290, 8, 85, _H_VIEW_H - 16) - # Animation property for smooth transitions - self._animation_progress = 0.0 - self._target_progress = 0.0 - self._animation = QtCore.QPropertyAnimation(self, b"animationProgress") - self._animation.setDuration(800) # 800ms transition - self._animation.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic) + _OUTLINE = QtGui.QColor("#ffffff") + _ZONE_BG = QtGui.QColor("#38465B7E") + _MUTED_TEXT = QtGui.QColor("#B4B4B4") + _ACTIVE = QtGui.QColor("#2ec4a0") + _HEATING = QtGui.QColor("#FF9800") - # Colors matching FlowGuard style - self._fill_color = QtGui.QColor(100, 200, 255) - self._node_color = QtGui.QColor(180, 180, 180) + _HEAT_ZONE = QtCore.QRectF(160, 20, 130, 30) - # Font - self._label_font = QtGui.QFont("Segoe UI", 9) - self._label_font.setBold(True) + def __init__(self, parent=None): + super().__init__(parent) - self.setMinimumSize(60, 200) + self.setMinimumSize(680, 260) self.setSizePolicy( - QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed ) - @QtCore.pyqtProperty(float) - def animationProgress(self): - """Property for QPropertyAnimation""" - return self._animation_progress - - @animationProgress.setter - def animationProgress(self, value): - """Setter for animation progress - triggers repaint""" - self._animation_progress = value - self.update() - - def set_filament_position(self, position: FilamentPos) -> None: - """Set the current filament position with smooth animation""" - self.filament_position = position - - # Get target percentage - target = self._get_position_percentage(position) - - # Animate from current progress to target - self._animation.stop() - self._animation.setStartValue(self._animation_progress) - self._animation.setEndValue(target) - self._animation.start() - - def set_gate_name(self, name: str) -> None: - """Set the gate/tool name""" - self.gate_name = name - self.update() - - def _get_position_percentage(self, position: FilamentPos) -> float: - """Convert FilamentPos to percentage along the path (0.0 to 1.0)""" - position_map = { - FilamentPos.UNKNOWN: 0.0, - FilamentPos.UNLOADED: 0.0, - FilamentPos.HOMED_GATE: 0.15, - FilamentPos.START_BOWDEN: 0.25, - FilamentPos.IN_BOWDEN: 0.45, - FilamentPos.END_BOWDEN: 0.65, - FilamentPos.HOMED_ENTRY: 0.70, - FilamentPos.HOMED_EXTRUDER: 0.75, - FilamentPos.EXTRUDER_ENTRY: 0.80, - FilamentPos.HOMED_TS: 0.85, - FilamentPos.IN_EXTRUDER: 0.92, - FilamentPos.LOADED: 1.0, + self._label_font = QtGui.QFont("Segoe UI", 10) + self._zone_font = QtGui.QFont() + self._zone_font.setBold(True) + self._zone_font.setPointSize(9) + self._temp_font = QtGui.QFont() + self._temp_font.setPointSize(8) + + self._filament_pos = FilamentPos.UNLOADED + self._bowden_progress = -1.0 + self._gate_color = QtGui.QColor("#787878") + self._action = "" + self._current_temp = 0.0 + self._target_temp = 0.0 + + self.lefttext: str = "AMU" + + self._heat_dot_count = 0 + self._heat_dot_timer = QtCore.QTimer(self) + self._heat_dot_timer.setInterval(1000) + self._heat_dot_timer.timeout.connect(self._add_heat_dot) + + self._sensors = { + "mmu_pre_gate": False, + "mmu_gate": False, + "toolhead": False, } - return position_map.get(position, 0.0) - - def _draw_vertical_path(self, painter: QtGui.QPainter) -> None: - """Draw the vertical path bar""" - rect = self.rect() - - top_margin = 30 - bottom_margin = 30 - - bar_width = 30 - bar_x = int(rect.width() / 2 - bar_width / 2) - bar_y = top_margin - bar_height = rect.height() - top_margin - bottom_margin - - # Draw background track - bg_pen = QtGui.QPen(QtGui.QColor(40, 40, 40), 2) - painter.setPen(bg_pen) - painter.setBrush(QtGui.QColor(25, 25, 25)) - track_rect = QtCore.QRectF(bar_x, bar_y, bar_width, bar_height) - painter.drawRect(track_rect) + self._fill_progress = 0.0 + self._fill_animation = QtCore.QPropertyAnimation(self, b"fillProgress") + self._fill_animation.setDuration(500) + self._fill_animation.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic) - # Draw filament fill (from bottom up) using animated progress - if self._animation_progress > 0.0: - fill_height = self._animation_progress * bar_height - - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(self._fill_color) - - fill_rect = QtCore.QRectF(bar_x, bar_y, bar_width, fill_height) - painter.drawRect(fill_rect) - - # Draw position nodes - center_x = bar_x + bar_width / 2 + @QtCore.pyqtProperty(float) + def fillProgress(self): + return self._fill_progress - # Pre-Gate node (top) - pregate_y = bar_y - painter.setPen(QtGui.QPen(self._node_color, 2)) - painter.setBrush(QtGui.QColor(25, 25, 25)) - painter.drawEllipse(QtCore.QPointF(center_x, pregate_y), 5, 5) + @fillProgress.setter + def fillProgress(self, value): + self._fill_progress = value + self.update() - # Extruder box (middle ~75% down) - extruder_y = bar_y + bar_height * 0.70 - box_height = 40 - box_y = extruder_y - box_height / 2 + def set_gate_color(self, color: QtGui.QColor) -> None: + """Set the color of the filament fill bar, e.g. from the selected gate's spool color.""" + self._gate_color = color + self.update() - pen = QtGui.QPen(QtGui.QColor(0, 150, 150), 2) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + def set_sensor(self, name: str, active: bool) -> None: + """Set the active state of a sensor marker ("mmu_pre_gate", "mmu_gate", "toolhead").""" + self._sensors[name] = active + self.update() - box_rect = QtCore.QRectF(bar_x - 3, box_y, bar_width + 6, box_height) - painter.drawRect(box_rect) + def set_left_text(self, text: str) -> None: + """sets left box title (e.g. "AMU" , "AUXILIAR EXTRUDER")""" + self.lefttext = text + self.update() - # Hub/Gate node (bottom) - hub_y = bar_y + bar_height - painter.setPen(QtGui.QPen(self._node_color, 2)) - painter.setBrush(QtGui.QColor(25, 25, 25)) - painter.drawEllipse(QtCore.QPointF(center_x, hub_y), 5, 5) + def set_action(self, action: str) -> None: + """Set the current MMU action (e.g. "Idle", "Loading", "Unloading", "Heating"). + + Starts/stops the heating-dot animation timer as needed. + """ + self._action = action + if action == "Heating": + if not self._heat_dot_timer.isActive(): + self._heat_dot_count = 0 + self._heat_dot_timer.start() + else: + self._heat_dot_timer.stop() + self.update() - def _draw_labels(self, painter: QtGui.QPainter) -> None: - """Draw labels""" - painter.setPen(QtGui.QColor(180, 180, 180)) - painter.setFont(self._label_font) + def _add_heat_dot(self) -> None: + self._heat_dot_count += 1 + if self._heat_dot_count > 3: + self._heat_dot_count = 0 + self.update() - # Top label - top_rect = QtCore.QRectF(0, 5, self.width(), 20) - painter.drawText(top_rect, QtCore.Qt.AlignmentFlag.AlignCenter, self.gate_name) + def set_temps(self, current: float, target: float) -> None: + """Set the extruder current/target temperatures shown under the heating message.""" + self._current_temp = current + self._target_temp = target + self.update() - # Bottom label - bottom_y = self.height() - 25 - bottom_rect = QtCore.QRectF(0, bottom_y, self.width(), 20) - painter.drawText(bottom_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "Toolhead") + def set_filament_pos(self, pos: FilamentPos, bowden_progress: float = -1.0) -> None: + """Animate the fill bar to the local-coordinate x matching the given filament position. + + Args: + pos: Current position of the filament in the MMU/extruder path. + bowden_progress: Percentage (0-100) through the bowden tube; only used + when pos is START_BOWDEN/IN_BOWDEN and >= 0, otherwise the fill + snaps to fixed checkpoints for that position. + """ + self._filament_pos = pos + self._bowden_progress = bowden_progress + self._animate_fill_to(self._compute_fill_height()) + + def _compute_fill_height(self) -> float: + pos = self._filament_pos + P = self._POS + + if pos == FilamentPos.UNLOADED: + return P["BEFORE_PRE_GATE"] + if pos == FilamentPos.HOMED_GATE: + return P["AFTER_GATE"] + if ( + pos in (FilamentPos.START_BOWDEN, FilamentPos.IN_BOWDEN) + and self._bowden_progress >= 0 + ): + bowden_range = P["END_BOWDEN"] - P["START_BOWDEN"] + return P["START_BOWDEN"] + bowden_range * self._bowden_progress / 100 + if pos == FilamentPos.START_BOWDEN: + return P["START_BOWDEN"] + if pos == FilamentPos.IN_BOWDEN: + return P["MID_BOWDEN"] + if pos in (FilamentPos.END_BOWDEN, FilamentPos.HOMED_ENTRY): + return P["EXTRUDER"] + if pos in (FilamentPos.HOMED_EXTRUDER, FilamentPos.EXTRUDER_ENTRY): + return P["TOOLHEAD"] - 10 + if pos == FilamentPos.HOMED_TS: + return P["TOOLHEAD"] + if pos == FilamentPos.IN_EXTRUDER: + return P["TOOLHEAD"] + 13 + if pos == FilamentPos.LOADED: + return P["NOZZLE_START"] + return 0.0 + + def _animate_fill_to(self, value: float) -> None: + self._fill_animation.stop() + self._fill_animation.setStartValue(self._fill_progress) + self._fill_animation.setEndValue(value) + self._fill_animation.start() + + def _transform(self) -> QtGui.QTransform: + scale = min(self.width() / self._H_VIEW_W, self.height() / self._H_VIEW_H) + offset_x = (self.width() - self._H_VIEW_W * scale) / 2 + offset_y = (self.height() - self._H_VIEW_H * scale) / 2 + t = QtGui.QTransform() + t.translate(offset_x, offset_y) + t.scale(scale, scale) + return t def paintEvent(self, event: QtGui.QPaintEvent) -> None: - """Paint the widget""" painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + painter.setTransform(self._transform()) - self._draw_vertical_path(painter) - self._draw_labels(painter) + self._draw_zones(painter) + self._draw_tube(painter) + self._draw_nozzle(painter) + self._draw_sensors(painter) + self._draw_heat_message(painter) painter.end() + def _draw_zones(self, painter: QtGui.QPainter) -> None: + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(self._ZONE_BG) + painter.drawRoundedRect(self._GATE_ZONE, 20, 20) + painter.drawRoundedRect(self._TOOLHEAD_ZONE, 20, 20) + + painter.setPen(QtGui.QColor("#FFFFFF")) + painter.setFont(self._zone_font) + for zone, name in ( + (self._GATE_ZONE, self.lefttext), + (self._TOOLHEAD_ZONE, "Toolhead"), + ): + painter.drawText( + QtCore.QRectF(zone.left(), zone.top() + 12, zone.width(), 14), + QtCore.Qt.AlignmentFlag.AlignHCenter, + name, + ) + + def _draw_tube(self, painter: QtGui.QPainter) -> None: + outline = QtGui.QPainterPath() + outline.moveTo(self._TUBE_START_X, self._TUBE_Y) + outline.lineTo(self._TAPER_START_X, self._TUBE_Y) + outline.lineTo(self._NOZZLE_TIP_X, self._TUBE_Y + self._TUBE_THICKNESS / 2) + outline.lineTo(self._TAPER_START_X, self._TUBE_Y + self._TUBE_THICKNESS) + outline.lineTo(self._TUBE_START_X, self._TUBE_Y + self._TUBE_THICKNESS) + painter.setPen(QtGui.QPen(self._OUTLINE, 1)) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.drawPath(outline) -# Example usage -if __name__ == "__main__": - import sys + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(self._gate_color) + fill_width = min( + max(0.0, self._fill_progress), self._TAPER_START_X - self._TUBE_START_X + ) + painter.drawRect( + QtCore.QRectF( + self._TUBE_START_X, self._TUBE_Y, fill_width, self._TUBE_THICKNESS + ) + ) - app = QtWidgets.QApplication(sys.argv) + def _draw_heat_message(self, painter: QtGui.QPainter) -> None: + if self._action != "Heating": + return + dots = ("." * self._heat_dot_count) + (" " * (3 - self._heat_dot_count)) - window = QtWidgets.QWidget() - window.setWindowTitle("Filament Path Widget Test") - window.setStyleSheet("background-color: #2a2a2a;") + top_zone = QtCore.QRectF( + self._HEAT_ZONE.left(), + self._HEAT_ZONE.top(), + self._HEAT_ZONE.width(), + self._HEAT_ZONE.height() / 2, + ) + bottom_zone = QtCore.QRectF( + self._HEAT_ZONE.left(), + self._HEAT_ZONE.top() + self._HEAT_ZONE.height() / 2, + self._HEAT_ZONE.width(), + self._HEAT_ZONE.height() / 2, + ) + + painter.setPen(self._HEATING) + painter.setFont(self._zone_font) + painter.drawText( + top_zone, QtCore.Qt.AlignmentFlag.AlignCenter, f"Heating{dots}" + ) + + painter.setPen(self._MUTED_TEXT) + painter.setFont(self._temp_font) + painter.drawText( + bottom_zone, + QtCore.Qt.AlignmentFlag.AlignCenter, + f"{self._current_temp:.0f}°/{self._target_temp:.0f}°", + ) + + def _draw_nozzle(self, painter: QtGui.QPainter) -> None: + if self._filament_pos != FilamentPos.LOADED: + return + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(self._gate_color) + painter.drawPolygon( + QtGui.QPolygonF( + [ + QtCore.QPointF(self._NOZZLE_FILL_START_X, self._TUBE_Y), + QtCore.QPointF(self._TAPER_START_X, self._TUBE_Y), + QtCore.QPointF( + self._NOZZLE_TIP_X, self._TUBE_Y + self._TUBE_THICKNESS / 2 + ), + QtCore.QPointF( + self._TAPER_START_X, self._TUBE_Y + self._TUBE_THICKNESS + ), + QtCore.QPointF( + self._NOZZLE_FILL_START_X, self._TUBE_Y + self._TUBE_THICKNESS + ), + ] + ) + ) - layout = QtWidgets.QHBoxLayout(window) + def _draw_sensor_marker( + self, painter: QtGui.QPainter, x: float, label: str, active: bool + ) -> None: + color = self._ACTIVE if active else self._MUTED_TEXT + line_top_y = self._TUBE_Y + self._TUBE_THICKNESS - # Add slider for testing - test_widget = FilamentPathWidget() - test_widget.set_gate_name("T0") - test_widget.setMaximumSize(30, 200) - layout.addWidget(test_widget) + painter.setPen(QtGui.QPen(color, 1.5)) + painter.drawLine(QtCore.QPointF(x, line_top_y), QtCore.QPointF(x, 96)) - slider_layout = QtWidgets.QVBoxLayout() - slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Vertical) - slider.setMinimum(0) - slider.setMaximum(10) - slider.valueChanged.connect( - lambda v: test_widget.set_filament_position(FilamentPos(v)) - ) - slider_layout.addWidget(QtWidgets.QLabel("Test")) - slider_layout.addWidget(slider) - layout.addLayout(slider_layout) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(color) + painter.drawEllipse(QtCore.QPointF(x, line_top_y), 3.5, 3.5) - window.resize(450, 350) - window.show() + painter.setPen(color) + painter.setFont(self._label_font) + painter.drawText( + QtCore.QRectF(x - 30, 98, 60, 16), + QtCore.Qt.AlignmentFlag.AlignCenter, + label, + ) - sys.exit(app.exec()) + def _draw_sensors(self, painter: QtGui.QPainter) -> None: + self._draw_sensor_marker(painter, 50, "Pre-Gate", self._sensors["mmu_pre_gate"]) + self._draw_sensor_marker(painter, 110, "Gate", self._sensors["mmu_gate"]) + self._draw_sensor_marker(painter, 332, "Toolhead", self._sensors["toolhead"])