813476c8db
Sprint 1 entrega el shell del Studio operativo. Para correrlo:
uv run vms-studio
Componentes:
vmssailor/studio/theme.py
- Aplica design tokens del Sprint 0 (paleta Deep Ocean) a PySide6
- QSS global completo + QPalette + fuentes Inter/Space Grotesk/JetBrains Mono
vmssailor/studio/app.py
- StudioApp (QApplication) con tema, logo, version
- run_studio() entry point
vmssailor/studio/main_window.py
- Layout: topbar / sidebar / canvas central / statusbar
- Menus: Proyecto (Nuevo wizard, Abrir, Guardar, Guardar como, Salir),
Edicion/Vista (stubs Sprint 2), Ayuda
- Operaciones funcionales: New from wizard, Open .vmsproj, Save, Save As,
Validate (cross-entity), Compile (placeholder)
- Reloj live + statusbar con stats del proyecto
vmssailor/studio/widgets/system_sidebar.py
- Sidebar dinamico que muestra wizard steps + sistemas habilitados + disponibles
- Lee catalogo maestro y proyecto activo
- Senial systemActivated(SystemId) al doble-click
vmssailor/studio/widgets/vessel_canvas.py
- QGraphicsView central con grilla naval (1m por celda)
- Renderiza silueta del buque en planta + mamparos + equipos
- ship_to_scene() transformacion canonica naval -> escena
- Centerline + Pp axis marcados
- Ruler de eslora con marcas cada 5m
- Zoom con wheel + scroll-pan, label de zoom% en header
vmssailor/studio/wizard/ - QWizard 8 pasos
- step_01_vessel_type: tipo + subtipo + nombre proyecto + cliente
- step_02_template: selector con biblioteca curada (Sunseeker, Ferretti, blank)
- step_03_dimensions: LOA/manga/calado/mamparos con pre-fill de plantilla
- step_04_systems: checkboxes agrupados por categoria con pre-select por default_for
- step_57_placeholder: stubs visuales para Sprint 2 (pasos 5, 6, 7)
- step_08_confirm: resumen HTML completo del proyecto a crear
- VesselWizard.build_project() construye un Project valido
Tests (tests/studio/, 11 nuevos, total 110/110):
- pytest-qt offscreen
- Smoke tests del MainWindow, wizard, canvas, sidebar
- test_ship_to_scene_mapping (transformacion naval->escena)
Stack agregado:
- PySide6 6.11.1
- pytest-qt 4.5.0
Decisiones autonomas:
- QFont.setWeight requiere QFont.Weight enum en PySide6 6.11 (no int)
- QFrame.Shape.NoFrame (no QListWidget.NoFrame) para PySide6 6.11
- Pasos 5-7 quedan placeholders explicitos: Sprint 2 implementa rule engine
- Wizard crea Project sin equipment todavia (Sprint 2 los agrega)
Criterios de aceptacion Sprint 1:
- uv run vms-studio: abre ventana operativa
- 110/110 pytest verde
- ruff clean
- Smoke offscreen: MainWindow + Wizard + Canvas + Sidebar OK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Python
346 lines
11 KiB
Python
"""VesselCanvas — QGraphicsView con grilla naval y silueta del buque.
|
||
|
||
Sistema de coordenadas:
|
||
- ShipCoord (x_pp, y_cl, z_bl) en metros
|
||
- Transformación a pixels: 1 m → 20 px (configurable vía zoom)
|
||
- Origen Pp (popa) en la izquierda, proa a la derecha
|
||
|
||
Sprint 1: muestra eslora con grilla, marcadores de mamparos y equipos.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||
from PySide6.QtGui import (
|
||
QBrush,
|
||
QColor,
|
||
QLinearGradient,
|
||
QPainter,
|
||
QPen,
|
||
QPolygonF,
|
||
)
|
||
from PySide6.QtWidgets import (
|
||
QFrame,
|
||
QGraphicsScene,
|
||
QGraphicsView,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from vmssailor.core.coords import ShipCoord
|
||
from vmssailor.core.project import Project
|
||
from vmssailor.studio.theme import (
|
||
C_ABYSS,
|
||
C_CYAN,
|
||
C_CYAN_DEEP,
|
||
C_FOAM,
|
||
C_FOG,
|
||
C_MIDNIGHT,
|
||
C_SAND,
|
||
C_STEEL,
|
||
C_WARN,
|
||
mono_font,
|
||
ui_font,
|
||
)
|
||
|
||
# Mapeo metros -> pixels por defecto
|
||
PX_PER_M_DEFAULT = 20.0
|
||
|
||
|
||
def ship_to_scene(coord: ShipCoord, px_per_m: float = PX_PER_M_DEFAULT) -> QPointF:
|
||
"""Transformación de coordenadas navales a coordenadas de la escena.
|
||
|
||
En la escena: X crece a la derecha (= x_pp positivo hacia proa),
|
||
Y crece HACIA ABAJO en Qt — por eso invertimos y_cl.
|
||
|
||
El centro vertical de la escena (y=0) corresponde a la línea de crujía.
|
||
"""
|
||
x = coord.x_pp * px_per_m
|
||
y = -coord.y_cl * px_per_m # estribor abajo, babor arriba en pantalla
|
||
return QPointF(x, y)
|
||
|
||
|
||
class VesselCanvas(QWidget):
|
||
"""Canvas central del Studio."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._project: Project | None = None
|
||
self._px_per_m = PX_PER_M_DEFAULT
|
||
|
||
# Hero header
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
header = QWidget()
|
||
header.setFixedHeight(56)
|
||
header.setStyleSheet(f"background: {C_MIDNIGHT}; border-bottom: 1px solid {C_STEEL};")
|
||
h_lay = QHBoxLayout(header)
|
||
h_lay.setContentsMargins(24, 0, 24, 0)
|
||
self._title = QLabel("Topología del buque")
|
||
self._title.setObjectName("title")
|
||
self._title.setFont(ui_font(13))
|
||
self._title.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;")
|
||
h_lay.addWidget(self._title)
|
||
h_lay.addStretch(1)
|
||
self._zoom_label = QLabel("Zoom: 100%")
|
||
self._zoom_label.setFont(mono_font(9))
|
||
self._zoom_label.setStyleSheet(f"color: {C_FOG};")
|
||
h_lay.addWidget(self._zoom_label)
|
||
layout.addWidget(header)
|
||
|
||
# Scene + view
|
||
self._scene = QGraphicsScene(self)
|
||
self._view = _VesselGraphicsView(self._scene, self)
|
||
self._view.setFrameShape(QFrame.NoFrame)
|
||
self._view.setRenderHint(QPainter.Antialiasing)
|
||
self._view.setRenderHint(QPainter.SmoothPixmapTransform)
|
||
self._view.setBackgroundBrush(self._background_brush())
|
||
self._view.setDragMode(QGraphicsView.ScrollHandDrag)
|
||
self._view.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||
self._view.zoomChanged.connect(self._on_zoom_changed)
|
||
layout.addWidget(self._view, 1)
|
||
|
||
self._render_empty_state()
|
||
|
||
# ----- Background ---------------------------------------------------
|
||
|
||
def _background_brush(self) -> QBrush:
|
||
grad = QLinearGradient(0, 0, 0, 1)
|
||
grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
|
||
grad.setColorAt(0.0, QColor(C_ABYSS))
|
||
grad.setColorAt(1.0, QColor(C_MIDNIGHT))
|
||
return QBrush(grad)
|
||
|
||
# ----- Public -------------------------------------------------------
|
||
|
||
def set_project(self, project: Project | None) -> None:
|
||
self._project = project
|
||
if project is None:
|
||
self._render_empty_state()
|
||
else:
|
||
self._render_project()
|
||
|
||
def fit_in_view(self) -> None:
|
||
if not self._scene.items():
|
||
return
|
||
self._view.fitInView(self._scene.itemsBoundingRect().adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||
|
||
# ----- Rendering ----------------------------------------------------
|
||
|
||
def _render_empty_state(self) -> None:
|
||
self._scene.clear()
|
||
self._scene.setSceneRect(QRectF(-200, -200, 1200, 600))
|
||
# Grid
|
||
self._draw_grid(self._scene.sceneRect(), grid_m=1.0)
|
||
# Center label
|
||
msg = self._scene.addText(
|
||
"Abre un proyecto existente (.vmsproj) o crea uno con\n"
|
||
"Proyecto › Nuevo desde wizard…",
|
||
ui_font(11),
|
||
)
|
||
msg.setDefaultTextColor(QColor(C_FOG))
|
||
rect = msg.boundingRect()
|
||
msg.setPos(-rect.width() / 2 + 400, -rect.height() / 2 + 100)
|
||
self._title.setText("Topología del buque · sin proyecto")
|
||
|
||
def _render_project(self) -> None:
|
||
assert self._project is not None
|
||
self._scene.clear()
|
||
v = self._project.vessel
|
||
|
||
# Compute scene rect
|
||
margin_m = 5.0
|
||
rect_m = QRectF(
|
||
-margin_m * self._px_per_m,
|
||
-(v.beam_max_m / 2 + margin_m) * self._px_per_m,
|
||
(v.length_overall_m + 2 * margin_m) * self._px_per_m,
|
||
(v.beam_max_m + 2 * margin_m) * self._px_per_m,
|
||
)
|
||
self._scene.setSceneRect(rect_m)
|
||
|
||
# Grid
|
||
self._draw_grid(rect_m, grid_m=1.0)
|
||
|
||
# Silhouette (plan view, simplified): a hull-shaped polygon
|
||
self._draw_vessel_plan(v)
|
||
|
||
# Bulkheads
|
||
for b in v.bulkheads:
|
||
x_px = b.x_pp * self._px_per_m
|
||
self._draw_bulkhead(x_px, v.beam_max_m, b.name)
|
||
|
||
# Equipment markers
|
||
for eq in self._project.equipment:
|
||
self._draw_equipment(eq)
|
||
|
||
# Axes
|
||
self._draw_axes(v.length_overall_m, v.beam_max_m)
|
||
|
||
self._title.setText(
|
||
f"Topología del buque · {v.name} · "
|
||
f"{v.length_overall_m:.1f}×{v.beam_max_m:.1f}×{v.draft_m:.1f} m"
|
||
)
|
||
|
||
# Fit
|
||
self.fit_in_view()
|
||
|
||
def _draw_grid(self, rect: QRectF, grid_m: float = 1.0) -> None:
|
||
step = grid_m * self._px_per_m
|
||
pen = QPen(QColor(C_STEEL))
|
||
pen.setWidthF(0.5)
|
||
pen.setCosmetic(True)
|
||
|
||
x = rect.left() - (rect.left() % step)
|
||
while x <= rect.right():
|
||
self._scene.addLine(x, rect.top(), x, rect.bottom(), pen)
|
||
x += step
|
||
y = rect.top() - (rect.top() % step)
|
||
while y <= rect.bottom():
|
||
self._scene.addLine(rect.left(), y, rect.right(), y, pen)
|
||
y += step
|
||
|
||
# Centerline (y_cl = 0) — más visible
|
||
cl_pen = QPen(QColor(C_CYAN_DEEP))
|
||
cl_pen.setWidthF(0.8)
|
||
cl_pen.setStyle(Qt.DashLine)
|
||
cl_pen.setCosmetic(True)
|
||
self._scene.addLine(rect.left(), 0, rect.right(), 0, cl_pen)
|
||
|
||
# Pp (x=0) — más visible
|
||
self._scene.addLine(0, rect.top(), 0, rect.bottom(), cl_pen)
|
||
|
||
def _draw_vessel_plan(self, vessel) -> None:
|
||
L = vessel.length_overall_m * self._px_per_m
|
||
B = vessel.beam_max_m * self._px_per_m
|
||
b2 = B / 2
|
||
|
||
poly = QPolygonF(
|
||
[
|
||
# Stern (popa) — square-ish
|
||
QPointF(0, -b2 * 0.85),
|
||
QPointF(0, b2 * 0.85),
|
||
# Mid-stern out to full beam
|
||
QPointF(L * 0.18, b2),
|
||
# Parallel mid-body
|
||
QPointF(L * 0.70, b2),
|
||
# Bow taper
|
||
QPointF(L * 0.92, b2 * 0.55),
|
||
QPointF(L, 0),
|
||
QPointF(L * 0.92, -b2 * 0.55),
|
||
QPointF(L * 0.70, -b2),
|
||
QPointF(L * 0.18, -b2),
|
||
]
|
||
)
|
||
|
||
grad = QLinearGradient(0, -b2, 0, b2)
|
||
grad.setColorAt(0.0, QColor(C_SAND))
|
||
grad.setColorAt(0.5, QColor("#94A3B8"))
|
||
grad.setColorAt(1.0, QColor(C_FOG))
|
||
brush = QBrush(grad)
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(2)
|
||
pen.setCosmetic(True)
|
||
self._scene.addPolygon(poly, pen, brush)
|
||
|
||
def _draw_bulkhead(self, x_px: float, beam_m: float, name: str) -> None:
|
||
b2 = beam_m * self._px_per_m / 2
|
||
pen = QPen(QColor(C_CYAN))
|
||
pen.setWidthF(1.5)
|
||
pen.setStyle(Qt.DashLine)
|
||
pen.setCosmetic(True)
|
||
self._scene.addLine(x_px, -b2, x_px, b2, pen)
|
||
text = self._scene.addText(name, ui_font(8))
|
||
text.setDefaultTextColor(QColor(C_CYAN))
|
||
text.setPos(x_px - text.boundingRect().width() / 2, b2 + 6)
|
||
|
||
def _draw_equipment(self, eq) -> None:
|
||
center = ship_to_scene(eq.location, self._px_per_m)
|
||
|
||
# color por sistema (simplificado: motor cyan, genset amber, otros sand)
|
||
sys_v = eq.system_id.value
|
||
if sys_v == "main_engine":
|
||
color = QColor(C_CYAN)
|
||
elif sys_v == "genset":
|
||
color = QColor(C_WARN)
|
||
else:
|
||
color = QColor(C_SAND)
|
||
|
||
# Halo (the returned QGraphicsItem stays owned by the scene)
|
||
self._scene.addEllipse(
|
||
center.x() - 14,
|
||
center.y() - 14,
|
||
28,
|
||
28,
|
||
QPen(color, 1.2, Qt.SolidLine),
|
||
QBrush(QColor(color.red(), color.green(), color.blue(), 40)),
|
||
)
|
||
# Dot
|
||
self._scene.addEllipse(
|
||
center.x() - 6,
|
||
center.y() - 6,
|
||
12,
|
||
12,
|
||
QPen(QColor(C_ABYSS), 1.5),
|
||
QBrush(color),
|
||
)
|
||
# Label
|
||
text = self._scene.addText(eq.tag_prefix, ui_font(8))
|
||
text.setDefaultTextColor(QColor(C_FOAM))
|
||
text.setPos(center.x() - text.boundingRect().width() / 2, center.y() + 12)
|
||
|
||
def _draw_axes(self, length_m: float, beam_m: float) -> None:
|
||
# Ruler X
|
||
pen = QPen(QColor(C_FOG))
|
||
pen.setCosmetic(True)
|
||
ruler_y = (beam_m / 2 + 1.5) * self._px_per_m
|
||
L = length_m * self._px_per_m
|
||
self._scene.addLine(0, ruler_y, L, ruler_y, pen)
|
||
for m in range(0, int(length_m) + 1, 5):
|
||
x = m * self._px_per_m
|
||
self._scene.addLine(x, ruler_y - 4, x, ruler_y + 4, pen)
|
||
t = self._scene.addText(f"{m} m", mono_font(7))
|
||
t.setDefaultTextColor(QColor(C_FOG))
|
||
t.setPos(x - t.boundingRect().width() / 2, ruler_y + 6)
|
||
label = self._scene.addText("x_pp →", mono_font(8))
|
||
label.setDefaultTextColor(QColor(C_CYAN))
|
||
label.setPos(L + 12, ruler_y - 8)
|
||
|
||
# Pp arrow / origin
|
||
origin_label = self._scene.addText("Pp", mono_font(9))
|
||
origin_label.setDefaultTextColor(QColor(C_CYAN))
|
||
origin_label.setPos(-20, ruler_y - 8)
|
||
|
||
# ----- Slots --------------------------------------------------------
|
||
|
||
def _on_zoom_changed(self, scale: float) -> None:
|
||
self._zoom_label.setText(f"Zoom: {scale * 100:.0f}%")
|
||
|
||
|
||
class _VesselGraphicsView(QGraphicsView):
|
||
"""QGraphicsView con zoom por wheel + scroll-pan."""
|
||
|
||
from PySide6.QtCore import Signal # local import to avoid double importing
|
||
|
||
zoomChanged = Signal(float)
|
||
|
||
def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None:
|
||
super().__init__(scene, parent)
|
||
self._scale = 1.0
|
||
self.setMouseTracking(True)
|
||
|
||
def wheelEvent(self, event) -> None: # type: ignore[override]
|
||
delta = event.angleDelta().y()
|
||
if delta == 0:
|
||
return
|
||
factor = 1.15 if delta > 0 else 1 / 1.15
|
||
new_scale = self._scale * factor
|
||
if not (0.1 <= new_scale <= 10.0):
|
||
return
|
||
self._scale = new_scale
|
||
self.scale(factor, factor)
|
||
self.zoomChanged.emit(self._scale)
|