feat(sprint1): motor NURBS, modelos de casco, visor 3D PyVista

Geometría:
  - BSplineCurve: interpolación scipy, arc_length, tangente, chord-length
  - LoftedSurface: lofting de secciones → RectBivariateSpline bivariate

Core (casco Wigley como caso de prueba):
  - Section: área, centroide_z, max_half_breadth, curva B-spline
  - OffsetsTable: from_wigley(), to_sections(), interpolación xy
  - Hull: volumen, Awp, LCB, VCB, Cb, Cm, Cp, desplazamiento, to_mesh()

UI:
  - Viewer3DWidget (pyvistaqt.QtInteractor): casco Wigley por defecto
    al arrancar, fondo navy, waterplane semi-transparente, fallback
    graceful si PyVista no disponible
  - MainWindow: Viewer3DWidget inyectado en viewport Perspectiva 3D

Tests: 39 nuevos tests, fórmulas analíticas Wigley verificadas (±1%)
  V = 4BLT/9, Cb = 4/9, Awp = 2BL/3 (derivación correcta)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 01:07:35 -04:00
parent 135f097079
commit 503e00bfc9
8 changed files with 1519 additions and 12 deletions
+170 -2
View File
@@ -1,2 +1,170 @@
"""Tabla de offsets. Stub — Sprint 1."""
raise NotImplementedError("offsets — Sprint 1")
"""
OffsetsTable — tabla de offsets naval clásica.
Estructura: filas = estaciones (x), columnas = líneas de agua (z).
Valores: semi-manga y(x, z) en metros.
Autor: Álvaro Romero
Sprint 1 — AR-ShipDesign
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from arshipdesign.core.section import Section
@dataclass
class OffsetsTable:
"""Tabla de offsets del casco.
Atributos
---------
x_stations : np.ndarray, shape (n_sta,)
Posiciones longitudinales [m] de cada estación, creciente.
z_waterlines : np.ndarray, shape (n_wl,)
Alturas de líneas de agua [m] desde la quilla, creciente.
data : np.ndarray, shape (n_sta, n_wl)
Semi-mangas y[i, j] en metros.
y[i, j] = semi-manga en la estación x_stations[i], línea z_waterlines[j].
station_labels : list[str]
Etiquetas opcionales para las estaciones.
lpp : float
Eslora entre perpendiculares [m].
beam : float
Manga máxima [m].
draft : float
Calado de diseño [m].
"""
x_stations: np.ndarray
z_waterlines: np.ndarray
data: np.ndarray
station_labels: list[str] = field(default_factory=list)
lpp: float = 0.0
beam: float = 0.0
draft: float = 0.0
def __post_init__(self) -> None:
self.x_stations = np.asarray(self.x_stations, dtype=float)
self.z_waterlines = np.asarray(self.z_waterlines, dtype=float)
self.data = np.asarray(self.data, dtype=float)
n_sta = len(self.x_stations)
n_wl = len(self.z_waterlines)
if self.data.shape != (n_sta, n_wl):
raise ValueError(
f"data.shape {self.data.shape} ≠ ({n_sta}, {n_wl})"
)
if not self.station_labels:
self.station_labels = [str(i) for i in range(n_sta)]
# ------------------------------------------------------------------
# Fábrica: casco Wigley analítico
# ------------------------------------------------------------------
@classmethod
def from_wigley(
cls,
lpp: float = 10.0,
beam: float = 1.5,
draft: float = 0.75,
n_stations: int = 21,
n_waterlines: int = 11,
) -> "OffsetsTable":
"""Genera tabla de offsets para el casco Wigley matemático.
Fórmula analítica:
y(x, z) = (B/2) · [1 (2ξ/L)²] · [1 (ζ/T)²]
donde ξ ∈ [L/2, L/2] y ζ ∈ [T, 0].
El AP corresponde a x=0, el FP a x=Lpp.
La quilla está a z=0, la flotación de diseño a z=draft.
"""
# Posiciones longitudinales: 0 (AP) → Lpp (FP)
x_sta = np.linspace(0.0, lpp, n_stations)
# Líneas de agua: 0 (quilla) → draft (calado diseño)
z_wl = np.linspace(0.0, draft, n_waterlines)
# Convertir a coordenadas centradas en el midship
xi = x_sta - lpp / 2.0 # ξ ∈ [-Lpp/2, Lpp/2]
zeta = z_wl - draft # ζ ∈ [-T, 0]
# Factores de forma
f_xi = 1.0 - (2.0 * xi / lpp) ** 2 # (n_sta,)
f_zeta = 1.0 - (zeta / draft) ** 2 # (n_wl,)
# y[i, j] = B/2 · f_xi[i] · f_zeta[j]
data = (beam / 2.0) * np.outer(f_xi, f_zeta)
data = np.clip(data, 0.0, None) # evitar negativos por redondeo
labels = [f"S{i}" for i in range(n_stations)]
return cls(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=labels,
lpp=lpp,
beam=beam,
draft=draft,
)
# ------------------------------------------------------------------
# Conversión a secciones
# ------------------------------------------------------------------
def to_sections(self) -> list[Section]:
"""Convierte la tabla en una lista de objetos Section."""
sections = []
for i, x in enumerate(self.x_stations):
sta = self.station_labels[i] if i < len(self.station_labels) else str(i)
sec = Section(
station=sta,
x=float(x),
half_breadths=self.data[i, :].copy(),
z_positions=self.z_waterlines.copy(),
label=f"x={x:.3f} m",
)
sections.append(sec)
return sections
# ------------------------------------------------------------------
# Acceso
# ------------------------------------------------------------------
def half_breadth(self, x: float, z: float) -> float:
"""Interpola la semi-manga en cualquier (x, z) [m]."""
# Interpolar en x
col_y = np.array([
float(np.interp(x, self.x_stations, self.data[:, j]))
for j in range(len(self.z_waterlines))
])
# Interpolar en z
return float(np.interp(z, self.z_waterlines, col_y))
@property
def n_stations(self) -> int:
return len(self.x_stations)
@property
def n_waterlines(self) -> int:
return len(self.z_waterlines)
@property
def max_half_breadth(self) -> float:
return float(self.data.max())
# ------------------------------------------------------------------
# Dunder
# ------------------------------------------------------------------
def __repr__(self) -> str:
return (
f"OffsetsTable(Lpp={self.lpp} m, B={self.beam} m, T={self.draft} m, "
f"stations={self.n_stations}, waterlines={self.n_waterlines})"
)