Módulo 1 fixes + Módulo 2 motor hidrostático (Tasks 13–13b)
Fixes Module 1 UI: - wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom con ángulo de astilla, corrección zona sobre chine planeador - viewer_3d: buffer hull pendiente para eliminar race condition 500ms - viewer_lines: reescritura completa — waterlines visibles, control points interactivos (drag DelftShip-style), señal offsets_edited - main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer que propaga cambios a todos los visores, editor, 3D y barra hidrostática Módulo 2 — motor HydrostaticCurves (Task 13): - integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas() - upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass - curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict() - tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad, CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget):
|
||||
super().__init__(parent)
|
||||
self._plotter: Optional["QtInteractor"] = None
|
||||
self._ready = False
|
||||
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
||||
self._build_ui()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -105,8 +106,13 @@ class Viewer3DWidget(QWidget):
|
||||
# Configurar tema dark para que combine con la UI
|
||||
self._plotter.set_background("#1a1d30") # viewportCanvas color
|
||||
|
||||
# Cargar casco Wigley como geometría de bienvenida
|
||||
self._load_default_wigley()
|
||||
# Cargar casco pendiente (recibido antes del init) o Wigley por defecto
|
||||
if self._pending_hull is not None:
|
||||
mesh = self._pending_hull.to_mesh()
|
||||
self._render_hull_mesh(mesh)
|
||||
self._pending_hull = None
|
||||
else:
|
||||
self._load_default_wigley()
|
||||
self._ready = True
|
||||
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
|
||||
|
||||
@@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget):
|
||||
def load_hull(self, hull) -> None:
|
||||
"""Carga un objeto Hull en el visor.
|
||||
|
||||
Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms),
|
||||
guarda el hull como pendiente — se cargará al final de _init_plotter().
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
hull : arshipdesign.core.hull.Hull
|
||||
"""
|
||||
if not self._ready or self._plotter is None:
|
||||
logger.warning("Viewer3DWidget no listo — hull no cargado")
|
||||
self._pending_hull = hull # se cargará cuando _init_plotter termine
|
||||
return
|
||||
try:
|
||||
mesh = hull.to_mesh()
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""
|
||||
Visores 2D del plano de líneas del casco.
|
||||
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 uno acepta un objeto Hull y se actualiza al llamar set_hull().
|
||||
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
|
||||
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.
|
||||
@@ -21,9 +25,9 @@ import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
@@ -33,45 +37,62 @@ from arshipdesign.core.hull import Hull
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Paleta del tema
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_BG = QColor("#1a1d30")
|
||||
_GRID = QColor("#2a3060")
|
||||
_WATERLINE = QColor("#4da8ff") # azul cyan
|
||||
_SECTION = QColor("#48a858") # verde
|
||||
_PROFILE = QColor("#e8a020") # dorado
|
||||
_DECK = QColor("#8868c8") # púrpura
|
||||
_KEEL = QColor("#e06060") # rojo suave
|
||||
_TEXT = QColor("#7a8ba8")
|
||||
_AXIS = QColor("#3e4255")
|
||||
_WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa)
|
||||
_BG = QColor("#1a1d30")
|
||||
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
|
||||
_WATERLINE = QColor("#4da8ff") # Líneas de agua
|
||||
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa)
|
||||
_SECTION = QColor("#48a858") # Secciones de proa (verde)
|
||||
_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul)
|
||||
_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado)
|
||||
_DECK = QColor("#8868c8") # Línea de cubierta (púrpura)
|
||||
_KEEL = QColor("#e06060") # Quilla (rojo suave)
|
||||
_TEXT = QColor("#7a8ba8")
|
||||
_AXIS = QColor("#3e4255")
|
||||
|
||||
# Puntos de control (malla editable)
|
||||
_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado
|
||||
_CPT_HOVER = QColor("#ffd700") # oro
|
||||
_CPT_DRAG = QColor("#ff5555") # rojo activo
|
||||
_CPT_RADIUS = 4.0 # px en reposo
|
||||
_CPT_HIT = 14.0 # px umbral de captura
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Base común
|
||||
# Clase base
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _BaseViewer(QWidget):
|
||||
"""Widget base con zoom/paneo común."""
|
||||
"""Widget base con zoom/paneo y edición de puntos de control."""
|
||||
|
||||
# Emitido cuando el usuario arrastra un punto y suelta el botón
|
||||
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._drag_start: Optional[QPointF] = None
|
||||
self.setMouseTracking(True)
|
||||
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.setMouseTracking(True)
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
# ─── API pública ──────────────────────────────────────────────────────────
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def set_hull(self, hull: Optional[Hull]) -> None:
|
||||
self._hull = hull
|
||||
self._hover_idx = None
|
||||
self._drag_idx = None
|
||||
self._fit_to_view()
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transformación mundo → pantalla
|
||||
# ------------------------------------------------------------------
|
||||
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
|
||||
|
||||
def _w2s(self, wx: float, wy: float) -> QPointF:
|
||||
"""Coordenada mundo → coordenada de pantalla."""
|
||||
return QPointF(
|
||||
wx * self._scale + self._offset.x(),
|
||||
wy * self._scale + self._offset.y(),
|
||||
@@ -84,7 +105,6 @@ class _BaseViewer(QWidget):
|
||||
)
|
||||
|
||||
def _fit_to_view(self) -> None:
|
||||
"""Ajusta zoom y offset para encuadrar el casco."""
|
||||
if self._hull is None:
|
||||
return
|
||||
bbox = self._world_bbox()
|
||||
@@ -96,30 +116,29 @@ class _BaseViewer(QWidget):
|
||||
return
|
||||
pw, ph = max(self.width(), 100), max(self.height(), 100)
|
||||
margin = 0.08
|
||||
scale_x = pw * (1 - margin * 2) / ww
|
||||
scale_y = ph * (1 - margin * 2) / wh
|
||||
self._scale = min(scale_x, scale_y)
|
||||
# Centrar
|
||||
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[float, float, float, float]]:
|
||||
return None # subclases lo sobreescriben
|
||||
return None # subclases
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Eventos
|
||||
# ------------------------------------------------------------------
|
||||
# ─── Eventos ─────────────────────────────────────────────────────────────
|
||||
|
||||
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||
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()
|
||||
# Zoom centrado en el cursor
|
||||
self._offset = QPointF(
|
||||
pos.x() + (self._offset.x() - pos.x()) * factor,
|
||||
pos.y() + (self._offset.y() - pos.y()) * factor,
|
||||
@@ -127,60 +146,111 @@ class _BaseViewer(QWidget):
|
||||
self._scale *= factor
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() in (Qt.MouseButton.MiddleButton,
|
||||
Qt.MouseButton.RightButton):
|
||||
self._drag_start = event.position()
|
||||
def mousePressEvent(self, event) -> None:
|
||||
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: # type: ignore[override]
|
||||
if self._drag_start is not None:
|
||||
d = event.position() - self._drag_start
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
# ── Paneo ─────────────────────────────────────────────────────────
|
||||
if self._pan_start is not None:
|
||||
d = event.position() - self._pan_start
|
||||
self._offset += d
|
||||
self._drag_start = event.position()
|
||||
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()
|
||||
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: # type: ignore[override]
|
||||
self._drag_start = None
|
||||
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: # type: ignore[override]
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
self._fit_to_view()
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers de dibujo
|
||||
# ------------------------------------------------------------------
|
||||
# ─── 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_axes(self, p: QPainter,
|
||||
x0w: float, x1w: float, y0w: float, y1w: float,
|
||||
x_label: str = "x [m]", y_label: str = "y [m]") -> None:
|
||||
"""Ejes y grilla con etiquetas."""
|
||||
p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine))
|
||||
|
||||
# Eje X
|
||||
p0 = self._w2s(x0w, 0.0)
|
||||
p1 = self._w2s(x1w, 0.0)
|
||||
p.drawLine(p0, p1)
|
||||
|
||||
# Eje Y
|
||||
p0 = self._w2s(0.0, y0w)
|
||||
p1 = self._w2s(0.0, y1w)
|
||||
p.drawLine(p0, p1)
|
||||
|
||||
def _draw_label(self, p: QPainter, text: str) -> None:
|
||||
p.setPen(QPen(_TEXT))
|
||||
fnt = QFont("Monospace", 8)
|
||||
p.setFont(fnt)
|
||||
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, 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))
|
||||
fnt = QFont("Monospace", 10)
|
||||
p.setFont(fnt)
|
||||
p.setFont(QFont("Monospace", 10))
|
||||
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
|
||||
|
||||
def _draw_control_point(
|
||||
self,
|
||||
p: QPainter,
|
||||
screen_pt: QPointF,
|
||||
idx: tuple[int, int],
|
||||
) -> None:
|
||||
"""Dibuja un punto de control con color según estado."""
|
||||
if idx == self._drag_idx:
|
||||
color = _CPT_DRAG
|
||||
r = _CPT_RADIUS * 1.8
|
||||
elif idx == self._hover_idx:
|
||||
color = _CPT_HOVER
|
||||
r = _CPT_RADIUS * 1.5
|
||||
else:
|
||||
color = _CPT_NORMAL
|
||||
r = _CPT_RADIUS
|
||||
p.setPen(QPen(color.darker(130), 1))
|
||||
p.setBrush(QBrush(color))
|
||||
p.drawEllipse(screen_pt, r, r)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Body Plan — secciones transversales
|
||||
@@ -190,19 +260,56 @@ class BodyPlanViewer(_BaseViewer):
|
||||
"""Vista de cuadernas (body plan).
|
||||
|
||||
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
|
||||
Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0).
|
||||
La quilla maestra se resalta.
|
||||
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.1
|
||||
z_max = ot.draft * 1.15
|
||||
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)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
# ── 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)
|
||||
@@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer):
|
||||
T = self._hull.draft
|
||||
n = ot.n_stations
|
||||
|
||||
# ── Grilla de líneas de agua ───────────────────────────────
|
||||
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
|
||||
p.setPen(wl_pen)
|
||||
for z in ot.z_waterlines:
|
||||
# Línea horizontal en z
|
||||
x_max = ot.max_half_breadth * 1.1
|
||||
left = self._w2s(-x_max, z)
|
||||
right = self._w2s( x_max, z)
|
||||
p.drawLine(left, right)
|
||||
# ── Líneas de agua — grilla horizontal ────────────────────────
|
||||
x_max = ot.max_half_breadth * 1.15
|
||||
for j, z in enumerate(ot.z_waterlines):
|
||||
is_design = abs(z - T) < 1e-6
|
||||
if is_design:
|
||||
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
||||
else:
|
||||
p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine))
|
||||
p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z))
|
||||
|
||||
# Línea de flotación de diseño (más gruesa)
|
||||
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
||||
x_max = ot.max_half_breadth * 1.1
|
||||
# Línea de flotación de diseño (más visible)
|
||||
p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine))
|
||||
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
|
||||
|
||||
# ── Dibujar secciones ──────────────────────────────────────
|
||||
# ── Secciones ─────────────────────────────────────────────────
|
||||
for i in range(n):
|
||||
# Progreso de AP a FP: proa a estribor, popa a babor
|
||||
is_forward = i >= n // 2
|
||||
is_fwd = i >= n // 2
|
||||
is_mid = i == n // 2
|
||||
|
||||
if is_forward:
|
||||
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
|
||||
if is_mid:
|
||||
pen = QPen(_MIDSHIP, 2.5)
|
||||
elif is_fwd:
|
||||
pen = QPen(_SECTION, 1.4)
|
||||
else:
|
||||
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
|
||||
|
||||
# Cuaderna maestra más gruesa
|
||||
if i == n // 2:
|
||||
pen.setWidthF(2.5)
|
||||
pen.setColor(_PROFILE)
|
||||
pen = QPen(_SECTION_AFT, 1.4)
|
||||
|
||||
p.setPen(pen)
|
||||
y_arr = ot.data[i, :]
|
||||
z_arr = ot.z_waterlines
|
||||
sign = 1.0 if is_forward else -1.0 # estribor o babor
|
||||
sign = 1.0 if is_fwd else -1.0
|
||||
|
||||
path = QPainterPath()
|
||||
started = False
|
||||
for y, z in zip(y_arr, z_arr):
|
||||
for k, (y, z) in enumerate(zip(y_arr, z_arr)):
|
||||
pt = self._w2s(sign * y, z)
|
||||
if not started:
|
||||
if k == 0:
|
||||
path.moveTo(pt)
|
||||
started = True
|
||||
else:
|
||||
path.lineTo(pt)
|
||||
# Cerrar en quilla
|
||||
path.lineTo(self._w2s(0.0, 0.0))
|
||||
p.drawPath(path)
|
||||
|
||||
# ── Ejes ──────────────────────────────────────────────────
|
||||
# ── Ejes ──────────────────────────────────────────────────────
|
||||
p.setPen(QPen(_AXIS, 1))
|
||||
x_max = ot.max_half_breadth * 1.1
|
||||
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico
|
||||
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
|
||||
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía
|
||||
|
||||
# ── Puntos de control ─────────────────────────────────────────
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
for i in range(n):
|
||||
for j in range(ot.n_waterlines):
|
||||
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
|
||||
|
||||
self._draw_label(p, "BODY PLAN")
|
||||
p.end()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Profile Viewer — vista lateral
|
||||
# 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 proyectadas, perfil de cubierta, quilla.
|
||||
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]:
|
||||
@@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer):
|
||||
-self._hull.lpp * 0.05,
|
||||
-self._hull.draft * 0.15,
|
||||
self._hull.lpp * 1.05,
|
||||
self._hull.draft * 1.25,
|
||||
self._hull.draft * 1.30,
|
||||
)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
def paintEvent(self, event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self._draw_background(p)
|
||||
@@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer):
|
||||
T = self._hull.draft
|
||||
Lpp = self._hull.lpp
|
||||
|
||||
# ── Grilla de estaciones ───────────────────────────────────
|
||||
# ── Grilla de estaciones ───────────────────────────────────────
|
||||
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
|
||||
for x in ot.x_stations:
|
||||
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15))
|
||||
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2))
|
||||
|
||||
# ── Líneas de agua en perfil (ancho máximo a cada z) ────────
|
||||
# ── Líneas de agua en perfil ───────────────────────────────────
|
||||
for j, z in enumerate(ot.z_waterlines):
|
||||
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
|
||||
width = 1.5 if abs(z - T) < 1e-6 else 0.8
|
||||
p.setPen(QPen(color, width))
|
||||
# En perfil, la línea de agua aparece como línea recta horizontal
|
||||
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
|
||||
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
|
||||
# Dibujamos la línea completa
|
||||
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 (z = puntal) ──────────────────────────────────
|
||||
# ── Cubierta ──────────────────────────────────────────────────
|
||||
p.setPen(QPen(_DECK, 1.8))
|
||||
path_deck = QPainterPath()
|
||||
for k, x in enumerate(ot.x_stations):
|
||||
@@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer):
|
||||
path_deck.lineTo(pt)
|
||||
p.drawPath(path_deck)
|
||||
|
||||
# ── Quilla ─────────────────────────────────────────────────
|
||||
# ── Quilla ────────────────────────────────────────────────────
|
||||
p.setPen(QPen(_KEEL, 2.0))
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
|
||||
|
||||
# ── Perpendiculares AP y FP ────────────────────────────────
|
||||
# ── 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))
|
||||
|
||||
# Etiquetas AP / FP
|
||||
p.setPen(QPen(_TEXT))
|
||||
p.setFont(QFont("Monospace", 8))
|
||||
ap_pt = self._w2s(0, -T * 0.12)
|
||||
fp_pt = self._w2s(Lpp, -T * 0.12)
|
||||
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
|
||||
Qt.AlignmentFlag.AlignCenter, "AP")
|
||||
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
|
||||
Qt.AlignmentFlag.AlignCenter, "FP")
|
||||
_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()
|
||||
@@ -365,7 +475,9 @@ class PlanViewer(_BaseViewer):
|
||||
"""Vista de planta (semiplano superior).
|
||||
|
||||
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
|
||||
Muestra: líneas de agua superpuestas como contornos.
|
||||
|
||||
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]:
|
||||
@@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer):
|
||||
-self._hull.lpp * 0.05,
|
||||
-y_max * 0.15,
|
||||
self._hull.lpp * 1.05,
|
||||
y_max * 1.20,
|
||||
y_max * 1.25,
|
||||
)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
# ── 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)
|
||||
@@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer):
|
||||
|
||||
ot = self._hull.offsets
|
||||
T = self._hull.draft
|
||||
|
||||
# ── Líneas de agua como contornos ──────────────────────────
|
||||
n_wl = ot.n_waterlines
|
||||
|
||||
# ── Líneas de agua como contornos ─────────────────────────────
|
||||
for j in range(n_wl):
|
||||
z = ot.z_waterlines[j]
|
||||
is_design = abs(z - T) < 1e-6
|
||||
color = _WL_DESIGN if is_design else _WATERLINE
|
||||
alpha = int(60 + 195 * j / max(n_wl - 1, 1))
|
||||
c = QColor(color)
|
||||
c.setAlpha(alpha)
|
||||
width = 2.0 if is_design else 0.9
|
||||
frac = j / max(n_wl - 1, 1)
|
||||
|
||||
p.setPen(QPen(c, width))
|
||||
if is_design:
|
||||
color = QColor(_WL_DESIGN)
|
||||
color.setAlphaF(1.0)
|
||||
width = 2.0
|
||||
else:
|
||||
color = QColor(_WATERLINE)
|
||||
color.setAlphaF(0.30 + 0.55 * frac)
|
||||
width = 0.9
|
||||
|
||||
p.setPen(QPen(color, width))
|
||||
path = QPainterPath()
|
||||
x_arr = ot.x_stations
|
||||
y_arr = ot.data[:, j]
|
||||
started = False
|
||||
for x, y in zip(x_arr, y_arr):
|
||||
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
|
||||
pt = self._w2s(x, y)
|
||||
if not started:
|
||||
if k == 0:
|
||||
path.moveTo(pt)
|
||||
started = True
|
||||
else:
|
||||
path.lineTo(pt)
|
||||
p.drawPath(path)
|
||||
|
||||
# ── Eje de crujía ──────────────────────────────────────────
|
||||
# ── 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),
|
||||
)
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
|
||||
|
||||
# ── Estaciones (líneas verticales tenues) ──────────────────
|
||||
# ── Estaciones ────────────────────────────────────────────────
|
||||
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
|
||||
y_max = ot.max_half_breadth
|
||||
for x in ot.x_stations:
|
||||
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1))
|
||||
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15))
|
||||
|
||||
# ── Puntos de control ─────────────────────────────────────────
|
||||
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))
|
||||
|
||||
self._draw_label(p, "VISTA DE PLANTA")
|
||||
p.end()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Utilidad interna
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return math.hypot(a.x() - b.x(), a.y() - b.y())
|
||||
|
||||
Reference in New Issue
Block a user