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