503e00bfc9
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>
133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
"""
|
|
Section — sección transversal del casco naval.
|
|
|
|
Una sección es un corte vertical del casco a posición longitudinal x.
|
|
Se define por puntos (y_half, z) de quilla a cubierta (casco simétrico).
|
|
|
|
Autor: Álvaro Romero
|
|
Sprint 1 — AR-ShipDesign
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
from scipy.integrate import simpson
|
|
|
|
from arshipdesign.geometry.nurbs_curve import BSplineCurve
|
|
|
|
|
|
@dataclass
|
|
class Section:
|
|
"""Sección transversal en una estación.
|
|
|
|
Atributos
|
|
---------
|
|
station : int or str
|
|
Número de estación (0 = AP, 20 = FP en sistema de 21 estaciones).
|
|
x : float
|
|
Posición longitudinal en metros desde AP.
|
|
half_breadths : np.ndarray
|
|
Semi-mangas [m] en cada z (mitad de babor).
|
|
z_positions : np.ndarray
|
|
Alturas z [m] desde la quilla (creciente, 0 = quilla).
|
|
label : str
|
|
Etiqueta opcional.
|
|
"""
|
|
|
|
station: int | str
|
|
x: float
|
|
half_breadths: np.ndarray
|
|
z_positions: np.ndarray
|
|
label: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
self.half_breadths = np.asarray(self.half_breadths, dtype=float)
|
|
self.z_positions = np.asarray(self.z_positions, dtype=float)
|
|
if self.half_breadths.shape != self.z_positions.shape:
|
|
raise ValueError(
|
|
"half_breadths y z_positions deben tener igual número de elementos"
|
|
)
|
|
if len(self.z_positions) < 2:
|
|
raise ValueError("Se necesitan al menos 2 puntos por sección")
|
|
if not np.all(np.diff(self.z_positions) >= 0):
|
|
raise ValueError("z_positions debe ser no-decreciente")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Curva B-spline
|
|
# ------------------------------------------------------------------
|
|
|
|
def curve(self, degree: int = 3) -> BSplineCurve:
|
|
"""Curva B-spline (y_half, z) de esta sección."""
|
|
pts = np.column_stack([self.half_breadths, self.z_positions])
|
|
k = min(degree, len(pts) - 1)
|
|
return BSplineCurve(pts, degree=k)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Hidrostáticos de sección
|
|
# ------------------------------------------------------------------
|
|
|
|
def _clip_to_draft(
|
|
self, draft: Optional[float]
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Recorta z/y al calado dado. Retorna (z_clipped, y_clipped)."""
|
|
z, y = self.z_positions, self.half_breadths
|
|
if draft is None:
|
|
return z, y
|
|
z_top = float(draft)
|
|
if z_top <= z[0]:
|
|
return np.array([z[0], z[0]]), np.array([0.0, 0.0])
|
|
if z_top < z[-1]:
|
|
y_top = float(np.interp(z_top, z, y))
|
|
mask = z < z_top
|
|
return (
|
|
np.concatenate([z[mask], [z_top]]),
|
|
np.concatenate([y[mask], [y_top]]),
|
|
)
|
|
return z, y
|
|
|
|
def area(self, draft: Optional[float] = None) -> float:
|
|
"""Área sumergida de la sección [m²] hasta *draft*.
|
|
|
|
Integra 2·y(z) dz (ambas bandas por simetría).
|
|
"""
|
|
z_int, y_int = self._clip_to_draft(draft)
|
|
if len(z_int) < 2:
|
|
return 0.0
|
|
if len(z_int) >= 3:
|
|
a = float(simpson(y_int, x=z_int))
|
|
else:
|
|
a = float(np.trapz(y_int, z_int))
|
|
return 2.0 * abs(a)
|
|
|
|
def centroid_z(self, draft: Optional[float] = None) -> float:
|
|
"""Centroide vertical de la sección sumergida [m desde quilla]."""
|
|
z_int, y_int = self._clip_to_draft(draft)
|
|
if len(z_int) < 2:
|
|
return float(self.z_positions[0])
|
|
if len(z_int) >= 3:
|
|
moment = float(simpson(y_int * z_int, x=z_int))
|
|
area = float(simpson(y_int, x=z_int))
|
|
else:
|
|
moment = float(np.trapz(y_int * z_int, z_int))
|
|
area = float(np.trapz(y_int, z_int))
|
|
if abs(area) < 1e-12:
|
|
return float(z_int[0])
|
|
return moment / area
|
|
|
|
def max_half_breadth(self, draft: Optional[float] = None) -> float:
|
|
"""Máxima semi-manga hasta *draft* [m]."""
|
|
z, y = self._clip_to_draft(draft)
|
|
return float(y.max()) if len(y) > 0 else 0.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Dunder
|
|
# ------------------------------------------------------------------
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"Section(station={self.station!r}, x={self.x:.3f} m, "
|
|
f"n_pts={len(self.z_positions)})"
|
|
)
|