Source code for dbs_annotator.models.electrode_viewer

"""
DBS Electrode 3D Interactive Viewer
Interactive 3D visualization of deep brain stimulation electrodes
with anodic/cathodic modes and case (ground) support
Based on Lead-DBS repository models
"""

import typing

from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import (
    QBrush,
    QColor,
    QFont,
    QLinearGradient,
    QPainter,
    QPainterPath,
    QPainterPathStroker,
    QPalette,
    QPen,
    QPolygonF,
    QRadialGradient,
)
from PySide6.QtWidgets import QSizePolicy, QWidget

# Import configuration
from ..config_electrode_models import (
    ContactState,
    StimulationRule,
)


[docs] class ElectrodeCanvas(QWidget): """Canvas for drawing 2D electrode visualization with clickable contacts""" def __init__(self, parent=None): """Initialize the electrode canvas with default empty state.""" super().__init__(parent) self.model = None self.contact_states = {} # {(contact_idx, segment_idx): ContactState} self.case_state = ContactState.OFF # Case (ground) state self.contact_rects = {} # Dictionary to store contact positions self.contact_hit_areas = {} # {(contact_idx, segment_idx): QPainterPath} self.ring_rects = {} # Dictionary for ring "caps" on directional electrodes self.case_rect = None # Case rectangle self.hovered_contact = None self.hovered_ring = None self.hovered_case = False self.validation_callback = None # Callback for validation self.setMinimumWidth(160) self.setContentsMargins(2, 2, 2, 2) self.setAutoFillBackground(False) self.setMouseTracking(True) self.setCursor(Qt.CursorShape.PointingHandCursor) self.export_mode = False self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def set_export_mode(self, enabled: bool) -> None: """Toggle export mode (tighter padding, larger scale for PNG output).""" self.export_mode = bool(enabled) self.update() def set_model(self, model): """Set the electrode model and reset all states""" self.model = model self.contact_states.clear() self.case_state = ContactState.OFF self.contact_rects.clear() self.contact_hit_areas.clear() self.ring_rects.clear() self.case_rect = None self.hovered_contact = None self.hovered_ring = None self.hovered_case = False self.update() def _is_contact_directional(self, contact_idx: int) -> bool: """Return True if the given contact index is a segmented (directional) contact.""" if not self.model: return False return self.model.is_level_directional(contact_idx) def calculate_scale(self): """Calculate exact scale to fill the canvas with the electrode drawing.""" if not self.model: return 20 contacts_total_mm = ( self.model.num_contacts * self.model.contact_height + max(0, self.model.num_contacts - 1) * self.model.contact_spacing ) # Fixed pixel overhead (not scale-dependent): # top_padding (before case) + lead_gap (case to lead body) top_padding = 2 if self.export_mode else 7 lead_gap = 8 if self.export_mode else 15 fixed_px = top_padding + lead_gap # Scale-dependent overhead in mm: # case(4mm) + initial_y_offset(2mm) + 1mm per inter-contact gap + tail(0.3mm) scale_overhead_mm = 4.0 + 2.0 + max(0, self.model.num_contacts - 1) * 1.0 + 0.3 scaled_mm = contacts_total_mm + scale_overhead_mm # Exact formula: fixed_px + scaled_mm * scale = canvas_height usable = max(1, self.height() - fixed_px - 2) # 2px safety margin scale = usable / scaled_mm max_scale = 80 if self.export_mode else 24 return min(scale, max_scale) def get_contact_at_pos(self, pos): """Return contact (contact_index, segment_index) at mouse position""" point = QPointF(pos) for contact_id, hit_area in self.contact_hit_areas.items(): if hit_area.contains(point): return contact_id return None def get_ring_at_pos(self, pos): """Return ring index at mouse position""" for ring_idx, rect in self.ring_rects.items(): # Make ring easier to click pad = 6 if rect.adjusted(-pad, -pad, pad, pad).contains(pos): return ring_idx return None def is_case_at_pos(self, pos): """Check if mouse is over the case""" if self.case_rect and self.case_rect.contains(pos): return True return False def _apply_change_if_valid(self, new_contact_states, new_case_state): """Apply new contact/case states and invoke validation callback.""" is_valid, error_msg = StimulationRule.validate_configuration( new_contact_states, new_case_state, ) # Always apply the change regardless of validation self.contact_states = new_contact_states self.case_state = new_case_state if self.validation_callback: self.validation_callback(is_valid, error_msg) self.update() return is_valid def cycle_contact_state(self, contact_id): """Cycle contact state: OFF -> ANODIC -> CATHODIC -> OFF""" new_states = dict(self.contact_states) current_state = new_states.get(contact_id, ContactState.OFF) if current_state == ContactState.OFF: new_states[contact_id] = ContactState.ANODIC elif current_state == ContactState.ANODIC: new_states[contact_id] = ContactState.CATHODIC elif current_state == ContactState.CATHODIC: if contact_id in new_states: del new_states[contact_id] self._apply_change_if_valid(new_states, self.case_state) def cycle_case_state(self): """Cycle case state: OFF -> ANODIC -> CATHODIC -> OFF""" new_case_state = self.case_state if self.case_state == ContactState.OFF: new_case_state = ContactState.ANODIC elif self.case_state == ContactState.ANODIC: new_case_state = ContactState.CATHODIC elif self.case_state == ContactState.CATHODIC: new_case_state = ContactState.OFF self._apply_change_if_valid(dict(self.contact_states), new_case_state) def set_ring_state(self, ring_idx, state): """Set state for all segments of a ring""" if ( not self.model or not self.model.is_directional or not self._is_contact_directional(ring_idx) ): return new_states = dict(self.contact_states) for seg in range(3): contact_id = (ring_idx, seg) if state == ContactState.OFF: if contact_id in new_states: del new_states[contact_id] else: new_states[contact_id] = state self._apply_change_if_valid(new_states, self.case_state) @typing.override def mousePressEvent(self, event): """Handle clicks on contacts, rings and case""" if event.button() == Qt.MouseButton.LeftButton: # Check if a contact was clicked contact_id = self.get_contact_at_pos(event.pos()) if contact_id: self.cycle_contact_state(contact_id) return # Check if a ring (cap) was clicked ring_idx = self.get_ring_at_pos(event.pos()) if ring_idx is not None: # Cycle entire ring state states = [] for seg in range(3): contact_id = (ring_idx, seg) states.append(self.contact_states.get(contact_id, ContactState.OFF)) # Determine predominant state if all(s == ContactState.OFF for s in states): new_state = ContactState.ANODIC elif all(s == ContactState.ANODIC for s in states): new_state = ContactState.CATHODIC else: new_state = ContactState.OFF self.set_ring_state(ring_idx, new_state) return # Check if case was clicked if self.is_case_at_pos(event.pos()): self.cycle_case_state() return @typing.override def mouseMoveEvent(self, event): """Handle hover over contacts, rings and case""" old_hovered_contact = self.hovered_contact old_hovered_ring = self.hovered_ring old_hovered_case = self.hovered_case self.hovered_case = self.is_case_at_pos(event.pos()) self.hovered_ring = self.get_ring_at_pos(event.pos()) self.hovered_contact = self.get_contact_at_pos(event.pos()) if ( old_hovered_contact != self.hovered_contact or old_hovered_ring != self.hovered_ring or old_hovered_case != self.hovered_case ): self.update() def get_state_color(self, state, is_hovered=False): """Return color based on state""" if state == ContactState.ANODIC: base_color = QColor(255, 100, 100) # Red for anodic border_color = QColor(200, 50, 50) elif state == ContactState.CATHODIC: base_color = QColor(100, 150, 255) # Blue for cathodic border_color = QColor(50, 100, 200) else: base_color = QColor(150, 150, 150) # Gray for OFF border_color = QColor(50, 50, 50) if is_hovered: base_color = base_color.lighter(120) border_color = border_color.lighter(120) border_width = 3 if state != ContactState.OFF else 1 if is_hovered: border_width += 1 return base_color, border_color, border_width @typing.override def paintEvent(self, event): """Render the electrode lead, contacts, case, and labels.""" if not self.model: return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) # Background palette = self.palette() # painter.fillRect(self.rect(), palette.color(QPalette.Window)) # Calculate optimal scale scale = self.calculate_scale() # Export: extra left room for E0–En labels beside directional segments. export_pad_left = max(56, int(scale * 0.75)) if self.export_mode else 0 center_x = export_pad_left + (self.width() - export_pad_left) / 2 - 4 top_padding = 2 if self.export_mode else 7 # Clear position dictionaries self.contact_rects.clear() self.contact_hit_areas.clear() self.ring_rects.clear() # Draw case (ground) at the top case_height = 4 * scale case_width = self.model.lead_diameter * scale * 1.35 + 10 case_x = center_x - case_width / 2 case_y = top_padding start_y = case_y + case_height + (8 if self.export_mode else 15) self.case_rect = QRectF(case_x, case_y, case_width, case_height) color, border_color, border_width = self.get_state_color( self.case_state, self.hovered_case ) painter.setBrush(QBrush(color)) painter.setPen(QPen(border_color, border_width)) painter.drawRoundedRect(self.case_rect, 5, 5) # 3D gradient for case case_gradient = QLinearGradient(case_x, case_y, case_x, case_y + case_height) color, border_color, border_width = self.get_state_color( self.case_state, self.hovered_case ) case_gradient.setColorAt(0, color.lighter(130)) case_gradient.setColorAt(0.5, color) case_gradient.setColorAt(1, color.darker(120)) painter.setBrush(QBrush(case_gradient)) painter.setPen(QPen(border_color, border_width)) painter.drawRoundedRect(self.case_rect, 5, 5) # Add specular highlight on case highlight_rect = QRectF( case_x + 2, case_y + 1, case_width * 0.4, case_height * 0.3 ) painter.setBrush(QColor(255, 255, 255, 40)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(highlight_rect, 3, 3) # Case label - smaller font in export mode painter.setPen( Qt.GlobalColor.white if self.case_state != ContactState.OFF else Qt.GlobalColor.black ) case_font_size = ( max(7, int(scale * 0.35)) if self.export_mode else max(8, int(scale * 0.4)) ) font = QFont("Arial", case_font_size, QFont.Weight.Bold) painter.setFont(font) painter.drawText(self.case_rect, Qt.AlignmentFlag.AlignCenter, "CASE") # Draw electrode body (lead) lead_width = self.model.lead_diameter * scale * 1.8 # Calculate where E0 (bottom contact) will be positioned contact_height_px = self.model.contact_height * scale # E0 is the last contact (index 0), positioned after all other # contacts and their spacing. e0_y_position = start_y + 2 * scale # Initial offset for _ in range(self.model.num_contacts - 1): # All contacts except E0 e0_y_position += ( contact_height_px + (self.model.contact_spacing + 1.0) * scale ) # Lead body end position depends on electrode type if getattr(self.model, "tip_contact", False): # Boston Scientific: lead ends at top of E0 (the tip IS a contact) total_height = e0_y_position - start_y else: # Medtronic/Abbott: lead extends slightly below E0 (0.3mm tail) total_height = e0_y_position + contact_height_px + 0.3 * scale - start_y # Create linear gradient for cylindrical effect (no spotlight) lead_gradient = QLinearGradient( center_x - lead_width / 2, start_y, center_x + lead_width / 2, start_y ) base_color = palette.color(QPalette.ColorRole.Midlight) lead_gradient.setColorAt(0, base_color.darker(120)) lead_gradient.setColorAt(0.3, base_color) lead_gradient.setColorAt(0.7, base_color) lead_gradient.setColorAt(1, base_color.darker(120)) painter.setBrush(QBrush(lead_gradient)) painter.setPen(QPen(palette.color(QPalette.ColorRole.Dark), 2)) if getattr(self.model, "tip_contact", False): # Boston Scientific: flat bottom so lead seamlessly meets the tip contact corner = lead_width / 4 lead_body_path = QPainterPath() lead_body_path.moveTo(center_x - lead_width / 2, start_y + corner) lead_body_path.arcTo( center_x - lead_width / 2, start_y, corner * 2, corner * 2, 180, -90 ) lead_body_path.lineTo(center_x + lead_width / 2 - corner, start_y) lead_body_path.arcTo( center_x + lead_width / 2 - corner * 2, start_y, corner * 2, corner * 2, 90, -90, ) lead_body_path.lineTo(center_x + lead_width / 2, start_y + total_height) lead_body_path.lineTo(center_x - lead_width / 2, start_y + total_height) lead_body_path.closeSubpath() painter.drawPath(lead_body_path) else: painter.drawRoundedRect( int(center_x - lead_width / 2), int(start_y), int(lead_width), int(total_height), int(lead_width / 4), int(lead_width / 4), ) # Add ambient occlusion (subtle shadows on edges) shadow_left = QLinearGradient( center_x - lead_width / 2, start_y, center_x - lead_width / 2 + lead_width * 0.1, start_y, ) shadow_left.setColorAt(0, QColor(0, 0, 0, 30)) shadow_left.setColorAt(1, QColor(0, 0, 0, 0)) painter.setBrush(QBrush(shadow_left)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRect( int(center_x - lead_width / 2), int(start_y), int(lead_width * 0.1), int(total_height), ) shadow_right = QLinearGradient( center_x + lead_width / 2 - lead_width * 0.1, start_y, center_x + lead_width / 2, start_y, ) shadow_right.setColorAt(0, QColor(0, 0, 0, 0)) shadow_right.setColorAt(1, QColor(0, 0, 0, 30)) painter.setBrush(QBrush(shadow_right)) painter.drawRect( int(center_x + lead_width / 2 - lead_width * 0.1), int(start_y), int(lead_width * 0.1), int(total_height), ) # Draw contacts with metallic 3D appearance current_y = start_y + 2 * scale base_extension = lead_width * 0.22 if self.model.is_directional else 0 for i in range(self.model.num_contacts): contact_height_px = self.model.contact_height * scale contact_number = self.model.num_contacts - 1 - i contact_idx = contact_number is_directional_contact = self._is_contact_directional(contact_idx) if is_directional_contact: # Store ring cap position but don't draw yet - will draw after contacts ring_cap_height = contact_height_px * 0.8 ring_cap_width = lead_width * 1.1 # Will be recalculated below ring_cap_rect = QRectF( center_x - ring_cap_width / 2, current_y - ring_cap_height, ring_cap_width, ring_cap_height, ) self.ring_rects[contact_idx] = ring_cap_rect # Directional electrode with 3D metallic segments lead_width * 0.5 extension = base_extension # Helper function to draw 3D segment def draw_3d_segment(poly, state, is_hovered, contact_id, label): color, border, width = self.get_state_color(state, is_hovered) # Create gradient for metallic appearance bounds = poly.boundingRect() segment_gradient = QRadialGradient( bounds.center().x(), bounds.center().y(), bounds.width() * 0.6 ) segment_gradient.setColorAt(0, color.lighter(150)) segment_gradient.setColorAt(0.5, color.lighter(110)) segment_gradient.setColorAt(0.9, color.darker(110)) segment_gradient.setColorAt(1, color.darker(130)) # Draw shadow beneath segment shadow_poly = QPolygonF(poly) shadow_poly.translate(1, 2) painter.setBrush(QColor(0, 0, 0, 30)) painter.setPen(Qt.PenStyle.NoPen) painter.drawPolygon(shadow_poly) # Draw main segment painter.setBrush(QBrush(segment_gradient)) painter.setPen(QPen(border, width)) painter.drawPolygon(poly) # Add specular highlight if state != ContactState.OFF: highlight_poly = QPolygonF( [ QPointF( bounds.left() + bounds.width() * 0.2, bounds.top() ), QPointF( bounds.left() + bounds.width() * 0.5, bounds.top() ), QPointF( bounds.left() + bounds.width() * 0.4, bounds.top() + bounds.height() * 0.3, ), QPointF( bounds.left() + bounds.width() * 0.1, bounds.top() + bounds.height() * 0.3, ), ] ) painter.setBrush(QColor(255, 255, 255, 40)) painter.setPen(Qt.PenStyle.NoPen) painter.drawPolygon(highlight_poly) self.contact_rects[contact_id] = bounds path = QPainterPath() path.addPolygon(poly) # Expand clickable area for better UX stroker = QPainterPathStroker() stroker.setWidth(max(10.0, scale * 0.8)) self.contact_hit_areas[contact_id] = stroker.createStroke( path ).united(path) # Label - smaller font in export mode painter.setPen( Qt.GlobalColor.white if state != ContactState.OFF else Qt.GlobalColor.black ) font_size = ( max(6, int(scale * 0.3)) if self.export_mode else max(7, int(scale * 0.4)) ) font = QFont("Arial", font_size, QFont.Weight.Bold) painter.setFont(font) painter.drawText(bounds, Qt.AlignmentFlag.AlignCenter, label) # Segment 'a' (left) - extends left contact_id_a = (contact_idx, 0) state_a = self.contact_states.get(contact_id_a, ContactState.OFF) is_hovered_a = contact_id_a == self.hovered_contact # Calculate positions to center b and prevent overlap b_width = lead_width * 0.55 b_left = center_x - b_width / 2 x_left = center_x - lead_width / 2 - extension poly_a = QPolygonF( [ QPointF(x_left - 2, current_y), QPointF(b_left - 1, current_y), # 2px gap from b QPointF(b_left - 2, current_y + contact_height_px), QPointF(x_left + extension / 2, current_y + contact_height_px), ] ) draw_3d_segment(poly_a, state_a, is_hovered_a, contact_id_a, "a") # Segment 'b' (center) contact_id_b = (contact_idx, 1) state_b = self.contact_states.get(contact_id_b, ContactState.OFF) is_hovered_b = contact_id_b == self.hovered_contact rect_b = QRectF(b_left, current_y, b_width, contact_height_px) # Convert rect to polygon for consistent rendering poly_b = QPolygonF( [ rect_b.topLeft(), rect_b.topRight(), rect_b.bottomRight(), rect_b.bottomLeft(), ] ) draw_3d_segment(poly_b, state_b, is_hovered_b, contact_id_b, "b") # Segment 'c' (right) - extends right contact_id_c = (contact_idx, 2) state_c = self.contact_states.get(contact_id_c, ContactState.OFF) is_hovered_c = contact_id_c == self.hovered_contact x_right = center_x + lead_width / 2 + extension poly_c = QPolygonF( [ QPointF(b_left + b_width + 2, current_y), # 2px gap from b QPointF(x_right + 2, current_y), QPointF(x_right - extension / 2, current_y + contact_height_px), QPointF(b_left + b_width + 2, current_y + contact_height_px), ] ) draw_3d_segment(poly_c, state_c, is_hovered_c, contact_id_c, "c") # Recalculate ring cap to align with segment edges a_left = center_x - lead_width / 2 - extension c_right = center_x + lead_width / 2 + extension ring_cap_width = c_right - a_left ring_cap_rect = QRectF( a_left, current_y - ring_cap_height * 0.9, ring_cap_width, ring_cap_height, ) self.ring_rects[contact_idx] = ( ring_cap_rect # Update with corrected position ) else: # Standard electrode without segments (also used for the # first and last contacts in directional leads). contact_id = (contact_idx, 0) contact_state = self.contact_states.get(contact_id, ContactState.OFF) is_hovered = contact_id == self.hovered_contact color, border_color, border_width = self.get_state_color( contact_state, is_hovered ) painter.setBrush(QBrush(color)) painter.setPen(QPen(border_color, border_width)) # Check if this is the tip contact (E0 on Boston Scientific) is_tip = contact_idx == 0 and getattr(self.model, "tip_contact", False) # Draw contact with cylindrical gradient rect = QRectF( center_x - lead_width / 2 + 2, current_y, lead_width - 4, contact_height_px, ) # Radial gradient for cylindrical contact contact_gradient = QRadialGradient( rect.center().x(), rect.center().y(), rect.width() * 0.6 ) contact_gradient.setColorAt(0, color.lighter(150)) contact_gradient.setColorAt(0.5, color.lighter(110)) contact_gradient.setColorAt(0.85, color.darker(110)) contact_gradient.setColorAt(1, color.darker(130)) if is_tip: # Boston Scientific tip contact: flush with lead body, # hemispherical bottom. tip_left = center_x - lead_width / 2 tip_right = center_x + lead_width / 2 tip_width = tip_right - tip_left tip_radius = tip_width / 2 tip_path = QPainterPath() tip_path.moveTo(tip_left, current_y) tip_path.lineTo(tip_right, current_y) tip_path.lineTo(tip_right, current_y + contact_height_px) arc_rect = QRectF( tip_left, current_y + contact_height_px - tip_radius, tip_width, tip_radius * 2, ) tip_path.arcTo(arc_rect, 0, -180) tip_path.lineTo(tip_left, current_y) tip_bounds = tip_path.boundingRect() contact_gradient_tip = QRadialGradient( tip_bounds.center().x(), tip_bounds.center().y(), tip_bounds.width() * 0.6, ) contact_gradient_tip.setColorAt(0, color.lighter(150)) contact_gradient_tip.setColorAt(0.5, color.lighter(110)) contact_gradient_tip.setColorAt(0.85, color.darker(110)) contact_gradient_tip.setColorAt(1, color.darker(130)) shadow_path = QPainterPath(tip_path) shadow_path.translate(0, 2) painter.setBrush(QColor(0, 0, 0, 20)) painter.setPen(Qt.PenStyle.NoPen) painter.drawPath(shadow_path) painter.setBrush(QBrush(contact_gradient_tip)) painter.setPen(QPen(border_color, border_width)) painter.drawPath(tip_path) if contact_state != ContactState.OFF: highlight_rect = QRectF( tip_left + tip_width * 0.15, current_y + 1, tip_width * 0.3, contact_height_px * 0.4, ) painter.setBrush(QColor(255, 255, 255, 50)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(highlight_rect, 2, 2) self.contact_rects[contact_id] = tip_bounds stroker = QPainterPathStroker() stroker.setWidth(max(10.0, scale * 0.9)) self.contact_hit_areas[contact_id] = stroker.createStroke( tip_path ).united(tip_path) else: # Standard rectangular ring contact shadow_rect = QRectF(rect) shadow_rect.translate(0, 2) painter.setBrush(QColor(0, 0, 0, 20)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(shadow_rect, 3, 3) painter.setBrush(QBrush(contact_gradient)) painter.setPen(QPen(border_color, border_width)) painter.drawRoundedRect(rect, 3, 3) if contact_state != ContactState.OFF: highlight_rect = QRectF( rect.left() + rect.width() * 0.15, rect.top() + 1, rect.width() * 0.3, rect.height() * 0.4, ) painter.setBrush(QColor(255, 255, 255, 50)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(highlight_rect, 2, 2) self.contact_rects[contact_id] = rect path = QPainterPath() path.addRoundedRect(rect, 3, 3) stroker = QPainterPathStroker() stroker.setWidth(max(10.0, scale * 0.9)) self.contact_hit_areas[contact_id] = stroker.createStroke( path ).united(path) # Contact number on the left - smaller font in export mode painter.setPen(palette.color(QPalette.ColorRole.Text)) elabel_size = ( max(7, int(scale * 0.35)) if self.export_mode else max(10, int(scale * 0.5)) ) font = QFont("Arial", elabel_size, QFont.Weight.Bold) painter.setFont(font) label_extension = base_extension if is_directional_contact else 0 extra_offset = label_extension + 15 if is_directional_contact else 0 # Adjust horizontal position for better alignment if is_directional_contact: label_x = ( center_x - lead_width / 2 - label_extension - 22 - extra_offset ) else: # For ring contacts, center the label relative to the contact rectangle contact_rect_x = center_x - lead_width / 2 + 2 contact_rect_width = lead_width - 4 label_x = contact_rect_x + contact_rect_width / 2 - 30 # Label format contact_label = f"E{contact_idx}" # Export mode uses a larger font; keep the label box wide enough that # "E1", "E2", … are not clipped on the left (fixed 35px was too narrow). label_width = max(35, elabel_size * 2 + 6) if is_directional_contact: label_right = label_x + 35 label_x = label_right - label_width else: label_right = label_x + 35 label_x = label_right - label_width painter.drawText( int(label_x), int(current_y), label_width, int(contact_height_px), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, contact_label, ) # Spacing between contacts - reasonable vertical space for clickability current_y += contact_height_px + (self.model.contact_spacing + 1.0) * scale # Draw ring caps on top of contacts (after all contacts are drawn) for contact_idx, ring_cap_rect in self.ring_rects.items(): # Cap color based on ring state ring_states = [ self.contact_states.get((contact_idx, seg), ContactState.OFF) for seg in range(3) ] if all(s == ContactState.ANODIC for s in ring_states): ring_state = ContactState.ANODIC elif all(s == ContactState.CATHODIC for s in ring_states): ring_state = ContactState.CATHODIC else: ring_state = ContactState.OFF is_ring_hovered = self.hovered_ring == contact_idx cap_color, cap_border, cap_width = self.get_state_color( ring_state, is_ring_hovered ) # 3D gradient for ring cap ring_gradient = QLinearGradient( ring_cap_rect.left(), ring_cap_rect.top(), ring_cap_rect.left(), ring_cap_rect.bottom(), ) ring_gradient.setColorAt(0, cap_color.lighter(150)) ring_gradient.setColorAt(0.3, cap_color.lighter(120)) ring_gradient.setColorAt(1, cap_color.darker(80)) # Draw shadow shadow_rect = QRectF(ring_cap_rect) shadow_rect.translate(0, 1) painter.setBrush(QColor(0, 0, 0, 25)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(shadow_rect, 3, 3) # Draw ring cap painter.setBrush(QBrush(ring_gradient)) painter.setPen(QPen(cap_border, cap_width)) painter.drawRoundedRect(ring_cap_rect, 3, 3) # Add highlight if ring_state != ContactState.OFF: highlight = QRectF( ring_cap_rect.left() + ring_cap_rect.width() * 0.2, ring_cap_rect.top() + 1, ring_cap_rect.width() * 0.4, ring_cap_rect.height() * 0.4, ) painter.setBrush(QColor(255, 255, 255, 50)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(highlight, 2, 2) # Ring label - smaller font in export mode painter.setPen( Qt.GlobalColor.white if ring_state != ContactState.OFF else Qt.GlobalColor.black ) ring_font_size = ( max(6, int(scale * 0.3)) if self.export_mode else max(7, int(scale * 0.3)) ) font = QFont("Arial", ring_font_size) painter.setFont(font) painter.drawText(ring_cap_rect, Qt.AlignmentFlag.AlignCenter, "Ring") @typing.override def resizeEvent(self, event): """Redraw when window is resized""" super().resizeEvent(event) self.update()