""" 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)