Files
AR-Shipdesign/arshipdesign/ui/widgets/viewer_lines.py
T
alro65 eac3a3c965 fix(viewer): remove diagonal control-net fan lines from body plan
_draw_cnet_bodyplan: eliminated the longitudinal edges (same WL index
across all stations) — in the body plan all sections are overlaid in
the same y-z plane so those connections produce confusing diagonal
spoke patterns radiating from bow/stern.  Now only transverse section
polylines are drawn as the muted control-net underlay.

_draw_cnet_planview: likewise removed the redundant longitudinal layer
(waterline contours) because layer-3 already draws them bold on top.
Only the transverse station-direction edges are kept, which are the
genuinely new information in plan view.

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

839 lines
35 KiB
Python

"""
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 (control net) — thin, muted ───────────────────────
# Capa intermedia entre grilla y curvas del casco.
# Conecta los nodos formando el poliedro de control.
_CNET_TRAN = QColor(50, 80, 130, 140) # aristas transversales (a lo largo de estación)
_CNET_LONG = QColor(35, 90, 80, 110) # aristas longitudinales (a lo largo de LdA)
# ── Curvas del casco (sobre la malla) ──────────────────────────────────
_WATERLINE = QColor("#2a82c0") # líneas de agua
_WL_DESIGN = QColor("#00ccff") # flotación de diseño
_SECTION = QColor("#3a9e52") # secciones de proa
_SECTION_AFT = QColor("#2a78c0") # secciones de popa
_MIDSHIP = QColor("#d89020") # cuaderna maestra
_DECK = QColor("#7058b8") # cubierta
_KEEL = QColor("#c85858") # quilla
_TEXT = QColor("#7a8ba8")
# ── Nodos (handles) — encima de todo, color único: NARANJA ─────────────
# El naranja no existe en ninguna curva del casco → cero ambigüedad.
_NODE_NORMAL = QColor("#FF8000") # naranja: estado de reposo
_NODE_HOVER = QColor("#FFD700") # oro: hover
_NODE_DRAG = QColor("#FF2020") # rojo vivo: arrastrando
_NODE_R = 4.5 # px semi-lado del cuadrado
_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 la malla de control en la Vista de Planta.
Solo aristas TRANSVERSALES: por cada estación i, una polilínea
vertical que conecta sus nodos a lo largo de todas las LdA
(x constante, y varía de 0 a manga máxima en esa estación).
Esto muestra claramente «este nodo pertenece a esta estación» y
distingue los nodos longitudinales (en la waterline) de los
transversales (en la estación).
Las aristas longitudinales (waterlines) se omiten aquí porque la
Capa 3 ya las dibuja como las propias curvas del casco, más bold.
"""
n_sta = ot.n_stations
n_wl = ot.n_waterlines
pen_t = QPen(_CNET_TRAN, 0.7, Qt.PenStyle.SolidLine)
p.setPen(pen_t)
p.setBrush(Qt.BrushStyle.NoBrush)
for i in range(n_sta):
path = QPainterPath()
for j in range(n_wl):
pt = w2s_fn(ot.x_stations[i], ot.data[i, j])
if j == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ─────────────────────────────────────────────────────────────────────────────
# 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
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
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)
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).
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_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))
# ── 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
y_max = ot.max_half_breadth
# ══ CAPA 1: Grilla de referencia ══════════════════════════════
# Estaciones — líneas verticales tenues
p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine))
for x in ot.x_stations:
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15))
# 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))
# ══ CAPA 2: Malla de control ══════════════════════════════════
_draw_cnet_planview(p, ot, self._w2s)
# ══ CAPA 3: Curvas del casco (waterlines como contornos) ══════
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.40 + 0.50 * frac)
width = 1.1
p.setPen(QPen(color, width))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
for i, (x, y) in enumerate(zip(ot.x_stations, ot.data[:, j])):
pt = self._w2s(x, y)
if i == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ══ CAPA 4: Nodos (cuadrados naranjas) ════════════════════════
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)