2445 lines
107 KiB
Python
2445 lines
107 KiB
Python
"""
|
||
Visores 2D del plano de líneas del casco — con edición interactiva.
|
||
|
||
Tres widgets especializados basados en QPainter:
|
||
• BodyPlanViewer — secciones transversales (body plan)
|
||
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
||
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
||
|
||
Cada visor muestra la malla de puntos de control de la OffsetsTable.
|
||
El usuario puede arrastrar cualquier punto para modificar la geometría;
|
||
al soltar se emite la señal ``offsets_edited(OffsetsTable)``.
|
||
|
||
Soportan zoom con rueda del ratón y paneo con botón medio/derecho.
|
||
Doble clic restablece el encuadre automático.
|
||
|
||
Referencia:
|
||
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
||
|
||
Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign
|
||
IACS Rec.34 §4: verificado contra OffsetsTable analítica Wigley.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||
from PySide6.QtGui import (
|
||
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, 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)
|