36584f782c
Color palette: - Control net (nodes + connecting lines): neutral grey (#A8B8D0 / 110,120,140) — matches DELFTship convention for control polygon handles - Forward stations (proa): bright green #22CC58 - Aft stations (popa): amber #C8A010 - Midship: orange #FF7020 - Node size reduced 4.5→3.0 px so hull curves dominate visually Plan view (Vista de Planta): - World bbox now symmetric: y ∈ [−B/2·1.22, +B/2·1.22] shows BOTH halves - Waterlines drawn as closed contours: CL-AP → starboard curve → CL-FP → port curve (mirrored) → close at CL-AP Every waterline terminates at the centerline at bow and stern - Control net grid: both directions (station-arm + waterline-arm) drawn on port AND starboard — same visual language as DELFTship control polygon - Station reference lines span full beam (both sides) - Centerline (eje de crujía) drawn as solid line dividing the two halves - Edit nodes remain on starboard only; port updates symmetrically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1047 lines
43 KiB
Python
1047 lines
43 KiB
Python
"""
|
||
Visores 2D del plano de líneas del casco — con edición interactiva.
|
||
|
||
Tres widgets especializados basados en QPainter:
|
||
• BodyPlanViewer — secciones transversales (body plan)
|
||
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
||
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
||
|
||
Cada visor muestra la malla de puntos de control de la OffsetsTable.
|
||
El usuario puede arrastrar cualquier punto para modificar la geometría;
|
||
al soltar se emite la señal ``offsets_edited(OffsetsTable)``.
|
||
|
||
Soportan zoom con rueda del ratón y paneo con botón medio/derecho.
|
||
Doble clic restablece el encuadre automático.
|
||
|
||
Referencia:
|
||
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
||
|
||
Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign
|
||
IACS Rec.34 §4: verificado contra OffsetsTable analítica Wigley.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||
from PySide6.QtGui import (
|
||
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, 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 (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(110, 120, 140, 90) # aristas (dirección estación)
|
||
_CNET_LONG = QColor(100, 112, 132, 75) # 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.
|
||
_NODE_NORMAL = QColor("#A8B8D0") # gris-azulado: reposo
|
||
_NODE_HOVER = QColor("#E0EAFF") # casi blanco: hover
|
||
_NODE_DRAG = QColor("#FF3838") # rojo: arrastrando
|
||
_NODE_R = 3.0 # px semi-lado (era 4.5)
|
||
_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.
|
||
|
||
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], 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)
|
||
|
||
|
||
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):
|
||
pt = w2s_fn(ot.x_stations[i], 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):
|
||
pt = w2s_fn(ot.x_stations[i], 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
|
||
) -> list[tuple[float, float]]:
|
||
"""Calcula los puntos (x_est, z) de una línea de pantoque (buttock) a semi-manga y_b.
|
||
|
||
Una línea de pantoque es la intersección del casco con el plano vertical
|
||
paralelo al plano de crujía a distancia y_b del eje (Y = cte).
|
||
|
||
Para cada estación i, se busca la altura z a la que la semi-manga del
|
||
casco iguala y_b, interpolando linealmente entre líneas de agua adyacentes.
|
||
El resultado se dibuja en la vista de perfil como una curva (x, z).
|
||
|
||
Parámetros
|
||
----------
|
||
ot : OffsetsTable
|
||
y_b : float
|
||
Semi-manga de la pantoque [m].
|
||
|
||
Retorna
|
||
-------
|
||
Lista de tuplas (x_station, z_interp) ordenadas de AP a FP.
|
||
"""
|
||
pts: list[tuple[float, float]] = []
|
||
for i in range(ot.n_stations):
|
||
hb = ot.data[i, :] # semi-mangas en cada LdA para estación i
|
||
zz = ot.z_waterlines
|
||
if y_b > float(hb.max()):
|
||
continue # la pantoque no alcanza esta estación
|
||
# Buscar primer cruce ascendente (quilla → cubierta)
|
||
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]))
|
||
pts.append((float(ot.x_stations[i]), z_interp))
|
||
break
|
||
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
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 1. Body Plan — secciones transversales
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
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 = 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
|
||
|
||
# B-spline suave desde la quilla hasta la cubierta.
|
||
# NO se cierra la spline con (0,0) ya que eso distorsiona la curva;
|
||
# en su lugar se añade un segmento recto de cierre a la quilla.
|
||
raw = np.column_stack([y_arr * sign, z_arr])
|
||
smooth = _smooth_pts(raw, n=80)
|
||
|
||
path = QPainterPath()
|
||
for k_pt in range(len(smooth)):
|
||
pt = self._w2s(smooth[k_pt, 0], smooth[k_pt, 1])
|
||
if k_pt == 0:
|
||
path.moveTo(pt)
|
||
else:
|
||
path.lineTo(pt)
|
||
# Cierre recto al punto de quilla en crujía (0, 0)
|
||
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 / 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).
|
||
|
||
Vista de sólo lectura (no editable directamente).
|
||
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
|
||
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))
|
||
|
||
# ── Líneas de pantoque (buttock lines) ────────────────────────
|
||
# Planos verticales a Y = B/4, B/2, 3B/4 del eje de crujía.
|
||
# Cada pantoque es una curva (x, z) que muestra el run del casco
|
||
# a una distancia lateral constante del plano de crujía.
|
||
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)
|
||
if len(pts) < 2:
|
||
continue
|
||
arr = np.array(pts, dtype=float)
|
||
smooth = _smooth_pts(arr, n=80)
|
||
frac = b_idx / _N_BUTT
|
||
col = QColor(_WATERLINE)
|
||
col.setAlphaF(0.38 + 0.45 * frac)
|
||
p.setPen(QPen(col, 1.1))
|
||
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)
|
||
|
||
# ── 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
|
||
# 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
|
||
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 ══════════════════════════════
|
||
# 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
|
||
p.setPen(QPen(_GRID_STA, 0.5, Qt.PenStyle.DotLine))
|
||
for x in ot.x_stations:
|
||
p.drawLine(self._w2s(x, -y_max * 1.10), self._w2s(x, 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.data[:, j]])
|
||
smooth = _smooth_pts(raw, n=80)
|
||
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 (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)
|