Files
AR-Shipdesign/arshipdesign/core/section.py
T
alro65 503e00bfc9 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>
2026-05-27 01:07:35 -04:00

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)})"
)