"""Símbolos navales para el editor de mímicos. Cada símbolo es un QGraphicsItemGroup parametrizable que: - Se dibuja con el estilo Deep Ocean - Tiene `tag_ref: str | None` (id del Tag al que está vinculado) - Expone `kind: str` para serialización - Soporta `setState(value)` que actualiza color/animación Sprint 3: motor, bomba, válvula, tanque, sensor genérico, indicador, línea. """ from __future__ import annotations from dataclasses import dataclass, field from typing import ClassVar from PySide6.QtCore import Qt from PySide6.QtGui import ( QBrush, QColor, QFont, QLinearGradient, QPainterPath, QPen, ) from PySide6.QtWidgets import ( QGraphicsEllipseItem, QGraphicsItem, QGraphicsItemGroup, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsTextItem, ) from vmssailor.studio.theme import ( C_ABYSS, C_CYAN, C_FOAM, C_FOG, C_IRON, C_MIDNIGHT, C_OK, C_SAND, C_STEEL, ) SYMBOL_KINDS = ( "motor", "pump", "valve", "tank", "sensor", "indicator", "line", "label", ) @dataclass(slots=True) class SymbolSpec: """Spec serializable de un símbolo dentro de un mímico.""" kind: str x: float y: float width: float = 80.0 height: float = 60.0 label: str = "" tag_ref: str | None = None rotation_deg: float = 0.0 extra: dict = field(default_factory=dict) class _BaseSymbol(QGraphicsItemGroup): """Símbolo base. Subclases dibujan en `_build()`. Cuando el usuario arrastra el símbolo, `itemChange` propaga la nueva posición al `SymbolSpec` para que sobreviva a re-renders del editor. """ KIND: ClassVar[str] = "base" def __init__(self, spec: SymbolSpec) -> None: super().__init__() # NOTA: asignar self.spec ANTES de setPos para que itemChange tenga # acceso al spec cuando Qt dispare ItemPositionHasChanged durante init. self.spec = spec self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setPos(spec.x, spec.y) self.setRotation(spec.rotation_deg) self._build() if spec.label: self._add_label(spec.label) def itemChange(self, change, value): # type: ignore[override] """Propaga la posición arrastrada al SymbolSpec en vivo.""" if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: spec = getattr(self, "spec", None) if spec is not None: spec.x = float(self.pos().x()) spec.y = float(self.pos().y()) return super().itemChange(change, value) def _build(self) -> None: raise NotImplementedError def _add_label(self, text: str) -> None: item = QGraphicsTextItem(text) font = QFont("Inter", 8) font.setBold(False) item.setFont(font) item.setDefaultTextColor(QColor(C_SAND)) rect = item.boundingRect() item.setPos(-rect.width() / 2 + self.spec.width / 2, self.spec.height + 4) self.addToGroup(item) def set_state_color(self, color: QColor) -> None: """Cambia el color principal del símbolo (para reflejar estado del tag).""" # Subclases lo implementan si tienen un elemento principal recoloreable pass class MotorSymbol(_BaseSymbol): KIND = "motor" def _build(self) -> None: w, h = self.spec.width, self.spec.height # Cuerpo (rectángulo con esquinas redondeadas) body = QGraphicsRectItem(0, 0, w, h) body.setBrush(QBrush(QColor(C_MIDNIGHT))) body.setPen(QPen(QColor(C_CYAN), 2)) self.addToGroup(body) self._body = body # Letra M letter = QGraphicsTextItem("M") letter.setFont(QFont("Space Grotesk", 22, QFont.Weight.Bold)) letter.setDefaultTextColor(QColor(C_CYAN)) rect = letter.boundingRect() letter.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) self.addToGroup(letter) def set_state_color(self, color: QColor) -> None: self._body.setPen(QPen(color, 2)) class PumpSymbol(_BaseSymbol): KIND = "pump" def _build(self) -> None: w, h = self.spec.width, self.spec.height # Circulo (cuerpo de bomba) body = QGraphicsEllipseItem(0, 0, w, h) body.setBrush(QBrush(QColor(C_MIDNIGHT))) body.setPen(QPen(QColor(C_CYAN), 2)) self.addToGroup(body) self._body = body # Aspas en cruz cx, cy = w / 2, h / 2 line1 = QGraphicsLineItem(cx - w / 3, cy, cx + w / 3, cy) line2 = QGraphicsLineItem(cx, cy - h / 3, cx, cy + h / 3) for ln in (line1, line2): ln.setPen(QPen(QColor(C_CYAN), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) self.addToGroup(ln) center = QGraphicsEllipseItem(cx - 3, cy - 3, 6, 6) center.setBrush(QBrush(QColor(C_CYAN))) center.setPen(QPen(Qt.PenStyle.NoPen)) self.addToGroup(center) def set_state_color(self, color: QColor) -> None: self._body.setPen(QPen(color, 2)) class ValveSymbol(_BaseSymbol): KIND = "valve" def _build(self) -> None: w, h = self.spec.width, self.spec.height # Reloj de arena (dos triángulos) path = QPainterPath() path.moveTo(0, 0) path.lineTo(w, h) path.lineTo(w, 0) path.lineTo(0, h) path.closeSubpath() item = QGraphicsPathItem(path) item.setBrush(QBrush(QColor(C_OK))) item.setPen(QPen(QColor(C_ABYSS), 2)) self.addToGroup(item) self._body = item # Vástago stem = QGraphicsLineItem(w / 2, -h / 2, w / 2, 0) stem.setPen(QPen(QColor(C_FOG), 2)) self.addToGroup(stem) handle = QGraphicsEllipseItem(w / 2 - 5, -h / 2 - 5, 10, 10) handle.setBrush(QBrush(QColor(C_OK))) handle.setPen(QPen(QColor(C_ABYSS))) self.addToGroup(handle) def set_state_color(self, color: QColor) -> None: self._body.setBrush(QBrush(color)) class TankSymbol(_BaseSymbol): KIND = "tank" def _build(self) -> None: w, h = self.spec.width, self.spec.height body = QGraphicsRectItem(0, 0, w, h) body.setBrush(QBrush(QColor(C_ABYSS))) body.setPen(QPen(QColor(C_IRON), 2)) self.addToGroup(body) # Fill (default 50%) fill_h = h * 0.5 grad = QLinearGradient(0, h - fill_h, 0, h) grad.setColorAt(0.0, QColor("#00D9FF")) grad.setColorAt(1.0, QColor("#1B7FB5")) fill = QGraphicsRectItem(2, h - fill_h + 2, w - 4, fill_h - 2) fill.setBrush(QBrush(grad)) fill.setPen(QPen(Qt.PenStyle.NoPen)) self.addToGroup(fill) self._fill = fill self._h = h self._w = w def set_fill_pct(self, pct: float) -> None: pct = max(0.0, min(100.0, pct)) fill_h = self._h * (pct / 100.0) self._fill.setRect(2, self._h - fill_h + 2, self._w - 4, max(0, fill_h - 2)) class SensorSymbol(_BaseSymbol): KIND = "sensor" def _build(self) -> None: w, h = self.spec.width, self.spec.height body = QGraphicsEllipseItem(0, 0, w, h) body.setBrush(QBrush(QColor(C_MIDNIGHT))) body.setPen(QPen(QColor(C_OK), 2)) self.addToGroup(body) self._body = body letter = QGraphicsTextItem(self.spec.extra.get("letter", "T")) letter.setFont(QFont("JetBrains Mono", 14, QFont.Weight.Bold)) letter.setDefaultTextColor(QColor(C_OK)) rect = letter.boundingRect() letter.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) self.addToGroup(letter) def set_state_color(self, color: QColor) -> None: self._body.setPen(QPen(color, 2)) class IndicatorSymbol(_BaseSymbol): KIND = "indicator" def _build(self) -> None: w, h = self.spec.width, self.spec.height bg = QGraphicsRectItem(0, 0, w, h) bg.setBrush(QBrush(QColor(C_MIDNIGHT))) bg.setPen(QPen(QColor(C_STEEL), 1)) self.addToGroup(bg) value = QGraphicsTextItem(self.spec.extra.get("value", "--")) value.setFont(QFont("JetBrains Mono", 14, QFont.Weight.Bold)) value.setDefaultTextColor(QColor(C_FOAM)) rect = value.boundingRect() value.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) self.addToGroup(value) self._value = value def set_value_text(self, text: str) -> None: self._value.setPlainText(text) class LineSymbol(_BaseSymbol): KIND = "line" def _build(self) -> None: w, h = self.spec.width, self.spec.height # Línea horizontal por defecto line = QGraphicsLineItem(0, h / 2, w, h / 2) line.setPen(QPen(QColor(C_CYAN), 2.5, Qt.PenStyle.SolidLine)) self.addToGroup(line) class LabelSymbol(_BaseSymbol): KIND = "label" def _build(self) -> None: text = self.spec.extra.get("text", self.spec.label or "Label") item = QGraphicsTextItem(text) font = QFont("Inter", 10, QFont.Weight.DemiBold) item.setFont(font) item.setDefaultTextColor(QColor(C_FOAM)) self.addToGroup(item) SYMBOL_FACTORY = { "motor": MotorSymbol, "pump": PumpSymbol, "valve": ValveSymbol, "tank": TankSymbol, "sensor": SensorSymbol, "indicator": IndicatorSymbol, "line": LineSymbol, "label": LabelSymbol, } def make_symbol(spec: SymbolSpec) -> _BaseSymbol: cls = SYMBOL_FACTORY.get(spec.kind, _BaseSymbol) return cls(spec)