Files
AR-Shipdesign/arshipdesign/ui/widgets/viewer_lines.py
T
alro65 36584f782c Lines plan visual overhaul: correct colors, full plan view symmetry
Color palette:
- Control net (nodes + connecting lines): neutral grey (#A8B8D0 / 110,120,140)
  — matches DELFTship convention for control polygon handles
- Forward stations (proa): bright green #22CC58
- Aft stations (popa): amber #C8A010
- Midship: orange #FF7020
- Node size reduced 4.5→3.0 px so hull curves dominate visually

Plan view (Vista de Planta):
- World bbox now symmetric: y ∈ [−B/2·1.22, +B/2·1.22] shows BOTH halves
- Waterlines drawn as closed contours: CL-AP → starboard curve → CL-FP
  → port curve (mirrored) → close at CL-AP
  Every waterline terminates at the centerline at bow and stern
- Control net grid: both directions (station-arm + waterline-arm) drawn
  on port AND starboard — same visual language as DELFTship control polygon
- Station reference lines span full beam (both sides)
- Centerline (eje de crujía) drawn as solid line dividing the two halves
- Edit nodes remain on starboard only; port updates symmetrically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:05:20 -04:00

1047 lines
43 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.
"""
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("#131722")
# ── Referencia / grilla (muy tenue, no compite con nada) ────────────────
_GRID_STA = QColor(38, 55, 88, 80) # líneas de estación
_GRID_WL = QColor(40, 60, 95, 70) # líneas de agua (referencia)
_AXIS = QColor("#3e4255")
# ── Malla de control (poliedro de control) — gris neutro, muy tenue ────
# Los nodos SON los vértices del polígono de control; la curva del casco
# pasa CERCA de ellos (interpolante aquí, aproximante en NURBS clásico).
# El gris neutro evita confusión con las curvas de casco (verde/ámbar/azul).
_CNET_TRAN = QColor(110, 120, 140, 90) # aristas (dirección estación)
_CNET_LONG = QColor(100, 112, 132, 75) # aristas (dirección LdA)
# ── Curvas del casco (sobre la malla) ──────────────────────────────────
_WATERLINE = QColor("#2878C8") # líneas de agua — azul
_WL_DESIGN = QColor("#00D0FF") # flotación de diseño — cian
_SECTION = QColor("#22CC58") # estaciones de proa — VERDE
_SECTION_AFT = QColor("#C8A010") # estaciones de popa — ÁMBAR
_MIDSHIP = QColor("#FF7020") # cuaderna maestra — naranja
_DECK = QColor("#7058b8") # cubierta
_KEEL = QColor("#c85858") # quilla
_TEXT = QColor("#7a8ba8")
# ── Nodos (vértices del polígono de control) — gris-azulado ────────────
# Gris claro = convenio DELFTship/Maxsurf para puntos de control.
# Pequeño (3 px) para no tapar las curvas del casco.
_NODE_NORMAL = QColor("#A8B8D0") # gris-azulado: reposo
_NODE_HOVER = QColor("#E0EAFF") # casi blanco: hover
_NODE_DRAG = QColor("#FF3838") # rojo: arrastrando
_NODE_R = 3.0 # px semi-lado (era 4.5)
_CPT_HIT = 16.0 # px umbral de captura (alias legacy)
_CPT_RADIUS = _NODE_R # alias legacy
# ─────────────────────────────────────────────────────────────────────────────
# Clase base
# ─────────────────────────────────────────────────────────────────────────────
class _BaseViewer(QWidget):
"""Widget base con zoom/paneo y edición de puntos de control."""
# Emitido mientras el usuario arrastra (en cada mouseMoveEvent con drag)
offsets_dragging = Signal(object) # OffsetsTable — actualización en vivo
# Emitido cuando el usuario suelta el botón (fin del drag)
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._show_curvature = False # toggle con tecla C
self.setMouseTracking(True)
self.setCursor(Qt.CursorShape.ArrowCursor)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# ─── API pública ──────────────────────────────────────────────────────────
def set_hull(self, hull: Optional[Hull]) -> None:
"""Carga el casco y resetea zoom/pan al autofit (para carga inicial)."""
self._hull = hull
self._hover_idx = None
self._drag_idx = None
self._fit_to_view()
self.update()
def update_offsets(self, hull: Optional[Hull]) -> None:
"""Actualiza datos SIN resetear zoom/pan — usar para ediciones live."""
self._hull = hull
self._hover_idx = None
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 keyPressEvent(self, event) -> None:
if event.key() == Qt.Key.Key_C:
self._show_curvature = not self._show_curvature
self.update()
else:
super().keyPressEvent(event)
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:
self.setFocus() # captura el foco de teclado al hacer clic
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()
self.offsets_dragging.emit(self._hull.offsets) # live cross-view
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_hint_overlay(self, p: QPainter) -> None:
"""Esquina inferior-derecha: atajo de teclado para curvatura."""
txt = "[C] Curvatura ON" if self._show_curvature else "[C] Curvatura"
col = QColor("#ffd700") if self._show_curvature else QColor("#3a4870")
p.setFont(QFont("Monospace", 7))
p.setPen(QPen(col))
r = self.rect().adjusted(0, 0, -4, -4)
p.drawText(r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, txt)
def _draw_control_point(
self,
p: QPainter,
screen_pt: QPointF,
idx: tuple[int, int],
) -> None:
"""Dibuja un nodo de control como cuadrado naranja sobre las curvas.
El naranja distingue inequívocamente los nodos de cualquier línea del
casco (azul/verde/dorado). La forma cuadrada evoca el vocabulario de
las herramientas CAD (Maxsurf, DelftShip).
"""
if idx == self._drag_idx:
color = _NODE_DRAG
r = _NODE_R * 1.8
elif idx == self._hover_idx:
color = _NODE_HOVER
r = _NODE_R * 1.4
else:
color = _NODE_NORMAL
r = _NODE_R
from PySide6.QtCore import QRectF
p.setPen(QPen(color.darker(180), 1))
p.setBrush(QBrush(color))
p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2))
# ─────────────────────────────────────────────────────────────────────────────
# Helpers: malla de control (control net)
# ─────────────────────────────────────────────────────────────────────────────
def _draw_cnet_bodyplan(p: QPainter, ot, w2s_fn) -> None:
"""Dibuja la malla de control en el Body Plan.
En el body plan TODAS las secciones se superponen en el mismo plano
y-z, por lo que las aristas longitudinales (mismo índice de LdA a
través de todas las estaciones) producen líneas diagonales en abanico
que carecen de sentido visual. Aquí solo se dibujan las aristas
TRANSVERSALES: la polilínea de control de cada sección, idéntica a la
curva del casco pero dibujada en color muted ANTES que la curva bold,
de forma que el ojo ve claramente «control net → curva encima».
"""
n_sta = ot.n_stations
n_wl = ot.n_waterlines
pen_t = QPen(_CNET_TRAN, 0.8, Qt.PenStyle.SolidLine)
p.setPen(pen_t)
p.setBrush(Qt.BrushStyle.NoBrush)
for i in range(n_sta):
sign = 1.0 if i >= n_sta // 2 else -1.0
path = QPainterPath()
for k in range(n_wl):
pt = w2s_fn(sign * ot.data[i, k], ot.z_waterlines[k])
if k == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
# Cerrar al eje de crujía en la quilla
path.lineTo(w2s_fn(0.0, 0.0))
p.drawPath(path)
def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None:
"""Dibuja el poliedro de control completo en la Vista de Planta.
Se dibujan DOS direcciones (igual que DELFTship):
• Dirección estación (aristas verticales en planta): misma estación,
distintas LdA → muestra cómo varía la manga con la altura.
• Dirección LdA (aristas horizontales en planta): misma LdA, distintas
estaciones → el polígono de control de la línea de agua.
Ambas direcciones se dibujan en BABOR y ESTRIBOR (simetría).
La Capa 3 superpone las curvas del casco en colores saturados encima,
lo que hace visualmente evidente la diferencia poliedro ↔ curva suave.
"""
n_sta = ot.n_stations
n_wl = ot.n_waterlines
pen = QPen(_CNET_TRAN, 0.7, Qt.PenStyle.SolidLine)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
for sign in (1.0, -1.0): # estribor (+) y babor ()
# ── Dirección estación: nodos de la misma estación a lo largo de LdA
for i in range(n_sta):
path = QPainterPath()
for j in range(n_wl):
pt = w2s_fn(ot.x_stations[i], sign * ot.data[i, j])
if j == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ── Dirección LdA: nodos de la misma LdA a lo largo de estaciones
for j in range(n_wl):
path = QPainterPath()
for i in range(n_sta):
pt = w2s_fn(ot.x_stations[i], sign * ot.data[i, j])
if i == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
def _compute_buttock_pts(
ot, y_b: float
) -> list[tuple[float, float]]:
"""Calcula los puntos (x_est, z) de una línea de pantoque (buttock) a semi-manga y_b.
Una línea de pantoque es la intersección del casco con el plano vertical
paralelo al plano de crujía a distancia y_b del eje (Y = cte).
Para cada estación i, se busca la altura z a la que la semi-manga del
casco iguala y_b, interpolando linealmente entre líneas de agua adyacentes.
El resultado se dibuja en la vista de perfil como una curva (x, z).
Parámetros
----------
ot : OffsetsTable
y_b : float
Semi-manga de la pantoque [m].
Retorna
-------
Lista de tuplas (x_station, z_interp) ordenadas de AP a FP.
"""
pts: list[tuple[float, float]] = []
for i in range(ot.n_stations):
hb = ot.data[i, :] # semi-mangas en cada LdA para estación i
zz = ot.z_waterlines
if y_b > float(hb.max()):
continue # la pantoque no alcanza esta estación
# Buscar primer cruce ascendente (quilla → cubierta)
for j in range(len(hb) - 1):
h0, h1 = float(hb[j]), float(hb[j + 1])
if h0 <= y_b <= h1:
dh = h1 - h0
t = (y_b - h0) / dh if abs(dh) > 1e-9 else 0.0
z_interp = float(zz[j]) + t * (float(zz[j + 1]) - float(zz[j]))
pts.append((float(ot.x_stations[i]), z_interp))
break
return pts
def _smooth_pts(pts_2d: np.ndarray, n: int = 60) -> np.ndarray:
"""Muestrea n puntos de una B-spline interpolada a través de pts_2d.
pts_2d : shape (m, 2)
Returns shape (n, 2). Si hay < 4 puntos o falla el spline,
devuelve los puntos originales sin modificar.
"""
from arshipdesign.geometry.nurbs_curve import BSplineCurve
m = len(pts_2d)
if m < 4:
return pts_2d
try:
k = min(3, m - 1)
curve = BSplineCurve(pts_2d, degree=k)
return curve.sample(n) # shape (n, 2)
except Exception:
return pts_2d
# ─────────────────────────────────────────────────────────────────────────────
# 1. Body Plan — secciones transversales
# ─────────────────────────────────────────────────────────────────────────────
class BodyPlanViewer(_BaseViewer):
"""Vista de cuadernas (body plan).
Espacio de mundo: x = semi-manga [m] (CL=0, estribor +, babor ),
y = altura sobre quilla [m] (Z, positivo arriba).
Convención: estaciones de proa (i ≥ n//2) en semiplano derecho (verde),
estaciones de popa (i < n//2) en semiplano izquierdo (azul).
La pantalla Qt tiene Y creciente hacia abajo. Para que la quilla quede
abajo y la cubierta arriba se invierte el eje Y en _w2s/_s2w/_fit_to_view.
"""
# ── Inversión del eje Y: quilla abajo, cubierta arriba ───────────────
def _w2s(self, wx: float, wy: float) -> QPointF:
"""Mundo → pantalla con Y invertido (Z=0 queda en el borde inferior)."""
return QPointF(
wx * self._scale + self._offset.x(),
-wy * self._scale + self._offset.y(), # negado
)
def _s2w(self, sx: float, sy: float) -> tuple[float, float]:
return (
(sx - self._offset.x()) / self._scale,
-(sy - self._offset.y()) / self._scale, # negado
)
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
# Con Y invertido: centro_mundo_y → centro_pantalla requiere + en vez de
cy = ph / 2 + (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy)
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
x_max = ot.max_half_breadth * 1.15
# ══ CAPA 1: Grilla de referencia (tenue, sin competir) ════════
# Líneas de agua horizontales — referencia de altura
for j, z in enumerate(ot.z_waterlines):
is_design = abs(z - T) < 1e-6
if is_design:
p.setPen(QPen(_WL_DESIGN.darker(200), 0.8, Qt.PenStyle.DashLine))
else:
p.setPen(QPen(_GRID_WL, 0.5, Qt.PenStyle.DotLine))
p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z))
# Ejes
p.setPen(QPen(_AXIS, 1.0))
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0))
p.setPen(QPen(_AXIS, 0.7, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.18))
# ══ CAPA 2: Malla de control (control net — thin, muted) ══════
_draw_cnet_bodyplan(p, ot, self._w2s)
# ══ CAPA 3: Curvas del casco (bold, saturated) ════════════════
for i in range(n):
is_fwd = i >= n // 2
is_mid = i == n // 2
if is_mid:
pen = QPen(_MIDSHIP, 2.2)
elif is_fwd:
pen = QPen(_SECTION, 1.5)
else:
pen = QPen(_SECTION_AFT, 1.5)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
y_arr = ot.data[i, :]
z_arr = ot.z_waterlines
sign = 1.0 if is_fwd else -1.0
# B-spline suave desde la quilla hasta la cubierta.
# NO se cierra la spline con (0,0) ya que eso distorsiona la curva;
# en su lugar se añade un segmento recto de cierre a la quilla.
raw = np.column_stack([y_arr * sign, z_arr])
smooth = _smooth_pts(raw, n=80)
path = QPainterPath()
for k_pt in range(len(smooth)):
pt = self._w2s(smooth[k_pt, 0], smooth[k_pt, 1])
if k_pt == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
# Cierre recto al punto de quilla en crujía (0, 0)
path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path)
# Flotación de diseño (encima de todo lo anterior)
p.setPen(QPen(_WL_DESIGN, 1.8, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
# ══ CAPA 4: Nodos (cuadrados naranjas — siempre encima) ═══════
for i in range(n):
for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
# ── Peine de curvatura (toggle C) ─────────────────────────────
if self._show_curvature:
for i in range(n):
sign = 1.0 if i >= n // 2 else -1.0
z_arr = ot.z_waterlines
y_arr = ot.data[i, :]
# En el body plan: curva en espacio (z, y) — normal en dirección y
_draw_curvature_comb(
p,
xs=z_arr, ys=y_arr * sign,
w2s_fn=lambda z, y: self._w2s(y, z),
scale=ot.draft * 0.25,
color_pos=QColor("#ff6b6b"),
color_neg=QColor("#6baaff"),
)
self._draw_hint_overlay(p)
self._draw_label(p, "BODY PLAN")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Profile Viewer — vista lateral (solo lectura)
# ─────────────────────────────────────────────────────────────────────────────
class ProfileViewer(_BaseViewer):
"""Vista lateral del casco (perfil / sheer plan).
Mundo: x = posición longitudinal [m] (AP izquierda, FP derecha),
y = altura sobre quilla [m] (Z, positivo arriba).
Muestra:
• Líneas de pantoque (buttocks): secciones verticales a Y = cte,
curvas longitudinales que revelan la forma del casco en perfil.
• Líneas de agua de referencia (horizontales).
• Línea de cubierta (sheer line).
• Línea de quilla.
• Marcas de estación (verticales).
Vista de sólo lectura (no editable directamente).
Igual que en BodyPlanViewer, se invierte el eje Y.
"""
# ── Inversión del eje Y: quilla abajo, cubierta arriba ───────────────
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]:
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_STA, 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))
# ── Líneas de pantoque (buttock lines) ────────────────────────
# Planos verticales a Y = B/4, B/2, 3B/4 del eje de crujía.
# Cada pantoque es una curva (x, z) que muestra el run del casco
# a una distancia lateral constante del plano de crujía.
y_max = ot.max_half_breadth
_N_BUTT = 3
for b_idx in range(1, _N_BUTT + 1):
y_b = y_max * b_idx / (_N_BUTT + 1)
pts = _compute_buttock_pts(ot, y_b)
if len(pts) < 2:
continue
arr = np.array(pts, dtype=float)
smooth = _smooth_pts(arr, n=80)
frac = b_idx / _N_BUTT
col = QColor(_WATERLINE)
col.setAlphaF(0.38 + 0.45 * frac)
p.setPen(QPen(col, 1.1))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
for k_pt in range(len(smooth)):
pt = self._w2s(float(smooth[k_pt, 0]), float(smooth[k_pt, 1]))
if k_pt == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ── 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
# Mostrar AMBOS semiplanos (estribor + babor) simétricamente
return (
-self._hull.lpp * 0.05,
-y_max * 1.22,
self._hull.lpp * 1.05,
y_max * 1.22,
)
# ── 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
y_max = ot.max_half_breadth
# ══ CAPA 1: Grilla de referencia ══════════════════════════════
# Eje de crujía — línea continua que divide babor y estribor
p.setPen(QPen(_AXIS, 1.2))
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
# Estaciones — líneas verticales en AMBOS semiplanos
p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine))
for x in ot.x_stations:
p.drawLine(self._w2s(x, -y_max * 1.10), self._w2s(x, y_max * 1.10))
# ══ CAPA 2: Poliedro de control (ambas mitades) ════════════════
_draw_cnet_planview(p, ot, self._w2s)
# ══ CAPA 3: Líneas de agua (ambos semiplanos) ══════════════════
# Cada línea de agua se dibuja como contorno cerrado:
# eje de crujía (AP) → semi-manga estribor → eje crujía (FP)
# → semi-manga babor → eje crujía (AP)
# Las curvas se cierran en el eje de crujía porque el casco es
# simétrico y la línea de agua termina en y=0 en AP y FP.
for j in range(n_wl):
z = ot.z_waterlines[j]
frac = j / max(n_wl - 1, 1)
is_design = abs(z - T) < 1e-6
if is_design:
color = QColor(_WL_DESIGN)
width = 2.2
else:
color = QColor(_WATERLINE)
color.setAlphaF(0.45 + 0.45 * frac)
width = 1.2
p.setPen(QPen(color, width))
p.setBrush(Qt.BrushStyle.NoBrush)
raw = np.column_stack([ot.x_stations, ot.data[:, j]])
smooth = _smooth_pts(raw, n=80)
n_smo = len(smooth)
# Coordenadas del eje de crujía en AP y FP (donde la LdA termina)
ap_x = float(smooth[0, 0])
fp_x = float(smooth[-1, 0])
# Semiplano estribor (y > 0) + cierre → semiplano babor (y < 0)
path = QPainterPath()
path.moveTo(self._w2s(ap_x, 0.0)) # inicio en CL-AP
for k in range(n_smo): # estribor: AP→FP
path.lineTo(self._w2s(float(smooth[k, 0]), float(smooth[k, 1])))
path.lineTo(self._w2s(fp_x, 0.0)) # cierre CL-FP
for k in range(n_smo - 1, -1, -1): # babor: FP→AP
path.lineTo(self._w2s(float(smooth[k, 0]), -float(smooth[k, 1])))
path.closeSubpath() # cierre CL-AP
p.drawPath(path)
# ══ CAPA 4: Nodos (estribor — lado editable) ══════════════════
# Solo se muestran los nodos del semiplano estribor (positivo).
# Babor es simétrico → editar un nodo actualiza ambos lados.
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))
# ── Peine de curvatura (toggle C) ─────────────────────────────
if self._show_curvature:
x_arr = ot.x_stations
for j in range(n_wl):
y_arr = ot.data[:, j]
_draw_curvature_comb(
p,
xs=x_arr, ys=y_arr,
w2s_fn=self._w2s,
scale=self._hull.beam * 0.18,
color_pos=QColor("#ff6b6b"),
color_neg=QColor("#6baaff"),
)
self._draw_hint_overlay(p)
self._draw_label(p, "VISTA DE PLANTA")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# Utilidades internas
# ─────────────────────────────────────────────────────────────────────────────
def _dist(a: QPointF, b: QPointF) -> float:
return math.hypot(a.x() - b.x(), a.y() - b.y())
def _curvature_comb_data(
xs: np.ndarray, ys: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Calcula curvatura discreta firmada y normales unitarias para una curva (xs, ys).
Retorna (kappas, nx, ny):
- kappas[i]: curvatura firmada en el punto i [1/unidad de longitud]
- (nx[i], ny[i]): normal unitaria (90° a la izquierda del tangente)
- Los extremos (i=0, i=n-1) tienen kappas=0.
"""
n = len(xs)
kappas = np.zeros(n)
nxs = np.zeros(n)
nys = np.zeros(n)
for i in range(1, n - 1):
dx1, dy1 = float(xs[i] - xs[i-1]), float(ys[i] - ys[i-1])
dx2, dy2 = float(xs[i+1] - xs[i]), float(ys[i+1] - ys[i])
l1 = math.hypot(dx1, dy1)
l2 = math.hypot(dx2, dy2)
if l1 < 1e-9 or l2 < 1e-9:
continue
# Tangente promediada normalizada
tx = dx1/l1 + dx2/l2
ty = dy1/l1 + dy2/l2
tl = math.hypot(tx, ty)
if tl < 1e-9:
continue
tx /= tl; ty /= tl
nxs[i] = -ty
nys[i] = tx
# Curvatura firmada (producto cruzado de tangentes unitarias)
cross = (dx1/l1) * (dy2/l2) - (dy1/l1) * (dx2/l2)
kappas[i] = 2.0 * cross / (l1 + l2 + 1e-12)
return kappas, nxs, nys
def _draw_curvature_comb(
p: QPainter,
xs: np.ndarray,
ys: np.ndarray,
w2s_fn,
scale: float,
color_pos: QColor,
color_neg: QColor,
) -> None:
"""
Dibuja el peine de curvatura sobre la curva discreta (xs, ys).
Cada 'diente' es una línea perpendicular a la curva con longitud k·scale.
Se dibuja también el spine conectando las puntas de los dientes.
Parámetros
----------
w2s_fn : callable(x, y) → QPointF
Función de conversión mundo→pantalla del visor.
scale : float
Factor de amplificación en unidades de mundo.
color_pos / color_neg : QColor
Colores para curvatura positiva / negativa.
"""
if len(xs) < 3:
return
kappas, nxs, nys = _curvature_comb_data(xs, ys)
tips_world: list[Optional[tuple[float, float]]] = []
for i in range(len(xs)):
k = kappas[i]
if abs(k) < 1e-9:
tips_world.append(None)
continue
ex = float(xs[i]) + nxs[i] * k * scale
ey = float(ys[i]) + nys[i] * k * scale
tips_world.append((ex, ey))
# Diente
col = color_pos if k > 0 else color_neg
p.setPen(QPen(col, 0.8))
p.drawLine(w2s_fn(float(xs[i]), float(ys[i])), w2s_fn(ex, ey))
# Spine (línea que une las puntas)
spine = QPainterPath()
started = False
for tip in tips_world:
if tip is None:
started = False
continue
pt = w2s_fn(tip[0], tip[1])
if not started:
spine.moveTo(pt)
started = True
else:
spine.lineTo(pt)
p.setPen(QPen(color_pos, 1.0))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawPath(spine)