308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""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)
|