Files
AR-Shipdesign/arshipdesign/ui/widgets/viewer_lines.py
T
alro65 a4b8b03a59 fix(ui): 3D stale cache + node/waterline visual hierarchy
hull.py
  - add invalidate() — clears _surface NURBS cache on in-place
    offsets edit; fixes 3D viewer showing old geometry after drag

main_window.py
  - call hull.invalidate() before load_hull() in
    _on_offsets_edited_from_viewer so PyVista always rebuilds mesh
    from the updated offsets

viewer_lines.py
  - 4-layer drawing order: grid → control-net → hull-curves → nodes
  - nodes changed from 4px white-blue circles to 6px orange squares
    (_NODE_NORMAL #FF8000) — unambiguous visual language vs blue/green
    hull curves
  - _draw_cnet_bodyplan / _draw_cnet_planview helpers: thin muted
    control-net mesh (transverse + longitudinal edges) drawn between
    grid and bold hull curves, matching Maxsurf/DelftShip visual style
  - waterline reference lines made more muted (_GRID_WL dotted)
  - all old _GRID / _CPT_* references replaced with new palette

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

876 lines
36 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 (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.
Capa visual entre la grilla de referencia y las curvas del casco:
• Aristas transversales — polilínea de control de cada sección
(equal to the section control polyline, muted, drawn BEFORE the
actual hull-curve so the colored curve reads on top of it).
• Aristas longitudinales — segmentos horizontales a la altura de cada
línea de agua, conectando todos los nodos de esa LdA en ambas bandas.
Permiten ver cómo varía la manga de proa a popa en cada calado.
"""
n_sta = ot.n_stations
n_wl = ot.n_waterlines
# ── Aristas transversales (a lo largo de cada sección) ────────────
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)
# ── Aristas longitudinales (a lo largo de cada LdA) ───────────────
# Para cada LdA j: una polilínea a través de todas las estaciones, en
# cada banda por separado (proa=+y, popa=y). Se ve como un arco a
# la altura z[j], mostrando la variación de manga longitudinalmente.
pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine)
p.setPen(pen_l)
for j in range(n_wl):
z = ot.z_waterlines[j]
# Banda de proa (estribor, sign=+1)
path_fwd = QPainterPath()
path_aft = QPainterPath()
for i in range(n_sta):
sign = 1.0 if i >= n_sta // 2 else -1.0
pt = w2s_fn(sign * ot.data[i, j], z)
if i == 0:
path_aft.moveTo(pt)
elif i == n_sta // 2:
path_fwd.moveTo(pt)
if i < n_sta // 2:
path_aft.lineTo(pt)
else:
path_fwd.lineTo(pt)
p.drawPath(path_fwd)
p.drawPath(path_aft)
def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None:
"""Dibuja la malla de control en la Vista de Planta.
• Aristas longitudinales — waterlines (conectan todas las estaciones
en una LdA = las curvas de contorno, dibujadas muted ANTES de las
curvas reales).
• Aristas transversales — polilínea vertical por estación,
conectando los nodos de esa estación a lo largo de todas las LdA.
Muestra cómo cambia la manga con el calado para cada estación.
"""
n_sta = ot.n_stations
n_wl = ot.n_waterlines
# ── Aristas longitudinales (contornos de LdA) ─────────────────────
pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine)
p.setPen(pen_l)
p.setBrush(Qt.BrushStyle.NoBrush)
for j in range(n_wl):
path = QPainterPath()
for i in range(n_sta):
pt = w2s_fn(ot.x_stations[i], ot.data[i, j])
if i == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ── Aristas transversales (polilínea de sección en planta) ─────────
pen_t = QPen(_CNET_TRAN, 0.7, Qt.PenStyle.SolidLine)
p.setPen(pen_t)
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)