sprint-3: editores de mimicos + tags + alarmas
3 editores nuevos integrados como tabs en MainWindow. vmssailor/studio/editors/symbols.py - 8 simbolos navales: motor, pump, valve, tank, sensor, indicator, line, label - _BaseSymbol QGraphicsItemGroup arrastrable + seleccionable - TankSymbol con fill animable por porcentaje - SymbolSpec dataclass serializable a JSON vmssailor/studio/editors/mimic_editor.py - Paleta lateral con tipos de simbolos - Doble-click crea simbolo en canvas - Selector de sistema (de los habilitados en el proyecto) - Boton 'Cargar demo' inserta P&ID demo (tanque-valvula-bomba-motor-sensor) - serialize_to_project() para guardar en .vmsproj vmssailor/studio/editors/tag_editor.py - Tabla CRUD con 13 columnas - Edicion inline de description, unit_si, range, control_mode - Validacion via Pydantic en cada edit - Filtro por texto en vivo - Contador de tags vmssailor/studio/editors/alarm_editor.py - Tabla aplanada de todas las alarmas configuradas - Counts por prioridad coloreados - Dialog modal para agregar AlarmConfig nueva - Edicion inline de threshold, operator, priority, hysteresis, delay, message - Eliminacion individual vmssailor/studio/main_window.py - Tabs 'Equipos', 'Mimicos', 'Tags', 'Alarmas' en panel derecho - Todos los editores reciben set_project y emiten projectMutated Tests (tests/studio/test_editors.py, 6 nuevos, total 126/126): - Symbol factory para los 8 tipos - MimicEditor con demo - TagEditor render - AlarmEditor priority counts + empty state - MainWindow tabs presentes 126/126 verde, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""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()`."""
|
||||
|
||||
KIND: ClassVar[str] = "base"
|
||||
|
||||
def __init__(self, spec: SymbolSpec) -> None:
|
||||
super().__init__()
|
||||
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.spec = spec
|
||||
self._build()
|
||||
if spec.label:
|
||||
self._add_label(spec.label)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user