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:
2026-05-17 18:34:30 -04:00
parent 6ad76a89fa
commit fbce1ecb42
7 changed files with 1233 additions and 4 deletions
+298
View File
@@ -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)