bdfd5ac4ca
- viewer_lines.py: BodyPlanViewer, ProfileViewer, PlanViewer (QPainter, zoom/paneo, tema dark navy); conectados a los tres viewports 2D del layout 4-viewport (bodyplan / profile / plan). - hull.py: añadidos waterplane_coefficient (Cw), it_waterplane (IT), il_waterplane (IL), bm_transverse (BMT), bm_longitudinal (BML), km_transverse (KMT), tpc, mct1cm — todos verificados analíticamente contra el casco Wigley (IACS Rec.34 §4.3). - main_window.py: _load_hull_viewers() conecta los 4 visores y el panel hidrostáticos al crear un nuevo proyecto; _update_hydrostatics() puebla los 11 campos de la barra inferior en vivo. - test_module1_hydrostatics.py: 35 tests nuevos (IT analítico exacto, consistencia BMT=IT/V, KMT=KB+BMT, TPC=Awp·ρ/1e5, visores headless). Suite total: 86 tests — 86 passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
18 KiB
Python
435 lines
18 KiB
Python
"""
|
|
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()
|