""" Visores 2D del plano de líneas del casco — con edición interactiva. 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 visor muestra la malla de puntos de control de la OffsetsTable. El usuario puede arrastrar cualquier punto para modificar la geometría; al soltar se emite la señal ``offsets_edited(OffsetsTable)``. Soportan zoom con rueda del ratón y paneo con botón medio/derecho. Doble clic restablece el encuadre automático. 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, Signal from PySide6.QtGui import ( QBrush, 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") # Estaciones (muy tenue) _WATERLINE = QColor("#4da8ff") # Líneas de agua _WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa) _SECTION = QColor("#48a858") # Secciones de proa (verde) _SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul) _MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado) _DECK = QColor("#8868c8") # Línea de cubierta (púrpura) _KEEL = QColor("#e06060") # Quilla (rojo suave) _TEXT = QColor("#7a8ba8") _AXIS = QColor("#3e4255") # Puntos de control (malla editable) _CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado _CPT_HOVER = QColor("#ffd700") # oro _CPT_DRAG = QColor("#ff5555") # rojo activo _CPT_RADIUS = 4.0 # px en reposo _CPT_HIT = 14.0 # px umbral de captura # ───────────────────────────────────────────────────────────────────────────── # Clase base # ───────────────────────────────────────────────────────────────────────────── class _BaseViewer(QWidget): """Widget base con zoom/paneo y edición de puntos de control.""" # Emitido cuando el usuario arrastra un punto y suelta el botón offsets_edited = Signal(object) # OffsetsTable modificada 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._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho) # Estado de edición de puntos de control self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline) self._drag_idx: Optional[tuple[int, int]] = None self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) self.setMouseTracking(True) self.setCursor(Qt.CursorShape.ArrowCursor) # ─── API pública ────────────────────────────────────────────────────────── def set_hull(self, hull: Optional[Hull]) -> None: self._hull = hull self._hover_idx = None self._drag_idx = None self._fit_to_view() self.update() # ─── Transform mundo ↔ pantalla ────────────────────────────────────────── def _w2s(self, wx: float, wy: float) -> QPointF: 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: 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 self._scale = min( pw * (1 - margin * 2) / ww, ph * (1 - margin * 2) / wh, ) 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 # ─── Eventos ───────────────────────────────────────────────────────────── def resizeEvent(self, event) -> None: self._fit_to_view() super().resizeEvent(event) def wheelEvent(self, event: QWheelEvent) -> None: if self._drag_idx is not None: return delta = event.angleDelta().y() factor = 1.15 if delta > 0 else 1.0 / 1.15 pos = event.position() 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: btn = event.button() if btn == Qt.MouseButton.LeftButton and self._hull is not None: idx = self._hit_test(event.position()) if idx is not None: self._drag_idx = idx self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]]) self.setCursor(Qt.CursorShape.SizeAllCursor) event.accept() return if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._pan_start = event.position() def mouseMoveEvent(self, event) -> None: # ── Paneo ───────────────────────────────────────────────────────── if self._pan_start is not None: d = event.position() - self._pan_start self._offset += d self._pan_start = event.position() self.update() return # ── Arrastre de punto de control ────────────────────────────────── if self._drag_idx is not None and self._hull is not None: self._apply_drag(event.position(), self._drag_idx) self.update() return # ── Hover ───────────────────────────────────────────────────────── old = self._hover_idx if self._hull is not None: self._hover_idx = self._hit_test(event.position()) else: self._hover_idx = None cursor = (Qt.CursorShape.SizeAllCursor if self._hover_idx is not None else Qt.CursorShape.ArrowCursor) self.setCursor(cursor) if self._hover_idx != old: self.update() def mouseReleaseEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None: self._drag_idx = None self.setCursor(Qt.CursorShape.ArrowCursor) if self._hull is not None: self.offsets_edited.emit(self._hull.offsets) event.accept() return if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._pan_start = None def mouseDoubleClickEvent(self, event) -> None: self._fit_to_view() self.update() # ─── Métodos de edición (implementados por subclases) ──────────────────── def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: """Busca el punto de control más cercano dentro del umbral de captura.""" return None # subclases def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: """Actualiza la OffsetsTable con la nueva posición del ratón.""" pass # subclases # ─── Helpers de dibujo ─────────────────────────────────────────────────── def _draw_background(self, p: QPainter) -> None: p.fillRect(self.rect(), _BG) def _draw_label(self, p: QPainter, text: str) -> None: p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) 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)) p.setFont(QFont("Monospace", 10)) p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) def _draw_control_point( self, p: QPainter, screen_pt: QPointF, idx: tuple[int, int], ) -> None: """Dibuja un punto de control con color según estado.""" if idx == self._drag_idx: color = _CPT_DRAG r = _CPT_RADIUS * 1.8 elif idx == self._hover_idx: color = _CPT_HOVER r = _CPT_RADIUS * 1.5 else: color = _CPT_NORMAL r = _CPT_RADIUS p.setPen(QPen(color.darker(130), 1)) p.setBrush(QBrush(color)) p.drawEllipse(screen_pt, r, r) # ───────────────────────────────────────────────────────────────────────────── # 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 +). Mitad de proa → estribor (derecha, verde). Mitad de popa → babor (izquierda, azul). Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para cambiar la semi-manga en esa estación y línea de agua. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None ot = self._hull.offsets y_max = ot.max_half_breadth * 1.15 z_max = ot.draft * 1.20 return (-y_max, -z_max * 0.05, y_max, z_max) # ── Edición ─────────────────────────────────────────────────────────────── def _screen_pt(self, i: int, j: int) -> QPointF: """Punto de control (i, j) en coordenadas de pantalla.""" ot = self._hull.offsets y = ot.data[i, j] z = ot.z_waterlines[j] sign = 1.0 if i >= ot.n_stations // 2 else -1.0 return self._w2s(sign * y, z) def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: return None ot = self._hull.offsets best_d, best_idx = _CPT_HIT, None for i in range(ot.n_stations): for j in range(ot.n_waterlines): d = _dist(pos, self._screen_pt(i, j)) if d < best_d: best_d, best_idx = d, (i, j) return best_idx def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx sign = 1.0 if i >= ot.n_stations // 2 else -1.0 wx, _ = self._s2w(pos.x(), pos.y()) new_y = max(0.0, sign * wx) # Limitar al doble de la manga para evitar explosiones new_y = min(new_y, self._hull.beam) ot.data[i, j] = new_y # ── Dibujo ──────────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: 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 # ── Líneas de agua — grilla horizontal ──────────────────────── x_max = ot.max_half_breadth * 1.15 for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine)) else: p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine)) p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z)) # Línea de flotación de diseño (más visible) p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) # ── Secciones ───────────────────────────────────────────────── for i in range(n): is_fwd = i >= n // 2 is_mid = i == n // 2 if is_mid: pen = QPen(_MIDSHIP, 2.5) elif is_fwd: pen = QPen(_SECTION, 1.4) else: pen = QPen(_SECTION_AFT, 1.4) p.setPen(pen) y_arr = ot.data[i, :] z_arr = ot.z_waterlines sign = 1.0 if is_fwd else -1.0 path = QPainterPath() for k, (y, z) in enumerate(zip(y_arr, z_arr)): pt = self._w2s(sign * y, z) if k == 0: path.moveTo(pt) else: path.lineTo(pt) # Cerrar en quilla path.lineTo(self._w2s(0.0, 0.0)) p.drawPath(path) # ── Ejes ────────────────────────────────────────────────────── p.setPen(QPen(_AXIS, 1)) p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía # ── Puntos de control ───────────────────────────────────────── p.setRenderHint(QPainter.RenderHint.Antialiasing, True) for i in range(n): for j in range(ot.n_waterlines): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_label(p, "BODY PLAN") p.end() # ───────────────────────────────────────────────────────────────────────────── # 2. Profile Viewer — vista lateral (solo lectura) # ───────────────────────────────────────────────────────────────────────────── 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, perfil de cubierta y quilla. No es editable (las z son constantes en la OffsetsTable). """ 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.30, ) def paintEvent(self, event) -> None: 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.2)) # ── Líneas de agua en perfil ─────────────────────────────────── for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: p.setPen(QPen(_WL_DESIGN, 1.8)) else: frac = j / max(ot.n_waterlines - 1, 1) color = QColor(_WATERLINE) color.setAlphaF(0.40 + 0.50 * frac) p.setPen(QPen(color, 0.9)) p.drawLine(self._w2s(0, z), self._w2s(Lpp, z)) # ── Cubierta ────────────────────────────────────────────────── 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 / 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)) p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) _lbl = lambda text, x, z: p.drawText( QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14), Qt.AlignmentFlag.AlignCenter, text ) _lbl("AP", 0, -T * 0.12) _lbl("FP", Lpp, -T * 0.12) 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). Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar la semi-manga de esa estación en esa línea de agua. """ 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.25, ) # ── Edición ─────────────────────────────────────────────────────────────── def _screen_pt(self, i: int, j: int) -> QPointF: ot = self._hull.offsets return self._w2s(ot.x_stations[i], ot.data[i, j]) def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: return None ot = self._hull.offsets best_d, best_idx = _CPT_HIT, None for i in range(ot.n_stations): for j in range(ot.n_waterlines): d = _dist(pos, self._screen_pt(i, j)) if d < best_d: best_d, best_idx = d, (i, j) return best_idx def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx _, wy = self._s2w(pos.x(), pos.y()) new_y = max(0.0, min(wy, self._hull.beam)) ot.data[i, j] = new_y # ── Dibujo ──────────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: 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 n_wl = ot.n_waterlines # ── Líneas de agua como contornos ───────────────────────────── for j in range(n_wl): z = ot.z_waterlines[j] is_design = abs(z - T) < 1e-6 frac = j / max(n_wl - 1, 1) if is_design: color = QColor(_WL_DESIGN) color.setAlphaF(1.0) width = 2.0 else: color = QColor(_WATERLINE) color.setAlphaF(0.30 + 0.55 * frac) width = 0.9 p.setPen(QPen(color, width)) path = QPainterPath() x_arr = ot.x_stations y_arr = ot.data[:, j] for k, (x, y) in enumerate(zip(x_arr, y_arr)): pt = self._w2s(x, y) if k == 0: path.moveTo(pt) 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 ──────────────────────────────────────────────── 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.15)) # ── Puntos de control ───────────────────────────────────────── for i in range(ot.n_stations): for j in range(n_wl): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_label(p, "VISTA DE PLANTA") p.end() # ───────────────────────────────────────────────────────────────────────────── # Utilidad interna # ───────────────────────────────────────────────────────────────────────────── def _dist(a: QPointF, b: QPointF) -> float: return math.hypot(a.x() - b.x(), a.y() - b.y())