Files
AR-VMS-Seaman/vmssailor/studio/widgets/vessel_canvas.py
T
alro65 813476c8db sprint-1: Studio shell PySide6 + wizard 8 pasos
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>
2026-05-17 07:52:31 -04:00

346 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.
"""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)