""" Visores 2D del plano de líneas del casco — con edición interactiva. Tres widgets especializados basados en QPainter: • BodyPlanViewer — secciones transversales (body plan) • ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla) • PlanViewer — vista de planta (líneas de agua desde arriba) Cada visor muestra la malla de puntos de control de la OffsetsTable. El usuario puede arrastrar cualquier punto para modificar la geometría; al soltar se emite la señal ``offsets_edited(OffsetsTable)``. Soportan zoom con rueda del ratón y paneo con botón medio/derecho. Doble clic restablece el encuadre automático. Referencia: Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan. Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign IACS Rec.34 §4: verificado contra OffsetsTable analítica Wigley. """ from __future__ import annotations import math from typing import Optional import numpy as np from PySide6.QtCore import QPointF, QRectF, Qt, Signal from PySide6.QtGui import ( QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent, ) from PySide6.QtWidgets import QWidget from arshipdesign.core.hull import Hull # ───────────────────────────────────────────────────────────────────────────── # Paleta del tema # ───────────────────────────────────────────────────────────────────────────── _BG = QColor("#131722") # ── Referencia / grilla (muy tenue, no compite con nada) ──────────────── _GRID_STA = QColor(38, 55, 88, 80) # líneas de estación _GRID_WL = QColor(40, 60, 95, 70) # líneas de agua (referencia) _AXIS = QColor("#3e4255") # ── Malla de control (control net) — thin, muted ─────────────────────── # Capa intermedia entre grilla y curvas del casco. # Conecta los nodos formando el poliedro de control. _CNET_TRAN = QColor(50, 80, 130, 140) # aristas transversales (a lo largo de estación) _CNET_LONG = QColor(35, 90, 80, 110) # aristas longitudinales (a lo largo de LdA) # ── Curvas del casco (sobre la malla) ────────────────────────────────── _WATERLINE = QColor("#2a82c0") # líneas de agua _WL_DESIGN = QColor("#00ccff") # flotación de diseño _SECTION = QColor("#3a9e52") # secciones de proa _SECTION_AFT = QColor("#2a78c0") # secciones de popa _MIDSHIP = QColor("#d89020") # cuaderna maestra _DECK = QColor("#7058b8") # cubierta _KEEL = QColor("#c85858") # quilla _TEXT = QColor("#7a8ba8") # ── Nodos (handles) — encima de todo, color único: NARANJA ───────────── # El naranja no existe en ninguna curva del casco → cero ambigüedad. _NODE_NORMAL = QColor("#FF8000") # naranja: estado de reposo _NODE_HOVER = QColor("#FFD700") # oro: hover _NODE_DRAG = QColor("#FF2020") # rojo vivo: arrastrando _NODE_R = 4.5 # px semi-lado del cuadrado _CPT_HIT = 16.0 # px umbral de captura (alias legacy) _CPT_RADIUS = _NODE_R # alias legacy # ───────────────────────────────────────────────────────────────────────────── # Clase base # ───────────────────────────────────────────────────────────────────────────── class _BaseViewer(QWidget): """Widget base con zoom/paneo y edición de puntos de control.""" # Emitido mientras el usuario arrastra (en cada mouseMoveEvent con drag) offsets_dragging = Signal(object) # OffsetsTable — actualización en vivo # Emitido cuando el usuario suelta el botón (fin del drag) offsets_edited = Signal(object) # OffsetsTable modificada def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._hull: Optional[Hull] = None self._scale = 1.0 self._offset = QPointF(0.0, 0.0) self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho) # Estado de edición de puntos de control self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline) self._drag_idx: Optional[tuple[int, int]] = None self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) self._show_curvature = False # toggle con tecla C self.setMouseTracking(True) self.setCursor(Qt.CursorShape.ArrowCursor) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # ─── API pública ────────────────────────────────────────────────────────── def set_hull(self, hull: Optional[Hull]) -> None: """Carga el casco y resetea zoom/pan al autofit (para carga inicial).""" self._hull = hull self._hover_idx = None self._drag_idx = None self._fit_to_view() self.update() def update_offsets(self, hull: Optional[Hull]) -> None: """Actualiza datos SIN resetear zoom/pan — usar para ediciones live.""" self._hull = hull self._hover_idx = None self.update() # ─── Transform mundo ↔ pantalla ────────────────────────────────────────── def _w2s(self, wx: float, wy: float) -> QPointF: return QPointF( wx * self._scale + self._offset.x(), wy * self._scale + self._offset.y(), ) def _s2w(self, sx: float, sy: float) -> tuple[float, float]: return ( (sx - self._offset.x()) / self._scale, (sy - self._offset.y()) / self._scale, ) def _fit_to_view(self) -> None: if self._hull is None: return bbox = self._world_bbox() if bbox is None: return wx0, wy0, wx1, wy1 = bbox ww, wh = wx1 - wx0, wy1 - wy0 if ww < 1e-6 or wh < 1e-6: return pw, ph = max(self.width(), 100), max(self.height(), 100) margin = 0.08 self._scale = min( pw * (1 - margin * 2) / ww, ph * (1 - margin * 2) / wh, ) cx = pw / 2 - (wx0 + ww / 2) * self._scale cy = ph / 2 - (wy0 + wh / 2) * self._scale self._offset = QPointF(cx, cy) def keyPressEvent(self, event) -> None: if event.key() == Qt.Key.Key_C: self._show_curvature = not self._show_curvature self.update() else: super().keyPressEvent(event) def _world_bbox(self) -> Optional[tuple[float, float, float, float]]: return None # subclases # ─── Eventos ───────────────────────────────────────────────────────────── def resizeEvent(self, event) -> None: self._fit_to_view() super().resizeEvent(event) def wheelEvent(self, event: QWheelEvent) -> None: if self._drag_idx is not None: return delta = event.angleDelta().y() factor = 1.15 if delta > 0 else 1.0 / 1.15 pos = event.position() self._offset = QPointF( pos.x() + (self._offset.x() - pos.x()) * factor, pos.y() + (self._offset.y() - pos.y()) * factor, ) self._scale *= factor self.update() def mousePressEvent(self, event) -> None: self.setFocus() # captura el foco de teclado al hacer clic btn = event.button() if btn == Qt.MouseButton.LeftButton and self._hull is not None: idx = self._hit_test(event.position()) if idx is not None: self._drag_idx = idx self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]]) self.setCursor(Qt.CursorShape.SizeAllCursor) event.accept() return if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._pan_start = event.position() def mouseMoveEvent(self, event) -> None: # ── Paneo ───────────────────────────────────────────────────────── if self._pan_start is not None: d = event.position() - self._pan_start self._offset += d self._pan_start = event.position() self.update() return # ── Arrastre de punto de control ────────────────────────────────── if self._drag_idx is not None and self._hull is not None: self._apply_drag(event.position(), self._drag_idx) self.update() self.offsets_dragging.emit(self._hull.offsets) # live cross-view return # ── Hover ───────────────────────────────────────────────────────── old = self._hover_idx if self._hull is not None: self._hover_idx = self._hit_test(event.position()) else: self._hover_idx = None cursor = (Qt.CursorShape.SizeAllCursor if self._hover_idx is not None else Qt.CursorShape.ArrowCursor) self.setCursor(cursor) if self._hover_idx != old: self.update() def mouseReleaseEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None: self._drag_idx = None self.setCursor(Qt.CursorShape.ArrowCursor) if self._hull is not None: self.offsets_edited.emit(self._hull.offsets) event.accept() return if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): self._pan_start = None def mouseDoubleClickEvent(self, event) -> None: self._fit_to_view() self.update() # ─── Métodos de edición (implementados por subclases) ──────────────────── def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: """Busca el punto de control más cercano dentro del umbral de captura.""" return None # subclases def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: """Actualiza la OffsetsTable con la nueva posición del ratón.""" pass # subclases # ─── Helpers de dibujo ─────────────────────────────────────────────────── def _draw_background(self, p: QPainter) -> None: p.fillRect(self.rect(), _BG) def _draw_label(self, p: QPainter, text: str) -> None: p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) p.drawText( self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text, ) def _draw_no_hull(self, p: QPainter, msg: str) -> None: p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 10)) p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) def _draw_hint_overlay(self, p: QPainter) -> None: """Esquina inferior-derecha: atajo de teclado para curvatura.""" txt = "[C] Curvatura ON" if self._show_curvature else "[C] Curvatura" col = QColor("#ffd700") if self._show_curvature else QColor("#3a4870") p.setFont(QFont("Monospace", 7)) p.setPen(QPen(col)) r = self.rect().adjusted(0, 0, -4, -4) p.drawText(r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, txt) def _draw_control_point( self, p: QPainter, screen_pt: QPointF, idx: tuple[int, int], ) -> None: """Dibuja un nodo de control como cuadrado naranja sobre las curvas. El naranja distingue inequívocamente los nodos de cualquier línea del casco (azul/verde/dorado). La forma cuadrada evoca el vocabulario de las herramientas CAD (Maxsurf, DelftShip). """ if idx == self._drag_idx: color = _NODE_DRAG r = _NODE_R * 1.8 elif idx == self._hover_idx: color = _NODE_HOVER r = _NODE_R * 1.4 else: color = _NODE_NORMAL r = _NODE_R from PySide6.QtCore import QRectF p.setPen(QPen(color.darker(180), 1)) p.setBrush(QBrush(color)) p.drawRect(QRectF(screen_pt.x() - r, screen_pt.y() - r, r * 2, r * 2)) # ───────────────────────────────────────────────────────────────────────────── # Helpers: malla de control (control net) # ───────────────────────────────────────────────────────────────────────────── def _draw_cnet_bodyplan(p: QPainter, ot, w2s_fn) -> None: """Dibuja la malla de control en el Body Plan. Capa visual entre la grilla de referencia y las curvas del casco: • Aristas transversales — polilínea de control de cada sección (equal to the section control polyline, muted, drawn BEFORE the actual hull-curve so the colored curve reads on top of it). • Aristas longitudinales — segmentos horizontales a la altura de cada línea de agua, conectando todos los nodos de esa LdA en ambas bandas. Permiten ver cómo varía la manga de proa a popa en cada calado. """ n_sta = ot.n_stations n_wl = ot.n_waterlines # ── Aristas transversales (a lo largo de cada sección) ──────────── pen_t = QPen(_CNET_TRAN, 0.8, Qt.PenStyle.SolidLine) p.setPen(pen_t) p.setBrush(Qt.BrushStyle.NoBrush) for i in range(n_sta): sign = 1.0 if i >= n_sta // 2 else -1.0 path = QPainterPath() for k in range(n_wl): pt = w2s_fn(sign * ot.data[i, k], ot.z_waterlines[k]) if k == 0: path.moveTo(pt) else: path.lineTo(pt) # Cerrar al eje de crujía en la quilla path.lineTo(w2s_fn(0.0, 0.0)) p.drawPath(path) # ── Aristas longitudinales (a lo largo de cada LdA) ─────────────── # Para cada LdA j: una polilínea a través de todas las estaciones, en # cada banda por separado (proa=+y, popa=−y). Se ve como un arco a # la altura z[j], mostrando la variación de manga longitudinalmente. pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine) p.setPen(pen_l) for j in range(n_wl): z = ot.z_waterlines[j] # Banda de proa (estribor, sign=+1) path_fwd = QPainterPath() path_aft = QPainterPath() for i in range(n_sta): sign = 1.0 if i >= n_sta // 2 else -1.0 pt = w2s_fn(sign * ot.data[i, j], z) if i == 0: path_aft.moveTo(pt) elif i == n_sta // 2: path_fwd.moveTo(pt) if i < n_sta // 2: path_aft.lineTo(pt) else: path_fwd.lineTo(pt) p.drawPath(path_fwd) p.drawPath(path_aft) def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None: """Dibuja la malla de control en la Vista de Planta. • Aristas longitudinales — waterlines (conectan todas las estaciones en una LdA = las curvas de contorno, dibujadas muted ANTES de las curvas reales). • Aristas transversales — polilínea vertical por estación, conectando los nodos de esa estación a lo largo de todas las LdA. Muestra cómo cambia la manga con el calado para cada estación. """ n_sta = ot.n_stations n_wl = ot.n_waterlines # ── Aristas longitudinales (contornos de LdA) ───────────────────── pen_l = QPen(_CNET_LONG, 0.7, Qt.PenStyle.SolidLine) p.setPen(pen_l) p.setBrush(Qt.BrushStyle.NoBrush) for j in range(n_wl): path = QPainterPath() for i in range(n_sta): pt = w2s_fn(ot.x_stations[i], ot.data[i, j]) if i == 0: path.moveTo(pt) else: path.lineTo(pt) p.drawPath(path) # ── Aristas transversales (polilínea de sección en planta) ───────── pen_t = QPen(_CNET_TRAN, 0.7, Qt.PenStyle.SolidLine) p.setPen(pen_t) for i in range(n_sta): path = QPainterPath() for j in range(n_wl): pt = w2s_fn(ot.x_stations[i], ot.data[i, j]) if j == 0: path.moveTo(pt) else: path.lineTo(pt) p.drawPath(path) # ───────────────────────────────────────────────────────────────────────────── # 1. Body Plan — secciones transversales # ───────────────────────────────────────────────────────────────────────────── class BodyPlanViewer(_BaseViewer): """Vista de cuadernas (body plan). Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +). Mitad de proa → estribor (derecha, verde). Mitad de popa → babor (izquierda, azul). Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para cambiar la semi-manga en esa estación y línea de agua. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None ot = self._hull.offsets y_max = ot.max_half_breadth * 1.15 z_max = ot.draft * 1.20 return (-y_max, -z_max * 0.05, y_max, z_max) # ── Edición ─────────────────────────────────────────────────────────────── def _screen_pt(self, i: int, j: int) -> QPointF: """Punto de control (i, j) en coordenadas de pantalla.""" ot = self._hull.offsets y = ot.data[i, j] z = ot.z_waterlines[j] sign = 1.0 if i >= ot.n_stations // 2 else -1.0 return self._w2s(sign * y, z) def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: return None ot = self._hull.offsets best_d, best_idx = _CPT_HIT, None for i in range(ot.n_stations): for j in range(ot.n_waterlines): d = _dist(pos, self._screen_pt(i, j)) if d < best_d: best_d, best_idx = d, (i, j) return best_idx def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx sign = 1.0 if i >= ot.n_stations // 2 else -1.0 wx, _ = self._s2w(pos.x(), pos.y()) new_y = max(0.0, sign * wx) # Limitar al doble de la manga para evitar explosiones new_y = min(new_y, self._hull.beam) ot.data[i, j] = new_y # ── Dibujo ──────────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "BODY PLAN\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft n = ot.n_stations x_max = ot.max_half_breadth * 1.15 # ══ CAPA 1: Grilla de referencia (tenue, sin competir) ════════ # Líneas de agua horizontales — referencia de altura for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: p.setPen(QPen(_WL_DESIGN.darker(200), 0.8, Qt.PenStyle.DashLine)) else: p.setPen(QPen(_GRID_WL, 0.5, Qt.PenStyle.DotLine)) p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z)) # Ejes p.setPen(QPen(_AXIS, 1.0)) p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) p.setPen(QPen(_AXIS, 0.7, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.18)) # ══ CAPA 2: Malla de control (control net — thin, muted) ══════ _draw_cnet_bodyplan(p, ot, self._w2s) # ══ CAPA 3: Curvas del casco (bold, saturated) ════════════════ for i in range(n): is_fwd = i >= n // 2 is_mid = i == n // 2 if is_mid: pen = QPen(_MIDSHIP, 2.2) elif is_fwd: pen = QPen(_SECTION, 1.5) else: pen = QPen(_SECTION_AFT, 1.5) p.setPen(pen) p.setBrush(Qt.BrushStyle.NoBrush) y_arr = ot.data[i, :] z_arr = ot.z_waterlines sign = 1.0 if is_fwd else -1.0 path = QPainterPath() for k, (y, z) in enumerate(zip(y_arr, z_arr)): pt = self._w2s(sign * y, z) if k == 0: path.moveTo(pt) else: path.lineTo(pt) path.lineTo(self._w2s(0.0, 0.0)) p.drawPath(path) # Flotación de diseño (encima de todo lo anterior) p.setPen(QPen(_WL_DESIGN, 1.8, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) # ══ CAPA 4: Nodos (cuadrados naranjas — siempre encima) ═══════ for i in range(n): for j in range(ot.n_waterlines): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) # ── Peine de curvatura (toggle C) ───────────────────────────── if self._show_curvature: for i in range(n): sign = 1.0 if i >= n // 2 else -1.0 z_arr = ot.z_waterlines y_arr = ot.data[i, :] # En el body plan: curva en espacio (z, y) — normal en dirección y _draw_curvature_comb( p, xs=z_arr, ys=y_arr * sign, w2s_fn=lambda z, y: self._w2s(y, z), scale=ot.draft * 0.25, color_pos=QColor("#ff6b6b"), color_neg=QColor("#6baaff"), ) self._draw_hint_overlay(p) self._draw_label(p, "BODY PLAN") p.end() # ───────────────────────────────────────────────────────────────────────────── # 2. Profile Viewer — vista lateral (solo lectura) # ───────────────────────────────────────────────────────────────────────────── class ProfileViewer(_BaseViewer): """Vista lateral del casco (perfil). Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m]. Muestra líneas de agua, perfil de cubierta y quilla. No es editable (las z son constantes en la OffsetsTable). """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None return ( -self._hull.lpp * 0.05, -self._hull.draft * 0.15, self._hull.lpp * 1.05, self._hull.draft * 1.30, ) def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "PERFIL LATERAL\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft Lpp = self._hull.lpp # ── Grilla de estaciones ─────────────────────────────────────── p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine)) for x in ot.x_stations: p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2)) # ── Líneas de agua en perfil ─────────────────────────────────── for j, z in enumerate(ot.z_waterlines): is_design = abs(z - T) < 1e-6 if is_design: p.setPen(QPen(_WL_DESIGN, 1.8)) else: frac = j / max(ot.n_waterlines - 1, 1) color = QColor(_WATERLINE) color.setAlphaF(0.40 + 0.50 * frac) p.setPen(QPen(color, 0.9)) p.drawLine(self._w2s(0, z), self._w2s(Lpp, z)) # ── Cubierta ────────────────────────────────────────────────── p.setPen(QPen(_DECK, 1.8)) path_deck = QPainterPath() for k, x in enumerate(ot.x_stations): pt = self._w2s(x, self._hull.depth) if k == 0: path_deck.moveTo(pt) else: path_deck.lineTo(pt) p.drawPath(path_deck) # ── Quilla ──────────────────────────────────────────────────── p.setPen(QPen(_KEEL, 2.0)) p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0)) # ── Perpendiculares AP / FP ──────────────────────────────────── p.setPen(QPen(_AXIS, 1.5)) p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05)) p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05)) p.setPen(QPen(_TEXT)) p.setFont(QFont("Monospace", 8)) _lbl = lambda text, x, z: p.drawText( QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14), Qt.AlignmentFlag.AlignCenter, text ) _lbl("AP", 0, -T * 0.12) _lbl("FP", Lpp, -T * 0.12) self._draw_label(p, "PERFIL LATERAL") p.end() # ───────────────────────────────────────────────────────────────────────────── # 3. Plan Viewer — vista de planta # ───────────────────────────────────────────────────────────────────────────── class PlanViewer(_BaseViewer): """Vista de planta (semiplano superior). Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor). Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar la semi-manga de esa estación en esa línea de agua. """ def _world_bbox(self) -> Optional[tuple]: if self._hull is None: return None y_max = self._hull.offsets.max_half_breadth return ( -self._hull.lpp * 0.05, -y_max * 0.15, self._hull.lpp * 1.05, y_max * 1.25, ) # ── Edición ─────────────────────────────────────────────────────────────── def _screen_pt(self, i: int, j: int) -> QPointF: ot = self._hull.offsets return self._w2s(ot.x_stations[i], ot.data[i, j]) def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: if self._hull is None: return None ot = self._hull.offsets best_d, best_idx = _CPT_HIT, None for i in range(ot.n_stations): for j in range(ot.n_waterlines): d = _dist(pos, self._screen_pt(i, j)) if d < best_d: best_d, best_idx = d, (i, j) return best_idx def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: ot = self._hull.offsets i, j = idx _, wy = self._s2w(pos.x(), pos.y()) new_y = max(0.0, min(wy, self._hull.beam)) ot.data[i, j] = new_y # ── Dibujo ──────────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) if self._hull is None: self._draw_no_hull(p, "VISTA DE PLANTA\nSin casco cargado") p.end() return ot = self._hull.offsets T = self._hull.draft n_wl = ot.n_waterlines y_max = ot.max_half_breadth # ══ CAPA 1: Grilla de referencia ══════════════════════════════ # Estaciones — líneas verticales tenues p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine)) for x in ot.x_stations: p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15)) # Eje de crujía p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0)) # ══ CAPA 2: Malla de control ══════════════════════════════════ _draw_cnet_planview(p, ot, self._w2s) # ══ CAPA 3: Curvas del casco (waterlines como contornos) ══════ for j in range(n_wl): z = ot.z_waterlines[j] frac = j / max(n_wl - 1, 1) is_design = abs(z - T) < 1e-6 if is_design: color = QColor(_WL_DESIGN) width = 2.2 else: color = QColor(_WATERLINE) color.setAlphaF(0.40 + 0.50 * frac) width = 1.1 p.setPen(QPen(color, width)) p.setBrush(Qt.BrushStyle.NoBrush) path = QPainterPath() for i, (x, y) in enumerate(zip(ot.x_stations, ot.data[:, j])): pt = self._w2s(x, y) if i == 0: path.moveTo(pt) else: path.lineTo(pt) p.drawPath(path) # ══ CAPA 4: Nodos (cuadrados naranjas) ════════════════════════ for i in range(ot.n_stations): for j in range(n_wl): self._draw_control_point(p, self._screen_pt(i, j), (i, j)) # ── Peine de curvatura (toggle C) ───────────────────────────── if self._show_curvature: x_arr = ot.x_stations for j in range(n_wl): y_arr = ot.data[:, j] _draw_curvature_comb( p, xs=x_arr, ys=y_arr, w2s_fn=self._w2s, scale=self._hull.beam * 0.18, color_pos=QColor("#ff6b6b"), color_neg=QColor("#6baaff"), ) self._draw_hint_overlay(p) self._draw_label(p, "VISTA DE PLANTA") p.end() # ───────────────────────────────────────────────────────────────────────────── # Utilidades internas # ───────────────────────────────────────────────────────────────────────────── def _dist(a: QPointF, b: QPointF) -> float: return math.hypot(a.x() - b.x(), a.y() - b.y()) def _curvature_comb_data( xs: np.ndarray, ys: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Calcula curvatura discreta firmada y normales unitarias para una curva (xs, ys). Retorna (kappas, nx, ny): - kappas[i]: curvatura firmada en el punto i [1/unidad de longitud] - (nx[i], ny[i]): normal unitaria (90° a la izquierda del tangente) - Los extremos (i=0, i=n-1) tienen kappas=0. """ n = len(xs) kappas = np.zeros(n) nxs = np.zeros(n) nys = np.zeros(n) for i in range(1, n - 1): dx1, dy1 = float(xs[i] - xs[i-1]), float(ys[i] - ys[i-1]) dx2, dy2 = float(xs[i+1] - xs[i]), float(ys[i+1] - ys[i]) l1 = math.hypot(dx1, dy1) l2 = math.hypot(dx2, dy2) if l1 < 1e-9 or l2 < 1e-9: continue # Tangente promediada normalizada tx = dx1/l1 + dx2/l2 ty = dy1/l1 + dy2/l2 tl = math.hypot(tx, ty) if tl < 1e-9: continue tx /= tl; ty /= tl nxs[i] = -ty nys[i] = tx # Curvatura firmada (producto cruzado de tangentes unitarias) cross = (dx1/l1) * (dy2/l2) - (dy1/l1) * (dx2/l2) kappas[i] = 2.0 * cross / (l1 + l2 + 1e-12) return kappas, nxs, nys def _draw_curvature_comb( p: QPainter, xs: np.ndarray, ys: np.ndarray, w2s_fn, scale: float, color_pos: QColor, color_neg: QColor, ) -> None: """ Dibuja el peine de curvatura sobre la curva discreta (xs, ys). Cada 'diente' es una línea perpendicular a la curva con longitud k·scale. Se dibuja también el spine conectando las puntas de los dientes. Parámetros ---------- w2s_fn : callable(x, y) → QPointF Función de conversión mundo→pantalla del visor. scale : float Factor de amplificación en unidades de mundo. color_pos / color_neg : QColor Colores para curvatura positiva / negativa. """ if len(xs) < 3: return kappas, nxs, nys = _curvature_comb_data(xs, ys) tips_world: list[Optional[tuple[float, float]]] = [] for i in range(len(xs)): k = kappas[i] if abs(k) < 1e-9: tips_world.append(None) continue ex = float(xs[i]) + nxs[i] * k * scale ey = float(ys[i]) + nys[i] * k * scale tips_world.append((ex, ey)) # Diente col = color_pos if k > 0 else color_neg p.setPen(QPen(col, 0.8)) p.drawLine(w2s_fn(float(xs[i]), float(ys[i])), w2s_fn(ex, ey)) # Spine (línea que une las puntas) spine = QPainterPath() started = False for tip in tips_world: if tip is None: started = False continue pt = w2s_fn(tip[0], tip[1]) if not started: spine.moveTo(pt) started = True else: spine.lineTo(pt) p.setPen(QPen(color_pos, 1.0)) p.setBrush(Qt.BrushStyle.NoBrush) p.drawPath(spine)