""" Visores 2D del plano de líneas del casco. Tres widgets especializados basados en QPainter: • BodyPlanViewer — secciones transversales (body plan) • ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla) • PlanViewer — vista de planta (líneas de agua desde arriba) Cada uno acepta un objeto Hull y se actualiza al llamar set_hull(). Soportan zoom con rueda del ratón y paneo con botón central/derecho. Referencia: Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan. Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign IACS Rec.34 §4: verificado contra OffsetsTable analítica Wigley. """ from __future__ import annotations import math from typing import Optional import numpy as np from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import ( QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent, ) from PySide6.QtWidgets import QWidget from arshipdesign.core.hull import Hull # ───────────────────────────────────────────────────────────────────────────── # Paleta del tema # ───────────────────────────────────────────────────────────────────────────── _BG = QColor("#1a1d30") _GRID = QColor("#2a3060") _WATERLINE = QColor("#4da8ff") # azul cyan _SECTION = QColor("#48a858") # verde _PROFILE = QColor("#e8a020") # dorado _DECK = QColor("#8868c8") # púrpura _KEEL = QColor("#e06060") # rojo suave _TEXT = QColor("#7a8ba8") _AXIS = QColor("#3e4255") _WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa) # ───────────────────────────────────────────────────────────────────────────── # Base común # ───────────────────────────────────────────────────────────────────────────── class _BaseViewer(QWidget): """Widget base con zoom/paneo común.""" def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._hull: Optional[Hull] = None self._scale = 1.0 self._offset = QPointF(0.0, 0.0) self._drag_start: Optional[QPointF] = None self.setMouseTracking(True) # ------------------------------------------------------------------ def set_hull(self, hull: Optional[Hull]) -> None: self._hull = hull self._fit_to_view() self.update() # ------------------------------------------------------------------ # Transformación mundo → pantalla # ------------------------------------------------------------------ def _w2s(self, wx: float, wy: float) -> QPointF: """Coordenada mundo → coordenada de pantalla.""" return QPointF( wx * self._scale + self._offset.x(), wy * self._scale + self._offset.y(), ) def _s2w(self, sx: float, sy: float) -> tuple[float, float]: return ( (sx - self._offset.x()) / self._scale, (sy - self._offset.y()) / self._scale, ) def _fit_to_view(self) -> None: """Ajusta zoom y offset para encuadrar el casco.""" if self._hull is None: return bbox = self._world_bbox() if bbox is None: return wx0, wy0, wx1, wy1 = bbox ww, wh = wx1 - wx0, wy1 - wy0 if ww < 1e-6 or wh < 1e-6: return pw, ph = max(self.width(), 100), max(self.height(), 100) margin = 0.08 scale_x = pw * (1 - margin * 2) / ww scale_y = ph * (1 - margin * 2) / wh self._scale = min(scale_x, scale_y) # Centrar cx = pw / 2 - (wx0 + ww / 2) * self._scale cy = ph / 2 - (wy0 + wh / 2) * self._scale self._offset = QPointF(cx, cy) def _world_bbox(self) -> Optional[tuple[float, float, float, float]]: return None # subclases lo sobreescriben # ------------------------------------------------------------------ # Eventos # ------------------------------------------------------------------ def resizeEvent(self, event) -> None: # type: ignore[override] self._fit_to_view() super().resizeEvent(event) def wheelEvent(self, event: QWheelEvent) -> None: delta = event.angleDelta().y() factor = 1.15 if delta > 0 else 1.0 / 1.15 pos = event.position() # Zoom centrado en el cursor self._offset = QPointF( pos.x() + (self._offset.x() - pos.x()) * factor, pos.y() + (self._offset.y() - pos.y()) * factor, ) self._scale *= factor self.update() def mousePressEvent(self, event) -> None: # type: ignore[override] if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._drag_start = event.position() def mouseMoveEvent(self, event) -> None: # type: ignore[override] if self._drag_start is not None: d = event.position() - self._drag_start self._offset += d self._drag_start = event.position() self.update() def mouseReleaseEvent(self, event) -> None: # type: ignore[override] self._drag_start = None def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override] self._fit_to_view() self.update() # ------------------------------------------------------------------ # Helpers de dibujo # ------------------------------------------------------------------ def _draw_background(self, p: QPainter) -> None: p.fillRect(self.rect(), _BG) def _draw_axes(self, p: QPainter, x0w: float, x1w: float, y0w: float, y1w: float, x_label: str = "x [m]", y_label: str = "y [m]") -> None: """Ejes y grilla con etiquetas.""" p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine)) # Eje X p0 = self._w2s(x0w, 0.0) p1 = self._w2s(x1w, 0.0) p.drawLine(p0, p1) # Eje Y p0 = self._w2s(0.0, y0w) p1 = self._w2s(0.0, y1w) p.drawLine(p0, p1) def _draw_label(self, p: QPainter, text: str) -> None: p.setPen(QPen(_TEXT)) fnt = QFont("Monospace", 8) p.setFont(fnt) p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text) def _draw_no_hull(self, p: QPainter, msg: str) -> None: p.setPen(QPen(_TEXT)) fnt = QFont("Monospace", 10) p.setFont(fnt) p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) # ───────────────────────────────────────────────────────────────────────────── # 1. Body Plan — secciones transversales # ───────────────────────────────────────────────────────────────────────────── class BodyPlanViewer(_BaseViewer): """Vista de cuadernas (body plan). Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +). Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0). La quilla maestra se resalta. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None ot = self._hull.offsets y_max = ot.max_half_breadth * 1.1 z_max = ot.draft * 1.15 return (-y_max, -z_max * 0.05, y_max, z_max) def paintEvent(self, event) -> None: # type: ignore[override] p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "BODY PLAN\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft n = ot.n_stations # ── Grilla de líneas de agua ─────────────────────────────── wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine) p.setPen(wl_pen) for z in ot.z_waterlines: # Línea horizontal en z x_max = ot.max_half_breadth * 1.1 left = self._w2s(-x_max, z) right = self._w2s( x_max, z) p.drawLine(left, right) # Línea de flotación de diseño (más gruesa) p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine)) x_max = ot.max_half_breadth * 1.1 p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) # ── Dibujar secciones ────────────────────────────────────── for i in range(n): # Progreso de AP a FP: proa a estribor, popa a babor is_forward = i >= n // 2 if is_forward: pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor) else: pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor) # Cuaderna maestra más gruesa if i == n // 2: pen.setWidthF(2.5) pen.setColor(_PROFILE) p.setPen(pen) y_arr = ot.data[i, :] z_arr = ot.z_waterlines sign = 1.0 if is_forward else -1.0 # estribor o babor path = QPainterPath() started = False for y, z in zip(y_arr, z_arr): pt = self._w2s(sign * y, z) if not started: path.moveTo(pt) started = True else: path.lineTo(pt) p.drawPath(path) # ── Ejes ────────────────────────────────────────────────── p.setPen(QPen(_AXIS, 1)) x_max = ot.max_half_breadth * 1.1 p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico self._draw_label(p, "BODY PLAN") p.end() # ───────────────────────────────────────────────────────────────────────────── # 2. Profile Viewer — vista lateral # ───────────────────────────────────────────────────────────────────────────── class ProfileViewer(_BaseViewer): """Vista lateral del casco (perfil). Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m]. Muestra: líneas de agua proyectadas, perfil de cubierta, quilla. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None return ( -self._hull.lpp * 0.05, -self._hull.draft * 0.15, self._hull.lpp * 1.05, self._hull.draft * 1.25, ) def paintEvent(self, event) -> None: # type: ignore[override] p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "PERFIL LATERAL\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft Lpp = self._hull.lpp # ── Grilla de estaciones ─────────────────────────────────── p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine)) for x in ot.x_stations: p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15)) # ── Líneas de agua en perfil (ancho máximo a cada z) ──────── for j, z in enumerate(ot.z_waterlines): color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE width = 1.5 if abs(z - T) < 1e-6 else 0.8 p.setPen(QPen(color, width)) # En perfil, la línea de agua aparece como línea recta horizontal # con el "ancho" dado por las semi-mangas (no visible en perfil lateral) # Lo que sí se muestra: intersección de líneas de agua con la proa y la popa # Dibujamos la línea completa p.drawLine(self._w2s(0, z), self._w2s(Lpp, z)) # ── Cubierta (z = puntal) ────────────────────────────────── p.setPen(QPen(_DECK, 1.8)) path_deck = QPainterPath() for k, x in enumerate(ot.x_stations): pt = self._w2s(x, self._hull.depth) if k == 0: path_deck.moveTo(pt) else: path_deck.lineTo(pt) p.drawPath(path_deck) # ── Quilla ───────────────────────────────────────────────── p.setPen(QPen(_KEEL, 2.0)) p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0)) # ── Perpendiculares AP y FP ──────────────────────────────── p.setPen(QPen(_AXIS, 1.5)) p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05)) p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05)) # Etiquetas AP / FP p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) ap_pt = self._w2s(0, -T * 0.12) fp_pt = self._w2s(Lpp, -T * 0.12) p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14), Qt.AlignmentFlag.AlignCenter, "AP") p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14), Qt.AlignmentFlag.AlignCenter, "FP") self._draw_label(p, "PERFIL LATERAL") p.end() # ───────────────────────────────────────────────────────────────────────────── # 3. Plan Viewer — vista de planta # ───────────────────────────────────────────────────────────────────────────── class PlanViewer(_BaseViewer): """Vista de planta (semiplano superior). Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor). Muestra: líneas de agua superpuestas como contornos. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None y_max = self._hull.offsets.max_half_breadth return ( -self._hull.lpp * 0.05, -y_max * 0.15, self._hull.lpp * 1.05, y_max * 1.20, ) def paintEvent(self, event) -> None: # type: ignore[override] p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "VISTA DE PLANTA\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft # ── Líneas de agua como contornos ────────────────────────── n_wl = ot.n_waterlines for j in range(n_wl): z = ot.z_waterlines[j] is_design = abs(z - T) < 1e-6 color = _WL_DESIGN if is_design else _WATERLINE alpha = int(60 + 195 * j / max(n_wl - 1, 1)) c = QColor(color) c.setAlpha(alpha) width = 2.0 if is_design else 0.9 p.setPen(QPen(c, width)) path = QPainterPath() x_arr = ot.x_stations y_arr = ot.data[:, j] started = False for x, y in zip(x_arr, y_arr): pt = self._w2s(x, y) if not started: path.moveTo(pt) started = True else: path.lineTo(pt) p.drawPath(path) # ── Eje de crujía ────────────────────────────────────────── p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) p.drawLine( self._w2s(0, 0), self._w2s(self._hull.lpp, 0), ) # ── Estaciones (líneas verticales tenues) ────────────────── p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine)) y_max = ot.max_half_breadth for x in ot.x_stations: p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1)) self._draw_label(p, "VISTA DE PLANTA") p.end()