Files

2445 lines
107 KiB
Python
Raw Permalink 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, QPolygonF, QWheelEvent,
)
from PySide6.QtWidgets import (
QCheckBox, QFrame, QGridLayout, QHBoxLayout, QLabel,
QLineEdit, QVBoxLayout, 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(130, 145, 170, 160) # aristas (dirección estación)
_CNET_LONG = QColor(120, 135, 160, 145) # 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.
_BUTTOCK = QColor("#28B8A0") # teal: pantocazas (distinto de waterlines)
_NODE_NORMAL = QColor("#A8B8D0") # gris-azulado: reposo
_NODE_HOVER = QColor("#E0EAFF") # casi blanco: hover
_NODE_DRAG = QColor("#FF3838") # rojo: arrastrando
_NODE_SELECTED = QColor("#FFD700") # oro: nodo seleccionado (panel info)
_NODE_CORNER = QColor("#FF8C00") # naranja oscuro: esquina
_NODE_PEER = QColor("#00D8FF") # cian: nodo par en otra vista
_NODE_R = 3.0 # px semi-lado
_CPT_HIT = 16.0 # px umbral de captura (alias legacy)
_CPT_RADIUS = _NODE_R # alias legacy
# Sentinels para tipos de nodo especiales (j negativo → no es índice de LdA)
_KEEL_IDX = -1 # nodo de quilla (keel_z[i] per-estación)
_SHEER_IDX = -2 # nodo de cubierta (sheer_z[i] per-estación)
_STEM_IDX = -10 # punto de control de roda; i = índice en stem_ctrl
_TRANS_IDX = -20 # punto de control de espejo; i = índice en transom_ctrl
# Colores de los contornos especiales del perfil
_STEM_COLOR = QColor("#e03030") # rojo — roda
_TRANSOM_COLOR = QColor("#c8a000") # ámbar — contorno del espejo
# ─────────────────────────────────────────────────────────────────────────────
# Panel flotante de información de nodo
# ─────────────────────────────────────────────────────────────────────────────
class NodeInfoPanel(QFrame):
"""Panel flotante con coordenadas X/Y/Z editables y checkbox de esquina.
Los campos son QLineEdit: el usuario puede escribir un valor y pulsar
Enter para aplicarlo directamente al nodo seleccionado.
Se posiciona en la esquina superior-derecha del visor padre.
"""
corner_toggled = Signal(bool) # checkbox cambió
coord_edited = Signal(str, float) # ("x"/"y"/"z", nuevo_valor_mundo)
_EDIT_SS = (
"QLineEdit { background: rgba(10,18,32,200); border: 1px solid #2a3a5a;"
"border-radius: 3px; color: #d0e8ff; font-family: Consolas; font-size: 10px;"
"padding: 1px 4px; }"
"QLineEdit:focus { border: 1px solid #4a7aaa; }"
)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("NodeInfoPanel")
# Evitar que clics en etiquetas/fondo del panel propaguen al viewer
# y limpien _selected_idx accidentalmente.
self.setAttribute(Qt.WidgetAttribute.WA_NoMousePropagation, True)
self.setStyleSheet(
"NodeInfoPanel { background: rgba(15,22,38,230); border: 1px solid #3a4a6a;"
"border-radius: 6px; }"
"QLabel { color: #6080a0; font-family: Consolas; font-size: 9px; }"
"QCheckBox { color: #c8dff0; font-size: 10px; }"
+ self._EDIT_SS
)
self.setFixedWidth(188)
outer = QVBoxLayout(self)
outer.setContentsMargins(8, 6, 8, 6)
outer.setSpacing(4)
# Título
title = QLabel("Nodo seleccionado")
title.setStyleSheet("color:#5878a0; font-size:9px;")
outer.addWidget(title)
# Rejilla X / Y / Z — etiqueta + QLineEdit editable
grid = QGridLayout()
grid.setSpacing(3)
grid.setContentsMargins(0, 0, 0, 0)
self._edits: dict[str, QLineEdit] = {}
for row, axis in enumerate(("x", "y", "z")):
lbl = QLabel(axis.upper() + ":")
lbl.setFixedWidth(14)
edit = QLineEdit("—")
edit.setFixedHeight(18)
edit.setAlignment(Qt.AlignmentFlag.AlignRight)
edit.returnPressed.connect(lambda a=axis: self._on_return(a))
grid.addWidget(lbl, row, 0)
grid.addWidget(edit, row, 1)
self._edits[axis] = edit
outer.addLayout(grid)
# Hint — Intro para aplicar
hint = QLabel("↵ Enter para aplicar")
hint.setStyleSheet("color:#405060; font-size:8px;")
outer.addWidget(hint)
# Checkbox esquina
self._chk_corner = QCheckBox("Esquina (sharp)")
self._chk_corner.toggled.connect(self._on_toggle)
outer.addWidget(self._chk_corner)
self.hide()
# ── API pública ───────────────────────────────────────────────────────
def update_node(self, x: float, y: float, z: float, is_corner: bool) -> None:
"""Actualiza los valores mostrados y el estado del checkbox."""
for axis, val in (("x", x), ("y", y), ("z", z)):
edit = self._edits[axis]
# Solo sobreescribir si el campo no tiene foco (el usuario podría estar editando)
if not edit.hasFocus():
edit.setText(f"{val:+.4f}")
self._chk_corner.blockSignals(True)
self._chk_corner.setChecked(is_corner)
self._chk_corner.blockSignals(False)
self.adjustSize()
self._reposition()
self.show()
self.raise_()
def _reposition(self) -> None:
parent = self.parentWidget()
if parent is None:
return
self.move(parent.width() - self.width() - 8, 8)
# ── Handlers internos ────────────────────────────────────────────────
def _on_return(self, axis: str) -> None:
text = self._edits[axis].text().strip()
try:
value = float(text)
except ValueError:
return # texto inválido → ignorar
self.coord_edited.emit(axis, value)
def _on_toggle(self, checked: bool) -> None:
self.corner_toggled.emit(checked)
# ─────────────────────────────────────────────────────────────────────────────
# 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
# Emitido cuando cambia el nodo seleccionado (None = deselección)
node_selected = Signal(object) # Optional[tuple[int, int]]
# Emitido cuando el usuario cambia el zoom (wheel) — escala px/m
scale_changed = Signal(float)
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._selected_idx: Optional[tuple[int, int]] = None # nodo seleccionado (panel info)
self._peer_selected_idx: Optional[tuple[int, int]] = None # seleccionado en otra vista
# Curva de control seleccionada con Shift+clic (arista de la malla NURBS)
# ("wl", j) → línea de agua j | ("sta", i) → estación i
# ("keel", None) → quilla | ("sheer", None) → cubierta
self._selected_curve: Optional[tuple[str, Optional[int]]] = None
# Panel flotante de información de nodo (corner + coords editables)
self._info_panel = NodeInfoPanel(self)
self._info_panel.corner_toggled.connect(self._on_corner_toggled)
self._info_panel.coord_edited.connect(self._on_coord_edited)
self._show_curvature = False # toggle con tecla [C]
self._show_fairness = False # toggle con tecla [F] — coloreo de equidad
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_scale(self) -> float:
"""Escala px/m que haría caber el contenido en este widget."""
bbox = self._world_bbox()
if bbox is None:
return 1.0
wx0, wy0, wx1, wy1 = bbox
ww, wh = wx1 - wx0, wy1 - wy0
if ww < 1e-6 or wh < 1e-6:
return 1.0
pw, ph = max(self.width(), 100), max(self.height(), 100)
m = 0.08
return min(pw * (1 - m * 2) / ww, ph * (1 - m * 2) / wh)
def center_at_scale(self, scale: float) -> None:
"""Aplica la escala dada y centra el contenido en el widget."""
bbox = self._world_bbox()
if bbox is None:
return
wx0, wy0, wx1, wy1 = bbox
ww, wh = wx1 - wx0, wy1 - wy0
pw, ph = max(self.width(), 100), max(self.height(), 100)
self._scale = scale
self._offset = QPointF(
pw / 2 - (wx0 + ww / 2) * scale,
ph / 2 - (wy0 + wh / 2) * scale,
)
self.update()
def _fit_to_view(self) -> None:
if self._hull is None:
return
self.center_at_scale(self.fit_scale())
def keyPressEvent(self, event) -> None:
key = event.key()
if key == Qt.Key.Key_C:
self._show_curvature = not self._show_curvature
self.update()
elif key == Qt.Key.Key_F:
self._show_fairness = not self._show_fairness
self.update()
elif key == Qt.Key.Key_S:
if self._smooth_selected_node():
if self._hull is not None:
self._hull.invalidate()
self.offsets_edited.emit(
self._hull.offsets if self._hull is not None else None)
self.update()
else:
super().keyPressEvent(event)
def _smooth_selected_node(self) -> bool:
"""Aplica 1 paso Laplaciano local al nodo seleccionado.
Suaviza solo el nodo activo promediando con sus vecinos estación anterior
y posterior. Para nodos de quilla/cubierta suaviza la componente Z.
Retorna True si realizó el suavizado.
"""
if self._selected_idx is None or self._hull is None:
return False
i, j = self._selected_idx
ot = self._hull.offsets
# Nodo de datos interior
if j >= 0 and 0 < i < ot.n_stations - 1:
prev_y = float(ot.data[i - 1, j])
cur_y = float(ot.data[i, j])
next_y = float(ot.data[i + 1, j])
ot.data[i, j] = max(0.0, (prev_y + cur_y + next_y) / 3.0)
return True
# Nodo de quilla interior
if j == _KEEL_IDX and 0 < i < ot.n_stations - 1:
kz = ot.keel_z
kz[i] = (float(kz[i - 1]) + float(kz[i]) + float(kz[i + 1])) / 3.0
return True
# Nodo de cubierta interior
if j == _SHEER_IDX and 0 < i < ot.n_stations - 1:
if len(self._hull.sheer_z) != ot.n_stations:
self._hull.sheer_z = self._hull.get_sheer_z().copy()
sz = self._hull.sheer_z
sz[i] = (float(sz[i - 1]) + float(sz[i]) + float(sz[i + 1])) / 3.0
return True
return False
def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]:
"""Detecta la arista de la malla de control más cercana a pos (Shift+clic).
Retorna ("wl", j) para línea de agua j,
("sta", i) para estación i, o None si no hay nada cercano.
Las subclases sobreescriben para añadir aristas especiales (keel, sheer).
"""
if self._hull is None:
return None
ot = self._hull.offsets
n_sta, n_wl = ot.n_stations, ot.n_waterlines
THRESHOLD = _CPT_HIT * 2.0
best_d, result = THRESHOLD, None
for j in range(n_wl):
for i in range(n_sta - 1):
d = _dist_to_segment(pos, self._screen_pt(i, j), self._screen_pt(i + 1, j))
if d < best_d:
best_d, result = d, ("wl", j)
for i in range(n_sta):
for j in range(n_wl - 1):
d = _dist_to_segment(pos, self._screen_pt(i, j), self._screen_pt(i, j + 1))
if d < best_d:
best_d, result = d, ("sta", i)
return result
def _fairness_color(self, i: int, j: int) -> QColor:
"""Color del nodo (i, j) según su segunda derivada en dirección longitudinal.
Verde → suave (equidad alta), Amarillo → moderado, Rojo → quiebre brusco.
Solo aplica a nodos interiores de la tabla de offsets.
"""
if self._hull is None:
return _NODE_NORMAL
ot = self._hull.offsets
if not (0 < i < ot.n_stations - 1) or not (0 <= j < ot.n_waterlines):
return _NODE_NORMAL
xs = ot.x_stations
dx = float(xs[i + 1] - xs[i - 1]) * 0.5
if dx < 1e-9:
return _NODE_NORMAL
y = ot.data
d2 = abs(float(y[i + 1, j]) - 2.0 * float(y[i, j]) + float(y[i - 1, j]))
roughness = d2 / (dx * dx) # m⁻¹ (segunda derivada normalizada)
# Umbrales empíricos — 0.005 = muy suave, 0.15 = quiebre visible
t_lo, t_hi = 0.005, 0.15
if roughness <= t_lo:
return QColor("#22cc66") # verde
if roughness >= t_hi:
return QColor("#e03030") # rojo
t = (roughness - t_lo) / (t_hi - t_lo)
if t < 0.5:
t2 = t * 2.0
return QColor(int(34 + 221 * t2), int(204), int(int(102 * (1 - t2))))
t2 = (t - 0.5) * 2.0
return QColor(255, int(204 * (1 - t2) + 48 * t2), 0)
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.scale_changed.emit(self._scale)
self.update()
def mousePressEvent(self, event) -> None:
self.setFocus() # captura el foco de teclado al hacer clic
btn = event.button()
mods = event.modifiers()
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
# ── Shift+clic: selección de curva completa (estilo Delftship) ──
if mods & Qt.KeyboardModifier.ShiftModifier:
curve = self._hit_test_edge(event.position())
if curve != self._selected_curve:
self._selected_curve = curve
self.update()
event.accept()
return
# ── Clic normal: arrastre de nodo (limpia selección de curva) ───
self._selected_curve = None
idx = self._hit_test(event.position())
if idx is not None:
self._drag_idx = idx
# j < 0 → nodo especial (keel/sheer), no está en data[i,j]
if idx[1] >= 0:
self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]])
else:
self._drag_orig = 0.0
self.setCursor(Qt.CursorShape.SizeAllCursor)
event.accept()
return
else:
# Clic en espacio vacío → deseleccionar nodo actual
if self._selected_idx is not None:
self._selected_idx = None
self._info_panel.hide()
self.update()
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._selected_idx = self._drag_idx # seleccionar nodo al soltar
self._drag_idx = None
self.setCursor(Qt.CursorShape.ArrowCursor)
if self._hull is not None:
self.offsets_edited.emit(self._hull.offsets)
self._on_node_selected(self._selected_idx)
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 selección y panel de información ─────────────────────────
def _node_world_xyz(self, idx: tuple[int, int]) -> tuple[float, float, float]:
"""Devuelve (x, y, z) en coordenadas de buque del nodo (i, j)."""
if self._hull is None:
return 0.0, 0.0, 0.0
i, j = idx
ot = self._hull.offsets
if j == _KEEL_IDX:
x = float(ot.x_stations[i]) + float(self._hull.get_keel_x_offsets()[i])
y = 0.0
z = float(ot.keel_z[i])
elif j == _SHEER_IDX:
x = float(ot.x_stations[i]) + float(self._hull.get_sheer_x_offsets()[i])
y = float(ot.data[i, -1]) if ot.n_waterlines > 0 else 0.0
z = float(self._hull.get_sheer_z()[i])
elif j == _STEM_IDX:
c = self._hull.get_stem_ctrl()
x, z = float(c[i, 0]), float(c[i, 1])
y = 0.0
elif j == _TRANS_IDX:
c = self._hull.get_transom_ctrl()
x, z = float(c[i, 0]), float(c[i, 1])
y = 0.0
else:
x = float(ot.x_stations[i]) + float(ot.x_offsets[i, j])
y = float(ot.data[i, j])
z = float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j])
return x, y, z
def _on_node_selected(self, idx: Optional[tuple[int, int]]) -> None:
"""Muestra el panel de información para el nodo seleccionado."""
self.node_selected.emit(idx)
if idx is None or self._hull is None:
self._info_panel.hide()
return
x, y, z = self._node_world_xyz(idx)
is_c = (idx[1] not in (_STEM_IDX, _TRANS_IDX) and
self._hull.is_corner(idx[0], idx[1]))
self._info_panel.update_node(x, y, z, is_c)
def set_peer_selection(self, idx: Optional[tuple[int, int]]) -> None:
"""Resalta el nodo (i, j) seleccionado en otra vista con anillo cian."""
if idx != self._peer_selected_idx:
self._peer_selected_idx = idx
self.update()
def _on_corner_toggled(self, checked: bool) -> None:
"""Aplica el cambio de esquina al hull y redibuja."""
if self._selected_idx is None or self._hull is None:
return
i, j = self._selected_idx
if checked != self._hull.is_corner(i, j):
self._hull.toggle_corner(i, j)
self.offsets_edited.emit(self._hull.offsets)
self.update()
# Refrescar panel para confirmar estado real (gold diamond ahora visible)
self._on_node_selected(self._selected_idx)
def _on_coord_edited(self, axis: str, value: float) -> None:
"""Aplica el valor tecleado en el panel de info al nodo seleccionado.
Hace la transformación inversa de _node_world_xyz:
valor mundo → offset almacenado en hull.offsets o hull.keel/sheer arrays.
"""
if self._selected_idx is None or self._hull is None:
return
try:
self._apply_coord_edit(axis, value)
except Exception as exc: # noqa: BLE001
import traceback
traceback.print_exc()
return
def _apply_coord_edit(self, axis: str, value: float) -> None:
"""Implementación real de _on_coord_edited (separada para capturar excepciones)."""
i, j = self._selected_idx
ot = self._hull.offsets
if j == _KEEL_IDX:
if axis == "x":
kxo = self._hull.get_keel_x_offsets().copy()
kxo[i] = value - float(ot.x_stations[i])
self._hull.keel_x_offsets = kxo
elif axis == "z":
ot.keel_z[i] = value
# y siempre 0 en quilla — ignorar
elif j == _SHEER_IDX:
if axis == "x":
sxo = self._hull.get_sheer_x_offsets().copy()
sxo[i] = value - float(ot.x_stations[i])
self._hull.sheer_x_offsets = sxo
elif axis == "y":
if ot.n_waterlines > 0:
ot.data[i, -1] = max(0.0, value)
elif axis == "z":
# Inicializar sheer_z si estaba vacío (default antes del arrufo)
if len(self._hull.sheer_z) != ot.n_stations:
self._hull.sheer_z = self._hull.get_sheer_z().copy()
self._hull.sheer_z[i] = value
elif j == _STEM_IDX:
sc = self._hull.stem_ctrl
if sc.ndim == 2 and i < sc.shape[0]:
if axis == "x":
sc[i, 0] = value
elif axis == "z":
sc[i, 1] = value
elif j == _TRANS_IDX:
tc = self._hull.transom_ctrl
if tc.ndim == 2 and i < tc.shape[0]:
if axis == "x":
tc[i, 0] = value
elif axis == "z":
tc[i, 1] = value
else: # nodo de línea de agua regular
if axis == "x":
ot.x_offsets[i, j] = value - float(ot.x_stations[i])
elif axis == "y":
ot.data[i, j] = max(0.0, value)
elif axis == "z":
ot.z_offsets[i, j] = value - float(ot.z_waterlines[j])
self._hull.invalidate()
self.offsets_edited.emit(self._hull.offsets)
self._on_node_selected(self._selected_idx) # refresca panel con valor calculado real
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: atajos de teclado activos."""
p.setFont(QFont("Consolas", 8))
r = self.rect().adjusted(0, 0, -6, -6)
curve_label = ""
if self._selected_curve is not None:
ctype, cidx = self._selected_curve
curve_label = {
"keel": "curva: QUILLA",
"sheer": "curva: CUBIERTA",
}.get(ctype, f"curva: {'LdA' if ctype == 'wl' else 'STA'} {cidx}")
lines = [
("[Shift+clic] Seleccionar curva", bool(self._selected_curve)),
("[C] Curvatura", self._show_curvature),
("[F] Equidad", self._show_fairness),
("[S] Suavizar nodo", False),
]
y_off = 0
if curve_label:
p.setPen(QPen(QColor("#00FFB0")))
adj_r = r.adjusted(0, 0, 0, -y_off)
p.drawText(adj_r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, curve_label)
y_off += 11
for txt, active in reversed(lines):
label = f"{txt} ON" if active else txt
col = QColor("#ffd700") if active else QColor("#6878a8")
p.setPen(QPen(col))
adj_r = r.adjusted(0, 0, 0, -y_off)
p.drawText(adj_r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, label)
y_off += 11
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).
"""
is_corner = (self._hull is not None and
idx[1] not in (_STEM_IDX, _TRANS_IDX) and
self._hull.is_corner(idx[0], idx[1]))
if idx == self._drag_idx:
color = _NODE_DRAG
r = _NODE_R * 1.8
elif idx == self._selected_idx:
color = _NODE_SELECTED
r = _NODE_R * 1.6
elif idx == self._hover_idx:
color = _NODE_HOVER
r = _NODE_R * 1.4
elif is_corner:
color = _NODE_CORNER
r = _NODE_R * 1.2
elif self._show_fairness and idx[1] >= 0:
# Coloreo de equidad: verde=suave → rojo=quiebre
color = self._fairness_color(idx[0], idx[1])
r = _NODE_R * 1.1
else:
color = _NODE_NORMAL
r = _NODE_R
p.setPen(QPen(color.darker(180), 1))
p.setBrush(QBrush(color))
# Esquinas → rombo (45°) — también cuando están seleccionadas
# para que el usuario vea inmediatamente que la esquina fue marcada.
# Solo el modo drag fuerza cuadrado (para no confundir durante arrastre).
if is_corner and idx != self._drag_idx:
cx, cy = screen_pt.x(), screen_pt.y()
diamond = QPolygonF([
QPointF(cx, cy - r),
QPointF(cx + r, cy ),
QPointF(cx, cy + r),
QPointF(cx - r, cy ),
])
p.drawPolygon(diamond)
else:
p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2))
# Anillo cian: nodo correspondiente seleccionado en otra vista
if idx == self._peer_selected_idx:
rp = _NODE_R * 2.8
p.setPen(QPen(_NODE_PEER, 1.8))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawEllipse(screen_pt, rp, rp)
# ─────────────────────────────────────────────────────────────────────────────
# 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], float(ot.z_waterlines[k]) + float(ot.z_offsets[i, k]))
if k == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
# Sin cierre al eje: el polígono de control es abierto (quilla→cubierta).
# El cierre recto a (0,0) solo se dibuja en la curva del casco (Capa 3).
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):
x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j])
pt = w2s_fn(x_eff, 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):
x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j])
pt = w2s_fn(x_eff, 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,
keel_z: Optional[np.ndarray] = None,
sheer_z: Optional[np.ndarray] = None,
keel_x_off: Optional[np.ndarray] = None,
sheer_x_off: Optional[np.ndarray] = None,
) -> list[tuple[float, float]]:
"""Calcula los puntos (x_eff, z) de una línea de pantoque a semi-manga y_b.
La pantoque se extiende hasta la cubierta (sheer) en sus extremos, tal
como se representa en planos de líneas tradicionales (Rawson & Tupper §1).
Parámetros
----------
ot : OffsetsTable
y_b : float
Semi-manga de la pantoque [m].
keel_z : array (n_sta,) | None
Altura de quilla por estación. Si None se asume z=0 en todas.
sheer_z : array (n_sta,) | None
Altura de cubierta por estación. Si None se usa el último z_waterlines.
keel_x_off : array (n_sta,) | None
Desviación X del nodo de quilla por estación [m]. Si None → 0.
sheer_x_off : array (n_sta,) | None
Desviación X del nodo de cubierta por estación [m]. Si None → 0.
Necesario para que un transom invertido/raked desplace correctamente
el extremo de cada pantoque hacia la popa.
"""
pts: list[tuple[float, float]] = []
# (i, sz_i, z_interp, sx_i) para añadir extremos hasta cubierta al final
valid_info: list[tuple[int, float, float, float]] = []
z_wl_top = float(ot.z_waterlines[-1])
for i in range(ot.n_stations):
x_nom = float(ot.x_stations[i])
hb_base = ot.data[i, :]
zz_base = ot.z_waterlines + ot.z_offsets[i, :]
xx_base = ot.x_offsets[i, :] # desvío X per-nodo de línea de agua
kz_i = float(keel_z[i]) if keel_z is not None else 0.0
sz_i = float(sheer_z[i]) if sheer_z is not None else z_wl_top
kx_i = float(keel_x_off[i]) if keel_x_off is not None else 0.0
sx_i = float(sheer_x_off[i]) if sheer_x_off is not None else 0.0
# Prepend keel point: breadth = 0 a keel_z[i], x_off = keel_x_off[i]
if kz_i < float(zz_base[0]) - 1e-6:
hb = np.concatenate([[0.0], hb_base])
zz = np.concatenate([[kz_i], zz_base])
xx = np.concatenate([[kx_i], xx_base])
else:
hb = hb_base.copy()
zz = zz_base.copy()
xx = xx_base.copy()
# Append sheer point si sheer_z[i] supera el último waterline.
# Costado vertical → breadth = data[i, -1] hasta cubierta.
if sz_i > z_wl_top + 1e-6:
hb = np.append(hb, float(ot.data[i, -1]))
zz = np.append(zz, sz_i)
xx = np.append(xx, sx_i)
if y_b > float(hb.max()):
continue # pantoque demasiado ancha para esta estación
# Buscar primer cruce ascendente (quilla → sheer)
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]))
# X efectiva interpolada entre los dos nodos del intervalo
x_off_int = float(xx[j]) + t * (float(xx[j + 1]) - float(xx[j]))
x_eff = x_nom + x_off_int
pts.append((x_eff, z_interp))
valid_info.append((i, sz_i, z_interp, sx_i))
break
# ── Extender hasta cubierta en los extremos (AP y FP) ─────────────────
# En planos de líneas, las pantocazas terminan en la línea de cubierta.
# El punto terminal usa la X efectiva del nodo sheer (incluye sheer_x_off),
# lo que refleja la inclinación del transom invertido en la popa.
if valid_info:
# Extremo de proa (FP) — último válido
i_fwd, sz_fwd, z_fwd, sx_fwd = valid_info[-1]
if sz_fwd > z_fwd + 1e-4:
pts.append((float(ot.x_stations[i_fwd]) + sx_fwd, sz_fwd))
# Extremo de popa (AP) — primero válido
if len(valid_info) > 1:
i_aft, sz_aft, z_aft, sx_aft = valid_info[0]
if sz_aft > z_aft + 1e-4:
pts.insert(0, (float(ot.x_stations[i_aft]) + sx_aft, sz_aft))
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
def _smooth_pts_cp(ctrl_2d: np.ndarray, n: int = 60) -> np.ndarray:
"""B-spline APROXIMANTE desde puntos de control (comportamiento NURBS real).
A diferencia de ``_smooth_pts`` (interpolante), la curva es *atraída* por
los puntos de control pero NO pasa necesariamente por los interiores —
solo por los extremos (knot vector clamped).
Ventaja clave: mover un punto de control deforma SUAVEMENTE toda la curva
con influencia ponderada decreciente; no crea kinks locales.
ctrl_2d : shape (m, 2)
Returns : shape (n, 2)
"""
from scipy.interpolate import BSpline as _SciPyBSpline
m = len(ctrl_2d)
if m < 2:
return ctrl_2d.copy()
k = min(3, m - 1)
# Knot vector clamped → extremos interpolados exactamente, interior aproximado
n_int = max(0, m - k - 1)
interior = np.linspace(0.0, 1.0, n_int + 2)[1:-1] if n_int > 0 else np.array([])
t_knots = np.concatenate([np.zeros(k + 1), interior, np.ones(k + 1)])
try:
spl = _SciPyBSpline(t_knots, ctrl_2d, k)
t_eval = np.linspace(0.0, 1.0, max(2, n))
return spl(t_eval)
except Exception:
return ctrl_2d.copy()
def _smooth_curve_segs(
ctrl_2d: np.ndarray,
corner_mask: "list[bool]",
n: int = 60,
) -> np.ndarray:
"""B-spline aproximante con soporte de nodos esquina.
Los índices True en *corner_mask* generan ruptura de tangente:
la curva se parte en segmentos independientes con ángulo agudo.
ctrl_2d : shape (m, 2)
corner_mask: len m booleans — True = esquina en ese índice
Returns : shape (n, 2)
"""
m = len(ctrl_2d)
if not corner_mask or not any(corner_mask[1:m - 1]):
return _smooth_pts_cp(ctrl_2d, n)
corners = [k for k in range(1, m - 1) if corner_mask[k]]
split_pts = [0] + corners + [m - 1]
result: list = []
for si in range(len(split_pts) - 1):
s, e = split_pts[si], split_pts[si + 1]
seg = ctrl_2d[s: e + 1]
n_seg = max(2, round(n * (e - s) / max(1, m - 1)))
pts = _smooth_pts_cp(seg, n_seg)
if result:
result.extend(pts[1:].tolist())
else:
result.extend(pts.tolist())
return np.array(result, dtype=float)
# ─────────────────────────────────────────────────────────────────────────────
# 1. Body Plan — secciones transversales
# ─────────────────────────────────────────────────────────────────────────────
def _draw_dim_grid(
p: QPainter,
w2s_fn,
s2w_fn,
widget_w: int,
widget_h: int,
) -> None:
"""Grilla cartesiana de fondo con cotas en metros.
Dibuja líneas de referencia muy tenues con etiquetas de medidas reales.
Usa la escala actual del visor para elegir un intervalo 'bonito'.
No modifica el estado del painter más allá del pen/font.
"""
# Rango del mundo visible en las cuatro esquinas
wx0, wy0 = s2w_fn(0, widget_h)
wx1, wy1 = s2w_fn(widget_w, 0 )
xlo, xhi = min(wx0, wx1), max(wx0, wx1)
ylo, yhi = min(wy0, wy1), max(wy0, wy1)
def _nice(rng: float, tgt: int = 7) -> float:
if rng < 1e-9:
return 1.0
raw = rng / tgt
mag = 10.0 ** math.floor(math.log10(max(raw, 1e-12)))
n = raw / mag
if n < 1.5: return mag
if n < 3.5: return 2.0 * mag
if n < 7.5: return 5.0 * mag
return 10.0 * mag
sx = _nice(xhi - xlo)
sy = _nice(yhi - ylo)
gc = QColor(60, 78, 110, 80) # líneas de grilla — tenue
gtxt = QColor(200, 220, 255, 230) # etiquetas — blanco-azulado brillante
# Fuentes monoespaciadas disponibles en Windows; fallback a genérica
font = QFont("Consolas", 9)
font.setWeight(QFont.Weight.Medium)
p.setFont(font)
fm_h = 12 # altura aproximada de línea de texto en px (9pt ≈ 12px a 96dpi)
# ── Líneas verticales (X = constante) ────────────────────────────────
x = math.floor(xlo / sx) * sx
while x <= xhi + sx * 0.5:
sx_lo = w2s_fn(x, ylo)
sx_hi = w2s_fn(x, yhi)
p.setPen(QPen(gc, 0.5))
p.drawLine(sx_lo, sx_hi)
# Etiqueta anclada a borde inferior del widget, no al extremo de la línea
tx = sx_lo.x()
ty = widget_h - fm_h - 3 # siempre dentro del widget
label = f"{x:.0f}m"
lw = max(40, len(label) * 7)
# Fondo oscuro semiopaco para legibilidad sobre cualquier color
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(10, 14, 26, 190))
p.drawRoundedRect(QRectF(tx - lw / 2 - 2, ty - 1, lw + 4, fm_h + 2), 2, 2)
p.setPen(QPen(gtxt))
p.drawText(QRectF(tx - lw / 2, ty, lw, fm_h),
Qt.AlignmentFlag.AlignCenter, label)
x += sx
# ── Líneas horizontales (Z = constante) ──────────────────────────────
y = math.floor(ylo / sy) * sy
while y <= yhi + sy * 0.5:
sy_l = w2s_fn(xlo, y)
sy_r = w2s_fn(xhi, y)
p.setPen(QPen(gc, 0.5))
p.drawLine(sy_l, sy_r)
# Etiqueta anclada a borde izquierdo del widget
tx = 3
ty = sy_l.y() - fm_h // 2
ty = max(2, min(ty, widget_h - fm_h - 2))
label = f"{y:.1f}"
lw = max(32, len(label) * 7)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(10, 14, 26, 190))
p.drawRoundedRect(QRectF(tx - 1, ty - 1, lw + 4, fm_h + 2), 2, 2)
p.setPen(QPen(gtxt))
p.drawText(QRectF(tx, ty, lw, fm_h),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
label)
y += sy
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 = max(float(self._hull.get_sheer_z().max()), float(ot.z_waterlines[-1])) * 1.20
z_min = min(float(ot.keel_z.min()), 0.0) * 1.15 - z_max * 0.05
return (-y_max, z_min, y_max, z_max)
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
"""Punto de control (i, j) en coordenadas de pantalla.
j = _KEEL_IDX (-1): quilla per-estación en crujía.
j = _SHEER_IDX (-2): cubierta (breadth = último LdA, Z = sheer_z[i]).
j >= 0: LdA normal.
"""
ot = self._hull.offsets
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
if j == _KEEL_IDX:
return self._w2s(0.0, float(ot.keel_z[i]))
if j == _SHEER_IDX:
y = float(ot.data[i, -1])
return self._w2s(sign * y, float(self._hull.get_sheer_z()[i]))
y = ot.data[i, j]
z = float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j])
return self._w2s(sign * y, z)
def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]:
"""En Body Plan: Shift+clic sobre una sección selecciona la estación i."""
if self._hull is None:
return None
ot = self._hull.offsets
n_sta = ot.n_stations
THRESHOLD = _CPT_HIT * 2.0
best_d, result = THRESHOLD, None
sentinels_and_wl = (_KEEL_IDX,) + tuple(range(ot.n_waterlines)) + (_SHEER_IDX,)
for i in range(n_sta):
pts = [sentinels_and_wl[k:k+2] for k in range(len(sentinels_and_wl) - 1)]
for ja, jb in pts:
d = _dist_to_segment(pos, self._screen_pt(i, ja), self._screen_pt(i, jb))
if d < best_d:
best_d, result = d, ("sta", i)
return result
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 jj in (_KEEL_IDX, _SHEER_IDX) + tuple(range(ot.n_waterlines)):
d = _dist(pos, self._screen_pt(i, jj))
if d < best_d:
best_d, best_idx = d, (i, jj)
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, wz = self._s2w(pos.x(), pos.y())
if j == _KEEL_IDX:
kz = ot.keel_z
z_ceil = float(ot.z_waterlines[0]) - 1e-3
new_z = float(np.clip(wz, -self._hull.depth, z_ceil))
kz[i] = new_z
return
if j == _SHEER_IDX:
if len(self._hull.sheer_z) != ot.n_stations:
self._hull.sheer_z = self._hull.get_sheer_z().copy()
z_floor = float(ot.z_waterlines[-1]) + 1e-3
self._hull.sheer_z[i] = float(np.clip(wz, z_floor, self._hull.depth * 3.0))
return
# ── Semi-manga Y + altura Z — independientes por nodo via z_offsets ─────
# Y: semi-manga por-estación (no afecta a ninguna otra estación)
new_y = max(0.0, sign * wx)
new_y = min(new_y, self._hull.beam)
ot.data[i, j] = new_y
# Z: z_offsets[i, j] permite mover este nodo verticalmente sin alterar
# ningún otro nodo (ni la misma LdA j en otras estaciones).
z_ref = float(ot.z_waterlines[j])
keel_i = float(ot.keel_z[i])
sheer_i = float(self._hull.get_sheer_z()[i])
new_z = float(np.clip(wz, keel_i + 1e-3, sheer_i - 1e-3))
if j > 0:
z_prev = float(ot.z_waterlines[j - 1]) + float(ot.z_offsets[i, j - 1])
new_z = max(new_z, z_prev + 1e-3)
if j < ot.n_waterlines - 1:
z_next = float(ot.z_waterlines[j + 1]) + float(ot.z_offsets[i, j + 1])
new_z = min(new_z, z_next - 1e-3)
ot.z_offsets[i, j] = new_z - z_ref
# ── 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
z_top = max(float(self._hull.get_sheer_z().max()), float(ot.z_waterlines[-1]))
# ══ CAPA 1: Grilla de referencia (tenue, sin competir) ════════
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, z_top * 1.10))
# ══ CAPA 2: Malla de control (control net — thin, muted) ══════
_draw_cnet_bodyplan(p, ot, self._w2s)
# ══ CAPA 3: Curvas del casco desde la malla de control ═══════
# Se usan las secciones de la malla (x_stations[i]) para que los
# nodos de CAPA 4 queden exactamente sobre las curvas.
sheer_z_arr = self._hull.get_sheer_z()
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 + ot.z_offsets[i, :]
sign = 1.0 if is_fwd else -1.0
keel_z_i = float(ot.keel_z[i])
sheer_z_i = float(sheer_z_arr[i])
keel_pt = np.array([[0.0, keel_z_i]])
raw_wl = np.column_stack([y_arr * sign, z_arr])
n_wl = ot.n_waterlines
# Solo añadir sheer_pt si el sheer está POR ENCIMA del último waterplane.
# Cuando z_wl[-1] == depth == sheer_z el punto ya está en raw_wl[-1].
sheer_above_wl = sheer_z_i > float(z_arr[-1]) + 1e-3
if sheer_above_wl:
sheer_pt = np.array([[float(y_arr[-1]) * sign, sheer_z_i]])
raw = np.vstack([keel_pt, raw_wl, sheer_pt])
corner_mask = (
[self._hull.is_corner(i, _KEEL_IDX)]
+ [self._hull.is_corner(i, j) for j in range(n_wl)]
+ [self._hull.is_corner(i, _SHEER_IDX)]
)
else:
raw = np.vstack([keel_pt, raw_wl])
corner_mask = (
[self._hull.is_corner(i, _KEEL_IDX)]
+ [self._hull.is_corner(i, j) for j in range(n_wl)]
)
smooth = _smooth_curve_segs(raw, corner_mask, n=80)
# Clip al semiplano correcto — la semi-manga nunca cruza la crujía
if sign > 0:
smooth[:, 0] = np.clip(smooth[:, 0], 0.0, None)
else:
smooth[:, 0] = np.clip(smooth[:, 0], None, 0.0)
path = QPainterPath()
path.moveTo(self._w2s(float(smooth[0, 0]), float(smooth[0, 1])))
for k_pt in range(1, len(smooth)):
path.lineTo(self._w2s(float(smooth[k_pt, 0]), float(smooth[k_pt, 1])))
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 — siempre encima) ════════════════
for i in range(n):
self._draw_control_point(p, self._screen_pt(i, _KEEL_IDX), (i, _KEEL_IDX))
self._draw_control_point(p, self._screen_pt(i, _SHEER_IDX), (i, _SHEER_IDX))
for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
# ── Peine de curvatura estilo Delftship (toggle C) ───────────────
# Muestra pelos solo en la estación seleccionada; si no hay nodo
# seleccionado muestra todas las estaciones (modo exploración).
if self._show_curvature:
# _selected_curve ("sta", i) tiene prioridad sobre nodo seleccionado
if self._selected_curve is not None and self._selected_curve[0] == "sta":
sel_i = self._selected_curve[1]
elif self._selected_idx is not None:
sel_i = self._selected_idx[0]
else:
sel_i = None
for i in range(n):
if sel_i is not None and i != sel_i:
continue
sign = 1.0 if i >= n // 2 else -1.0
z_arr = ot.z_waterlines + ot.z_offsets[i, :]
y_arr = ot.data[i, :]
_draw_curvature_comb(
p,
xs=z_arr, ys=y_arr * sign,
w2s_fn=lambda z, y: self._w2s(y, z),
scale=ot.draft * 0.40,
color_pos=QColor("#b060e0"),
color_neg=QColor("#7030b0"),
)
# ── Curva seleccionada Shift+clic (highlight estilo Delftship) ──────
if self._selected_curve is not None:
ctype, cidx = self._selected_curve
if ctype == "sta" and cidx is not None:
p.setPen(QPen(QColor("#00FFB0"), 2.5))
seq = (_KEEL_IDX,) + tuple(range(ot.n_waterlines)) + (_SHEER_IDX,)
for k in range(len(seq) - 1):
p.drawLine(self._screen_pt(cidx, seq[k]),
self._screen_pt(cidx, seq[k + 1]))
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).
Edición interactiva:
• Arrastrar un nodo en X mueve la estación longitudinalmente
(AP/FP fijos; estaciones intermedias con orden preservado).
• Arrastrar un nodo en Z mueve la línea de agua verticalmente
(j=0 quilla fija en 0; vecinas con orden preservado).
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
ot = self._hull.offsets
top = max(float(self._hull.get_sheer_z().max()), self._hull.depth) * 1.18
bot = min(float(ot.keel_z.min()), 0.0) * 1.15 - self._hull.draft * 0.10
return (
-self._hull.lpp * 0.05,
bot,
self._hull.lpp * 1.05,
top,
)
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
"""Nodo (i, j) en pantalla.
j = _KEEL_IDX (-1) : quilla → (x_sta[i], keel_z[i])
j = _SHEER_IDX (-2) : cubierta → (x_sta[i], sheer_z[i])
j = _STEM_IDX (-10): roda → stem_ctrl[i]
j = _TRANS_IDX (-20): espejo → transom_ctrl[i]
j >= 0 : LdA normal → (x_sta[i], z_wl[j])
"""
ot = self._hull.offsets
if j == _STEM_IDX:
c = self._hull.get_stem_ctrl()
return self._w2s(float(c[i, 0]), float(c[i, 1]))
if j == _TRANS_IDX:
c = self._hull.get_transom_ctrl()
return self._w2s(float(c[i, 0]), float(c[i, 1]))
xi = float(ot.x_stations[i])
if j == _KEEL_IDX:
return self._w2s(xi + float(self._hull.get_keel_x_offsets()[i]), float(ot.keel_z[i]))
if j == _SHEER_IDX:
return self._w2s(xi + float(self._hull.get_sheer_x_offsets()[i]), float(self._hull.get_sheer_z()[i]))
x_eff = xi + float(ot.x_offsets[i, j])
return self._w2s(x_eff, float(ot.z_waterlines[j]) + float(ot.z_offsets[i, j]))
def _hit_test_edge(self, pos: QPointF) -> Optional[tuple[str, Optional[int]]]:
"""Añade aristas de quilla y cubierta al hit-test base."""
if self._hull is None:
return None
ot = self._hull.offsets
n_sta = ot.n_stations
THRESHOLD = _CPT_HIT * 2.0
best_d, result = THRESHOLD, None
# Aristas de quilla
for i in range(n_sta - 1):
d = _dist_to_segment(pos, self._screen_pt(i, _KEEL_IDX),
self._screen_pt(i + 1, _KEEL_IDX))
if d < best_d:
best_d, result = d, ("keel", None)
# Aristas de cubierta
for i in range(n_sta - 1):
d = _dist_to_segment(pos, self._screen_pt(i, _SHEER_IDX),
self._screen_pt(i + 1, _SHEER_IDX))
if d < best_d:
best_d, result = d, ("sheer", None)
# Aristas de LdA de la malla base (si hay alguna más cercana)
base = super()._hit_test_edge(pos)
return result if result is not None else base
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
# Roda — solo puntos INTERMEDIOS (extremos fijados a quilla/sheer)
n_stem = len(self._hull.get_stem_ctrl())
for k in range(1, n_stem - 1):
d = _dist(pos, self._screen_pt(k, _STEM_IDX))
if d < best_d:
best_d, best_idx = d, (k, _STEM_IDX)
# Espejo — solo puntos INTERMEDIOS
n_trans = len(self._hull.get_transom_ctrl())
for k in range(1, n_trans - 1):
d = _dist(pos, self._screen_pt(k, _TRANS_IDX))
if d < best_d:
best_d, best_idx = d, (k, _TRANS_IDX)
# Quilla, cubierta y LdA — TODOS los nodos editables en X+Z
# (Vista Perfil: ejes X longitudinal y Z vertical — regla de ejes)
for i in range(ot.n_stations):
for jj in (_KEEL_IDX, _SHEER_IDX) + tuple(range(ot.n_waterlines)):
d = _dist(pos, self._screen_pt(i, jj))
if d < best_d:
best_d, best_idx = d, (i, jj)
return best_idx
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
ot = self._hull.offsets
i, j = idx
wx, wz = self._s2w(pos.x(), pos.y())
# ── Roda — solo puntos intermedios (extremos fijados a quilla/sheer-FP) ──
if j == _STEM_IDX:
ctrl = self._hull.get_stem_ctrl()
if i == 0 or i == len(ctrl) - 1:
return # endpoints son controlled por keel/sheer
if self._hull.stem_ctrl.shape[0] < 3:
self._hull.stem_ctrl = ctrl.copy()
self._hull.stem_ctrl[i, 0] = float(wx)
self._hull.stem_ctrl[i, 1] = float(wz)
return
# ── Espejo — solo puntos intermedios ──────────────────────────────────
if j == _TRANS_IDX:
ctrl = self._hull.get_transom_ctrl()
if i == 0 or i == len(ctrl) - 1:
return
if self._hull.transom_ctrl.shape[0] < 3:
self._hull.transom_ctrl = ctrl.copy()
self._hull.transom_ctrl[i, 0] = float(wx)
self._hull.transom_ctrl[i, 1] = float(wz)
return
# ── X: per-node x_offsets — x_stations es INMUTABLE en drag ─────────
x_ref = float(ot.x_stations[i])
if j in (_KEEL_IDX, _SHEER_IDX):
kx = (self._hull.get_keel_x_offsets()
if j == _KEEL_IDX else self._hull.get_sheer_x_offsets())
new_x = float(np.clip(wx, -self._hull.lpp * 0.2, self._hull.lpp * 1.2))
if i > 0:
new_x = max(new_x, float(ot.x_stations[i - 1]) + float(kx[i - 1]) + 0.01)
if i < ot.n_stations - 1:
new_x = min(new_x, float(ot.x_stations[i + 1]) + float(kx[i + 1]) - 0.01)
if j == _KEEL_IDX:
if len(self._hull.keel_x_offsets) != ot.n_stations:
self._hull.keel_x_offsets = np.zeros(ot.n_stations)
self._hull.keel_x_offsets[i] = new_x - x_ref
else:
if len(self._hull.sheer_x_offsets) != ot.n_stations:
self._hull.sheer_x_offsets = np.zeros(ot.n_stations)
self._hull.sheer_x_offsets[i] = new_x - x_ref
elif 0 < i < ot.n_stations - 1:
new_x = float(np.clip(wx, 0.0, self._hull.lpp))
x_prev = float(ot.x_stations[i - 1]) + float(ot.x_offsets[i - 1, j])
x_next = float(ot.x_stations[i + 1]) + float(ot.x_offsets[i + 1, j])
new_x = max(new_x, x_prev + 0.01)
new_x = min(new_x, x_next - 0.01)
ot.x_offsets[i, j] = new_x - x_ref
else:
# Nodos de borde (AP i=0 / FP i=n_sta-1): X libre — DEFINEN el contorno
new_x = float(np.clip(wx, -self._hull.lpp * 0.15, self._hull.lpp * 1.15))
ot.x_offsets[i, j] = new_x - x_ref
# ── Z ─────────────────────────────────────────────────────────────────
if j == _KEEL_IDX:
kz = ot.keel_z
z_top = float(ot.z_waterlines[0]) - 1e-3
kz[i] = float(np.clip(wz, -self._hull.depth * 2, z_top))
elif j == _SHEER_IDX:
if len(self._hull.sheer_z) != ot.n_stations:
self._hull.sheer_z = self._hull.get_sheer_z().copy()
z_floor = float(ot.z_waterlines[-1]) + 1e-3
self._hull.sheer_z[i] = float(np.clip(wz, z_floor, self._hull.depth * 3.0))
else:
# Z: independiente por nodo — z_offsets[i, j] sin alterar z_waterlines
z_ref = float(ot.z_waterlines[j])
keel_i = float(ot.keel_z[i])
sheer_i = float(self._hull.get_sheer_z()[i])
new_z = float(np.clip(wz, keel_i + 1e-3, sheer_i - 1e-3))
if j > 0:
z_prev = float(ot.z_waterlines[j - 1]) + float(ot.z_offsets[i, j - 1])
new_z = max(new_z, z_prev + 1e-3)
if j < ot.n_waterlines - 1:
z_next = float(ot.z_waterlines[j + 1]) + float(ot.z_offsets[i, j + 1])
new_z = min(new_z, z_next - 1e-3)
ot.z_offsets[i, j] = new_z - z_ref
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
D = self._hull.depth
sheer = self._hull.get_sheer_z()
keel = ot.keel_z
x_sta = ot.x_stations
z_bot = min(float(keel.min()), 0.0)
# ── Grilla cartesiana de medición (fondo) ─────────────────────────
_draw_dim_grid(p, self._w2s, self._s2w, self.width(), self.height())
# ── Grilla de estaciones — planos en station_planes ──────────────
station_xk = self._hull.get_station_planes()
p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine))
for xk in station_xk:
z_lo = float(np.interp(xk, x_sta, keel)) - T * 0.05
z_hi = float(np.interp(xk, x_sta, sheer)) + T * 0.05
p.drawLine(self._w2s(xk, z_lo), self._w2s(xk, z_hi))
# ── Líneas de agua de referencia ──────────────────────────────
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))
# Etiqueta de calado de diseño
if is_design:
lp = self._w2s(Lpp, z)
p.setFont(QFont("Monospace", 7))
p.setPen(QPen(_WL_DESIGN))
p.drawText(
QRectF(lp.x() + 4, lp.y() - 8, 70, 14),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
f"T = {T:.2f} m",
)
# ── Líneas de pantoque (buttock lines) ─────────────────────────
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,
keel_z=keel, sheer_z=sheer,
keel_x_off=self._hull.get_keel_x_offsets(),
sheer_x_off=self._hull.get_sheer_x_offsets(),
)
if len(pts) < 2:
continue
arr = np.array(pts, dtype=float)
smooth = _smooth_pts(arr, n=80)
frac = b_idx / _N_BUTT
col = QColor(_BUTTOCK)
col.setAlphaF(0.50 + 0.40 * frac)
p.setPen(QPen(col, 1.2))
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)
# ── Malla de control del perfil (aristas de poliedro de control) ─────
# Aristas verticales: keel → wl[0] → ... → wl[n-1] → sheer por estación
# Aristas horizontales: misma LdA / quilla / cubierta entre estaciones
pen_cv = QPen(_CNET_TRAN, 0.9, Qt.PenStyle.DotLine)
pen_ch = QPen(_CNET_LONG, 0.9, Qt.PenStyle.DotLine)
p.setBrush(Qt.BrushStyle.NoBrush)
for i in range(ot.n_stations):
# Columna vertical de esta estación
col_pts = (
[self._screen_pt(i, _KEEL_IDX)]
+ [self._screen_pt(i, j) for j in range(ot.n_waterlines)]
+ [self._screen_pt(i, _SHEER_IDX)]
)
p.setPen(pen_cv)
for k in range(len(col_pts) - 1):
p.drawLine(col_pts[k], col_pts[k + 1])
# Aristas horizontales por línea de agua
p.setPen(pen_ch)
for j in range(ot.n_waterlines):
for i in range(ot.n_stations - 1):
p.drawLine(self._screen_pt(i, j), self._screen_pt(i + 1, j))
# Aristas horizontales quilla y cubierta
for i in range(ot.n_stations - 1):
p.drawLine(self._screen_pt(i, _KEEL_IDX), self._screen_pt(i + 1, _KEEL_IDX))
p.drawLine(self._screen_pt(i, _SHEER_IDX), self._screen_pt(i + 1, _SHEER_IDX))
# ── Contorno del perfil — CONECTADO (quilla→roda→sheer→espejo) ──────
# get_stem_ctrl/get_transom_ctrl garantizan snap de extremos:
# stem[0] = keel[-1] = (x_fp, keel_z[-1])
# stem[-1] = sheer[-1]= (x_fp, sheer_z[-1])
# trans[0] = keel[0] = (x_ap, keel_z[0])
# trans[-1] = sheer[0]= (x_ap, sheer_z[0])
n_sta = ot.n_stations
n_wl = ot.n_waterlines
keel_x_eff = x_sta + self._hull.get_keel_x_offsets()
sheer_x_eff = x_sta + self._hull.get_sheer_x_offsets()
keel_arr = np.column_stack([keel_x_eff, keel])
keel_smo = _smooth_curve_segs(
keel_arr,
[self._hull.is_corner(i, _KEEL_IDX) for i in range(n_sta)],
n=80,
)
sheer_arr = np.column_stack([sheer_x_eff, sheer])
sheer_smo = _smooth_curve_segs(
sheer_arr,
[self._hull.is_corner(i, _SHEER_IDX) for i in range(n_sta)],
n=80,
)
# Roda y espejo derivan de la COLUMNA DE BORDE del mallado.
# Los nodos de borde DEFINEN el contorno — no lo siguen.
kx = self._hull.get_keel_x_offsets()
sx = self._hull.get_sheer_x_offsets()
x_fp = float(ot.x_stations[-1])
x_ap = float(ot.x_stations[0])
# Helper: construye puntos de contorno vertical (roda / espejo).
# Sólo añade el nodo sheer si está POR ENCIMA del último waterplane.
def _boundary_col_pts(i_sta: int, x_base: float) -> tuple[np.ndarray, list[bool]]:
wl_pts = np.column_stack([
x_base + ot.x_offsets[i_sta, :],
ot.z_waterlines + ot.z_offsets[i_sta, :],
])
sheer_z_b = float(sheer[i_sta])
add_sheer = sheer_z_b > float(wl_pts[-1, 1]) + 1e-3
keel_row = np.array([[x_base + float(kx[i_sta]), float(keel[i_sta])]])
if add_sheer:
sheer_row = np.array([[x_base + float(sx[i_sta]), sheer_z_b]])
pts = np.vstack([keel_row, wl_pts, sheer_row])
mask = (
[self._hull.is_corner(i_sta, _KEEL_IDX)]
+ [self._hull.is_corner(i_sta, j) for j in range(n_wl)]
+ [self._hull.is_corner(i_sta, _SHEER_IDX)]
)
else:
pts = np.vstack([keel_row, wl_pts])
mask = (
[self._hull.is_corner(i_sta, _KEEL_IDX)]
+ [self._hull.is_corner(i_sta, j) for j in range(n_wl)]
)
return pts, mask
stem_pts, stem_mask = _boundary_col_pts(n_sta - 1, x_fp)
stem_smo = _smooth_curve_segs(stem_pts, stem_mask, n=60)
trans_pts, trans_mask = _boundary_col_pts(0, x_ap)
trans_smo = _smooth_curve_segs(trans_pts, trans_mask, n=60)
def _outline_seg(pen: QPen, pts: np.ndarray, reverse: bool = False) -> None:
seq = pts[::-1] if reverse else pts
path = QPainterPath()
for k_pt, row in enumerate(seq):
pt = self._w2s(float(row[0]), float(row[1]))
if k_pt == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawPath(path)
# Silueta cerrada — un solo QPainterPath garantiza cero huecos.
# Orden: quilla(AP→FP) → roda(↑) → cubierta(FP→AP) → espejo(↓) → cierre
sil = QPainterPath()
sil.moveTo(self._w2s(float(keel_smo[0, 0]), float(keel_smo[0, 1])))
for _r in keel_smo[1:]:
sil.lineTo(self._w2s(float(_r[0]), float(_r[1])))
for _r in stem_smo:
sil.lineTo(self._w2s(float(_r[0]), float(_r[1])))
for _r in sheer_smo[::-1]:
sil.lineTo(self._w2s(float(_r[0]), float(_r[1])))
for _r in trans_smo[::-1]:
sil.lineTo(self._w2s(float(_r[0]), float(_r[1])))
sil.closeSubpath()
p.setPen(QPen(QColor("#b0c8e0"), 2.0))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawPath(sil)
# Acento de color por segmento (encima del path base)
_outline_seg(QPen(_KEEL, 1.8), keel_smo)
_outline_seg(QPen(_STEM_COLOR, 1.8), stem_smo)
_outline_seg(QPen(_DECK, 1.5), sheer_smo, reverse=True)
_outline_seg(QPen(_TRANSOM_COLOR, 1.8), trans_smo, reverse=True)
# ── Perpendiculares AP / FP ────────────────────────────────────
p.setPen(QPen(_AXIS, 1.0, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(0, float(keel[0]) - T * 0.08),
self._w2s(0, float(sheer[0]) + T * 0.08))
p.drawLine(self._w2s(Lpp, float(keel[-1]) - T * 0.08),
self._w2s(Lpp, float(sheer[-1]) + T * 0.08))
# ── Curvas suaves de líneas de agua (perfil longitudinal de cada LdA) ──
# Se calculan aquí para reutilizarlas en el peine y en el highlight.
wl_smooths: list[np.ndarray] = []
for j in range(ot.n_waterlines):
xs_wl = x_sta + ot.x_offsets[:, j]
zs_wl = float(ot.z_waterlines[j]) + ot.z_offsets[:, j]
pts_wl = np.column_stack([xs_wl, zs_wl])
corners_wl = [self._hull.is_corner(i, j) for i in range(ot.n_stations)]
wl_smooths.append(_smooth_curve_segs(pts_wl, corners_wl, n=60))
# ── Peine de curvatura estilo Delftship (toggle C) ───────────────────
# nodo keel/sheer seleccionado → peine de esa curva
# nodo LdA j seleccionado → peine de esa LdA (en cian)
# sin selección → peine de quilla + cubierta
if self._show_curvature:
sel_j = self._selected_idx[1] if self._selected_idx is not None else None
comb_scale = D * 0.35
cc_pos = QColor("#b060e0")
cc_neg = QColor("#7030b0")
show_keel = sel_j is None or sel_j == _KEEL_IDX
show_sheer = sel_j is None or sel_j == _SHEER_IDX
if show_keel:
_draw_curvature_comb(
p, xs=keel_x_eff, ys=keel,
w2s_fn=self._w2s, scale=comb_scale,
color_pos=cc_pos, color_neg=cc_neg,
)
if show_sheer:
_draw_curvature_comb(
p, xs=sheer_x_eff, ys=sheer,
w2s_fn=self._w2s, scale=comb_scale,
color_pos=cc_pos, color_neg=cc_neg,
)
if sel_j is not None and 0 <= sel_j < len(wl_smooths):
sm = wl_smooths[sel_j]
if len(sm) >= 3:
_draw_curvature_comb(
p, xs=sm[:, 0], ys=sm[:, 1],
w2s_fn=self._w2s, scale=comb_scale,
color_pos=QColor("#00d8ff"),
color_neg=QColor("#0090cc"),
)
# ── Nodos editables ────────────────────────────────────────────────────
for i in range(ot.n_stations):
self._draw_control_point(p, self._screen_pt(i, _KEEL_IDX), (i, _KEEL_IDX))
self._draw_control_point(p, self._screen_pt(i, _SHEER_IDX), (i, _SHEER_IDX))
for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
# ── Etiquetas AP / FP ─────────────────────────────────────────
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, z_bot - T * 0.12)
_lbl("FP", Lpp, z_bot - T * 0.12)
# ── Highlight curva seleccionada (smooth) ─────────────────────────────
# Prioridad: Shift+clic en curva > nodo LdA activo seleccionado.
highlight_j: Optional[int] = None
sel_j_node = self._selected_idx[1] if self._selected_idx is not None else None
if sel_j_node is not None and 0 <= sel_j_node < ot.n_waterlines:
highlight_j = sel_j_node
if self._selected_curve is not None:
ctype, curve_j = self._selected_curve
p.setBrush(Qt.BrushStyle.NoBrush)
if ctype == "keel":
p.setPen(QPen(QColor("#00FFB0"), 2.5))
path = QPainterPath()
for k_pt, row in enumerate(keel_smo):
pt = self._w2s(float(row[0]), float(row[1]))
if k_pt == 0: path.moveTo(pt)
else: path.lineTo(pt)
p.drawPath(path)
elif ctype == "sheer":
p.setPen(QPen(QColor("#00FFB0"), 2.5))
path = QPainterPath()
for k_pt, row in enumerate(sheer_smo[::-1]):
pt = self._w2s(float(row[0]), float(row[1]))
if k_pt == 0: path.moveTo(pt)
else: path.lineTo(pt)
p.drawPath(path)
elif ctype == "wl" and curve_j is not None:
highlight_j = curve_j # smooth highlight below
if highlight_j is not None and highlight_j < len(wl_smooths):
sm = wl_smooths[highlight_j]
if len(sm) >= 2:
is_design = abs(float(ot.z_waterlines[highlight_j]) - T) < 1e-6
hl_col = QColor("#00FFD0") if is_design else QColor("#5acdff")
p.setPen(QPen(hl_col, 2.2))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
for k_pt, row in enumerate(sm):
pt = self._w2s(float(row[0]), float(row[1]))
if k_pt == 0: path.moveTo(pt)
else: path.lineTo(pt)
p.drawPath(path)
self._draw_label(p, "PERFIL LATERAL")
p.end()
def contextMenuEvent(self, event) -> None: # noqa: N802
"""Menú contextual: insertar LdA/estación, esquina, roda/espejo."""
if self._hull is None:
return
from PySide6.QtWidgets import QMenu
wx, wz = self._s2w(event.pos().x(), event.pos().y())
ot = self._hull.offsets
menu = QMenu(self)
act_wl = menu.addAction(f"Insertar línea de agua z = {wz:.3f} m")
act_sta = menu.addAction(f"Insertar estación x = {wx:.3f} m")
# Esquina — visible solo cuando hay nodo bajo el cursor
act_corner = None
from PySide6.QtCore import QPointF
hit_idx = self._hit_test(QPointF(event.pos()))
if hit_idx is not None:
hi, hj = hit_idx
if hj not in (_STEM_IDX, _TRANS_IDX):
is_c = self._hull.is_corner(hi, hj)
label = ("Desmarcar esquina (suavizar)" if is_c
else "Marcar como esquina (sharp)")
menu.addSeparator()
act_corner = menu.addAction(label)
# Añadir punto de control a roda o espejo si se hace clic cerca de ellos
menu.addSeparator()
act_stem = menu.addAction("Añadir punto de control a la roda")
act_trans = menu.addAction("Añadir punto de control al espejo")
result = menu.exec(event.globalPos())
if result == act_wl:
z = float(np.clip(wz, float(ot.z_waterlines[0]) + 1e-3,
float(ot.z_waterlines[-1]) - 1e-3))
self._hull.insert_waterline(z)
self._fit_to_view()
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif result == act_sta:
x = float(np.clip(wx, float(ot.x_stations[0]) + 1e-3,
float(ot.x_stations[-1]) - 1e-3))
self._hull.insert_station(x)
self._fit_to_view()
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif result == act_stem:
ctrl = self._hull.get_stem_ctrl().copy()
# Insertar a mitad del segmento más cercano al clic
dists = [np.hypot(wx - ctrl[k, 0], wz - ctrl[k, 1]) for k in range(len(ctrl))]
idx = int(np.argmin(dists))
idx = max(1, min(idx, len(ctrl) - 1)) # entre interior bounds
new_pt = ((ctrl[idx - 1] + ctrl[idx]) / 2).reshape(1, 2)
self._hull.stem_ctrl = np.insert(ctrl, idx, new_pt, axis=0)
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif result == act_trans:
ctrl = self._hull.get_transom_ctrl().copy()
dists = [np.hypot(wx - ctrl[k, 0], wz - ctrl[k, 1]) for k in range(len(ctrl))]
idx = int(np.argmin(dists))
idx = max(1, min(idx, len(ctrl) - 1))
new_pt = ((ctrl[idx - 1] + ctrl[idx]) / 2).reshape(1, 2)
self._hull.transom_ctrl = np.insert(ctrl, idx, new_pt, axis=0)
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif act_corner is not None and result == act_corner and hit_idx is not None:
hi, hj = hit_idx
self._hull.toggle_corner(hi, hj)
# Actualizar panel de info si el nodo sigue seleccionado
if self._selected_idx == hit_idx:
x, y, z = self._node_world_xyz(hit_idx)
self._info_panel.update_node(x, y, z, self._hull.is_corner(hi, hj))
self.offsets_edited.emit(self._hull.offsets)
self.update()
# ─────────────────────────────────────────────────────────────────────────────
# 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
x_eff = float(ot.x_stations[i]) + float(ot.x_offsets[i, j])
return self._w2s(x_eff, 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
wx, wy = self._s2w(pos.x(), pos.y())
# ── Semi-manga (eje Y del casco) — clamp en [0, beam], sin rebote ──
new_y = max(0.0, min(wy, self._hull.beam))
ot.data[i, j] = new_y
# ── Posición longitudinal del nodo — per-node x_offsets ───────────
# x_stations es INMUTABLE en drag; x_offsets[i,j] almacena la desviación.
if 0 < i < ot.n_stations - 1:
x_ref = float(ot.x_stations[i])
new_x = float(np.clip(wx, 0.0, self._hull.lpp))
x_prev = float(ot.x_stations[i - 1]) + float(ot.x_offsets[i - 1, j])
x_next = float(ot.x_stations[i + 1]) + float(ot.x_offsets[i + 1, j])
new_x = max(new_x, x_prev + 0.01)
new_x = min(new_x, x_next - 0.01)
ot.x_offsets[i, j] = new_x - x_ref
# ── 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 (en station_planes)
station_xk = self._hull.get_station_planes()
p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine))
for xk in station_xk:
p.drawLine(self._w2s(xk, -y_max * 1.10), self._w2s(xk, 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.x_offsets[:, j], ot.data[:, j]])
smooth = _smooth_pts(raw, n=80)
# La semi-manga no puede ser negativa (corrige oscilaciones del spline cerca de la proa)
smooth[:, 1] = np.clip(smooth[:, 1], 0.0, None)
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 estilo Delftship (toggle C) ───────────────
# Sin selección → todas las LdAs (violeta, alpha gradual por altura).
# Nodo/curva sel. → SOLO esa LdA (cian brillante); el resto se oculta.
if self._show_curvature:
if self._selected_curve is not None and self._selected_curve[0] == "wl":
sel_j = self._selected_curve[1]
elif self._selected_idx is not None and self._selected_idx[1] >= 0:
sel_j = self._selected_idx[1]
else:
sel_j = None
for j in range(n_wl):
if sel_j is not None and j != sel_j:
continue # ocultar las demás LdAs
x_eff = ot.x_stations + ot.x_offsets[:, j]
y_arr = ot.data[:, j]
if sel_j is not None: # seleccionada → cian
c_pos = QColor("#00d8ff")
c_neg = QColor("#0090cc")
else: # sin selección → violeta
frac = j / max(n_wl - 1, 1)
c_pos = QColor("#b060e0")
c_neg = QColor("#7030b0")
c_pos.setAlphaF(0.40 + 0.50 * frac)
c_neg.setAlphaF(0.40 + 0.50 * frac)
_draw_curvature_comb(
p,
xs=x_eff, ys=y_arr,
w2s_fn=self._w2s,
scale=self._hull.beam * 0.30,
color_pos=c_pos,
color_neg=c_neg,
)
# ── Curva seleccionada Shift+clic (highlight estilo Delftship) ──────
if self._selected_curve is not None:
ctype, cidx = self._selected_curve
p.setPen(QPen(QColor("#00FFB0"), 2.5))
if ctype == "wl" and cidx is not None:
for i in range(ot.n_stations - 1):
p.drawLine(self._screen_pt(i, cidx),
self._screen_pt(i + 1, cidx))
elif ctype == "sta" and cidx is not None:
for j in range(ot.n_waterlines - 1):
p.drawLine(self._screen_pt(cidx, j),
self._screen_pt(cidx, j + 1))
self._draw_hint_overlay(p)
self._draw_label(p, "VISTA DE PLANTA")
p.end()
def contextMenuEvent(self, event) -> None: # noqa: N802
"""Menú contextual: insertar estación, insertar LdA, corregir crujía."""
if self._hull is None:
return
from PySide6.QtWidgets import QMenu
wx, wy = self._s2w(event.pos().x(), event.pos().y())
ot = self._hull.offsets
menu = QMenu(self)
act_sta = menu.addAction(f"Insertar estación x = {wx:.3f} m")
act_wl = menu.addAction("Insertar línea de agua (editar en perfil)")
menu.addSeparator()
act_snap = menu.addAction("Corregir crujía — Y = 0 para nodos en línea central")
# Si hay un nodo seleccionado/hover cerca del eje, ofrecer acción individual
act_snap1 = None
from PySide6.QtCore import QPointF
idx = self._hit_test(QPointF(event.pos()))
if idx is not None:
i, j = idx
if j >= 0:
cur_y = float(ot.data[i, j])
tol = ot.max_half_breadth * 0.10
if abs(cur_y) < tol:
act_snap1 = menu.addAction(
f"Corregir este nodo Y {cur_y:+.4f} → 0"
)
result = menu.exec(event.globalPos())
if result == act_sta:
x = float(np.clip(wx, float(ot.x_stations[0]) + 1e-3,
float(ot.x_stations[-1]) - 1e-3))
self._hull.insert_station(x)
self._fit_to_view()
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif result == act_snap:
# Snap a Y=0 todos los nodos dentro del 5% del semiplano
tol = ot.max_half_breadth * 0.05
changed = False
for si in range(ot.n_stations):
for sj in range(ot.n_waterlines):
if abs(float(ot.data[si, sj])) < tol:
ot.data[si, sj] = 0.0
changed = True
if changed:
self.offsets_edited.emit(self._hull.offsets)
self.update()
elif act_snap1 is not None and result == act_snap1:
i, j = idx
ot.data[i, j] = 0.0
self.offsets_edited.emit(self._hull.offsets)
self.update()
# ─────────────────────────────────────────────────────────────────────────────
# Utilidades internas
# ─────────────────────────────────────────────────────────────────────────────
def _dist(a: QPointF, b: QPointF) -> float:
return math.hypot(a.x() - b.x(), a.y() - b.y())
def _dist_to_segment(pt: QPointF, a: QPointF, b: QPointF) -> float:
"""Distancia perpendicular (en px) del punto pt al segmento ab."""
dx, dy = b.x() - a.x(), b.y() - a.y()
len_sq = dx * dx + dy * dy
if len_sq < 1e-9:
return _dist(pt, a)
t = max(0.0, min(1.0, ((pt.x() - a.x()) * dx + (pt.y() - a.y()) * dy) / len_sq))
return math.hypot(pt.x() - (a.x() + t * dx), pt.y() - (a.y() + t * dy))
def _resample_curve_smooth(
xs: np.ndarray, ys: np.ndarray, n: int = 80
) -> tuple[np.ndarray, np.ndarray]:
"""Remuestrea la curva (xs, ys) en *n* puntos equidistantes en arco.
Usa CubicSpline de scipy si está disponible (resultado suave), si no
cae a interpolación lineal (evita crash pero menos suave).
Los peines siempre tendrán al menos *n* pelos independientemente de cuántos
puntos tenga la tabla de offsets original.
"""
if len(xs) < 3:
return xs, ys
try:
from scipy.interpolate import CubicSpline
x_f = xs.astype(float)
y_f = ys.astype(float)
# Parametrización por longitud de arco
diffs = np.diff(np.column_stack([x_f, y_f]), axis=0)
ds = np.hypot(diffs[:, 0], diffs[:, 1])
t = np.concatenate([[0.0], np.cumsum(ds)])
# Eliminar duplicados
t_u, idx = np.unique(t, return_index=True)
if len(t_u) < 3:
return xs, ys
t_new = np.linspace(t_u[0], t_u[-1], n)
return (CubicSpline(t_u, x_f[idx])(t_new),
CubicSpline(t_u, y_f[idx])(t_new))
except Exception:
# Fallback lineal
x_f = xs.astype(float)
y_f = ys.astype(float)
diffs = np.diff(np.column_stack([x_f, y_f]), axis=0)
ds = np.hypot(diffs[:, 0], diffs[:, 1])
t = np.concatenate([[0.0], np.cumsum(ds)])
t_new = np.linspace(t[0], t[-1], n)
return np.interp(t_new, t, x_f), np.interp(t_new, t, y_f)
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 estilo Delftship sobre la curva (xs, ys).
Los 'pelos' son perpendiculares a la curva:
• Longitud normalizada: max|κ| → scale (siempre visible aunque la curva sea suave)
• Sentido: positivo = curvatura convexa, negativo = inflexión (voltea al otro lado)
• Spine: línea que une las puntas de todos los pelos
Parámetros
----------
scale : float — longitud máxima del pelo en unidades de mundo (el de max curvatura)
"""
if len(xs) < 3:
return
# Remuestrear a 80 puntos equidistantes en arco para peines densos y suaves
xs, ys = _resample_curve_smooth(xs, ys, n=80)
kappas, nxs, nys = _curvature_comb_data(xs, ys)
# Normalizar: max|κ| → 1.0 para que los pelos sean siempre visibles
max_k = float(np.max(np.abs(kappas)))
if max_k < 1e-12:
return
norm_k = kappas / max_k # rango [-1, 1]; max longitud = scale
tips_world: list[Optional[tuple[float, float]]] = []
for i in range(len(xs)):
k = norm_k[i]
if abs(k) < 1e-4: # extremos (siempre 0 por construcción)
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))
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 — curva que une las puntas (revela irregularidades de curvatura)
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)