Files
AR-VMS-Seaman/vmssailor/studio/editors/mimic_editor.py
T

308 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Editor visual de mímicos por sistema.
Sprint 3: canvas QGraphicsView con paleta lateral de símbolos arrastrables.
Cada mímico se asocia a un SystemId del proyecto. La serialización JSON se
guarda en `Project.notes` por simplicidad en Sprint 3 (más adelante se
agrega a `.vmsproj` con tabla propia).
"""
from __future__ import annotations
import json
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QGraphicsScene,
QGraphicsView,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from vmssailor.core.project import Project
from vmssailor.studio.editors.symbols import (
SYMBOL_KINDS,
SymbolSpec,
make_symbol,
)
from vmssailor.studio.theme import (
C_ABYSS,
C_CYAN,
C_FOG,
C_STEEL,
mono_font,
ui_font,
)
_SYMBOL_LABELS = {
"motor": "Motor (M)",
"pump": "Bomba",
"valve": "Válvula",
"tank": "Tanque",
"sensor": "Sensor (T/P/L)",
"indicator": "Indicador valor",
"line": "Línea proceso",
"label": "Etiqueta",
}
class MimicEditor(QWidget):
"""Editor de mímicos. Layout: paleta | canvas | propiedades."""
mimicChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._project: Project | None = None
# Almacén in-memory de mímicos por sistema:
# { system_id_value : [SymbolSpec, ...] }
self._mimics: dict[str, list[SymbolSpec]] = {}
self._empty_state_active: bool = True
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12)
outer.setSpacing(10)
# System selector + actions
top_row = QHBoxLayout()
top_row.addWidget(QLabel("Sistema:"))
self._system_combo = QComboBox()
self._system_combo.setMinimumWidth(220)
top_row.addWidget(self._system_combo)
self._btn_clear = QPushButton("Limpiar mímico")
self._btn_add_demo = QPushButton("Cargar demo")
top_row.addWidget(self._btn_clear)
top_row.addWidget(self._btn_add_demo)
top_row.addStretch(1)
self._symbol_count = QLabel("0 símbolos")
self._symbol_count.setFont(mono_font(10))
self._symbol_count.setStyleSheet(f"color: {C_CYAN};")
top_row.addWidget(self._symbol_count)
outer.addLayout(top_row)
# Splitter: palette | canvas
splitter = QSplitter(Qt.Horizontal)
outer.addWidget(splitter, 1)
# Palette
palette = QListWidget()
palette.setMinimumWidth(180)
palette.setMaximumWidth(220)
for kind in SYMBOL_KINDS:
item = QListWidgetItem(_SYMBOL_LABELS.get(kind, kind))
item.setData(Qt.ItemDataRole.UserRole, kind)
palette.addItem(item)
palette.itemDoubleClicked.connect(self._on_palette_double_click)
splitter.addWidget(palette)
self._palette = palette
# Canvas
self._scene = QGraphicsScene(self)
self._view = QGraphicsView(self._scene)
self._view.setFrameShape(QFrame.Shape.NoFrame)
self._view.setRenderHint(QPainter.RenderHint.Antialiasing)
self._view.setBackgroundBrush(QBrush(QColor(C_ABYSS)))
self._view.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
splitter.addWidget(self._view)
splitter.setSizes([200, 700])
# Signals
self._system_combo.currentIndexChanged.connect(self._on_system_changed)
self._btn_clear.clicked.connect(self._on_clear)
self._btn_add_demo.clicked.connect(self._on_add_demo)
self._draw_empty_state("Selecciona un proyecto y un sistema.")
# ----- Public API -------------------------------------------------
def set_project(self, project: Project | None) -> None:
self._project = project
# Reload mimics from project.notes if previously serialized
self._mimics = self._deserialize_mimics(project)
self._refresh_system_combo()
self._render_current()
def serialize_to_project(self) -> dict[str, Any]:
"""Devuelve la representación JSON-friendly para guardar en .vmsproj."""
return {
sys: [
{
"kind": s.kind,
"x": s.x,
"y": s.y,
"width": s.width,
"height": s.height,
"label": s.label,
"tag_ref": s.tag_ref,
"rotation_deg": s.rotation_deg,
"extra": s.extra,
}
for s in items
]
for sys, items in self._mimics.items()
}
# ----- Internals --------------------------------------------------
def _deserialize_mimics(self, project: Project | None) -> dict[str, list[SymbolSpec]]:
if project is None or not project.notes:
return {}
try:
# Convención Sprint 3: notes puede contener bloque JSON con prefijo
notes = project.notes
idx = notes.find("__MIMICS_JSON__:")
if idx < 0:
return {}
blob = notes[idx + len("__MIMICS_JSON__:"):].strip()
data = json.loads(blob)
out: dict[str, list[SymbolSpec]] = {}
for sys, items in data.items():
out[sys] = [SymbolSpec(**i) for i in items]
return out
except Exception:
return {}
def _refresh_system_combo(self) -> None:
self._system_combo.blockSignals(True)
self._system_combo.clear()
if self._project is None:
self._system_combo.addItem("(sin proyecto)", None)
else:
for sid in self._project.systems_enabled:
self._system_combo.addItem(sid.value, sid.value)
if self._system_combo.count() == 0:
self._system_combo.addItem("(sin sistemas habilitados)", None)
self._system_combo.blockSignals(False)
def _current_system(self) -> str | None:
return self._system_combo.currentData()
def _on_system_changed(self) -> None:
self._render_current()
def _on_clear(self) -> None:
sys = self._current_system()
if sys is None:
return
self._mimics[sys] = []
self._render_current()
self.mimicChanged.emit()
def _on_add_demo(self) -> None:
sys = self._current_system()
if sys is None:
return
# Demo simple: 1 tanque + 1 bomba + 1 motor + 1 línea + 1 indicator
demo = [
SymbolSpec(kind="tank", x=40, y=80, width=80, height=140, label="TANK_1"),
SymbolSpec(kind="line", x=120, y=140, width=80, height=20),
SymbolSpec(kind="valve", x=200, y=120, width=40, height=40, label="V1"),
SymbolSpec(kind="line", x=240, y=140, width=80, height=20),
SymbolSpec(kind="pump", x=320, y=120, width=60, height=60, label="PUMP_1"),
SymbolSpec(kind="line", x=380, y=140, width=120, height=20),
SymbolSpec(kind="motor", x=520, y=110, width=80, height=80, label="ME_PORT"),
SymbolSpec(kind="sensor", x=620, y=80, width=40, height=40, label="T", extra={"letter": "T"}),
SymbolSpec(
kind="indicator",
x=620,
y=140,
width=80,
height=30,
label="rpm",
extra={"value": "1520"},
),
]
self._mimics[sys] = demo
self._render_current()
self.mimicChanged.emit()
def _on_palette_double_click(self, item: QListWidgetItem) -> None:
"""Agrega un símbolo nuevo SIN reconstruir la escena.
Si ya hay símbolos arrastrados por el usuario, conservan posición.
El nuevo se coloca en el siguiente slot libre de una grilla 6 × N.
"""
sys = self._current_system()
if sys is None:
return
kind = item.data(Qt.ItemDataRole.UserRole)
existing = self._mimics.setdefault(sys, [])
x, y = self._next_free_position(existing)
new_spec = SymbolSpec(kind=kind, x=x, y=y, label="")
existing.append(new_spec)
if self._empty_state_active:
# En empty state hay un label central; render completo para limpiarlo
self._render_current()
else:
# Solo agrega el nuevo item — preserva posiciones arrastradas
new_item = make_symbol(new_spec)
self._scene.addItem(new_item)
new_item.setSelected(True)
self._symbol_count.setText(f"{len(existing)} símbolos")
self.mimicChanged.emit()
def _next_free_position(self, specs: list[SymbolSpec]) -> tuple[float, float]:
"""Devuelve (x, y) en una grilla 6 × N evitando solapamientos."""
col_w, row_h = 110, 90
cols = 6
used = set()
for s in specs:
col = int(round((s.x - 40) / col_w))
row = int(round((s.y - 40) / row_h))
used.add((col, row))
for row in range(30):
for col in range(cols):
if (col, row) not in used:
return 40.0 + col * col_w, 40.0 + row * row_h
return 40.0, 40.0
def _render_current(self) -> None:
sys = self._current_system()
self._scene.clear()
if sys is None:
self._draw_empty_state("Sin sistema seleccionado.")
return
specs = self._mimics.get(sys, [])
if not specs:
self._draw_empty_state(
f"Mímico vacío para sistema '{sys}'.\n"
"Doble-click en un símbolo de la paleta para agregar, o pulsa Cargar demo."
)
self._symbol_count.setText("0 símbolos")
return
self._empty_state_active = False
self._draw_grid()
for spec in specs:
self._scene.addItem(make_symbol(spec))
self._symbol_count.setText(f"{len(specs)} símbolos")
def _draw_empty_state(self, msg: str) -> None:
self._empty_state_active = True
self._draw_grid()
text = self._scene.addText(msg, ui_font(11))
text.setDefaultTextColor(QColor(C_FOG))
rect = text.boundingRect()
text.setPos(-rect.width() / 2 + 360, -rect.height() / 2 + 220)
def _draw_grid(self) -> None:
self._scene.setSceneRect(0, 0, 800, 480)
pen = QPen(QColor(C_STEEL))
pen.setCosmetic(True)
pen.setWidthF(0.5)
for x in range(0, 801, 40):
self._scene.addLine(x, 0, x, 480, pen)
for y in range(0, 481, 40):
self._scene.addLine(0, y, 800, y, pen)