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>
141 lines
4.5 KiB
Python
141 lines
4.5 KiB
Python
"""
|
|
BSplineCurve — curva B-spline paramétrica basada en scipy.interpolate.
|
|
|
|
Autor: Álvaro Romero
|
|
Sprint 1 — AR-ShipDesign
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
from scipy.interpolate import make_interp_spline
|
|
from scipy.integrate import quad
|
|
|
|
|
|
class BSplineCurve:
|
|
"""Curva B-spline interpolada a partir de puntos de datos.
|
|
|
|
Parámetros
|
|
----------
|
|
points : array-like, shape (n, 2) o (n, 3)
|
|
Puntos de datos por los que pasa la curva (interpolación exacta).
|
|
degree : int, optional
|
|
Grado del B-spline (por defecto 3 = cúbico).
|
|
knots : array-like or None
|
|
Vector de nudos externo. Si es None se genera automáticamente
|
|
con método cuerda-longitud (no uniforme, más estable).
|
|
|
|
Notas
|
|
-----
|
|
El parámetro interno *t* varía en [0, 1].
|
|
La curva se construye usando ``scipy.interpolate.make_interp_spline``.
|
|
Para n puntos y grado k se necesita n >= k+1.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
points: np.ndarray,
|
|
degree: int = 3,
|
|
*,
|
|
knots: np.ndarray | None = None,
|
|
) -> None:
|
|
pts = np.asarray(points, dtype=float)
|
|
if pts.ndim != 2 or pts.shape[1] not in (2, 3):
|
|
raise ValueError("points debe ser array (n, 2) o (n, 3)")
|
|
if pts.shape[0] < degree + 1:
|
|
raise ValueError(
|
|
f"Se necesitan al menos {degree + 1} puntos para grado {degree}"
|
|
)
|
|
|
|
self._pts = pts
|
|
self._k = degree
|
|
self._dim = pts.shape[1]
|
|
|
|
# Parámetro interno por longitud de cuerda normalizada
|
|
self._t_param = _chord_length_parameterization(pts)
|
|
|
|
if knots is not None:
|
|
self._spl = make_interp_spline(
|
|
self._t_param, pts, k=degree, t=knots
|
|
)
|
|
else:
|
|
self._spl = make_interp_spline(self._t_param, pts, k=degree)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Evaluación
|
|
# ------------------------------------------------------------------
|
|
|
|
def evaluate(self, t: float | np.ndarray) -> np.ndarray:
|
|
"""Evalúa la curva en el parámetro *t* ∈ [0, 1].
|
|
|
|
Retorna array (dim,) para t escalar, o (m, dim) para t array.
|
|
"""
|
|
t = np.asarray(t, dtype=float)
|
|
scalar = t.ndim == 0
|
|
t = np.atleast_1d(np.clip(t, 0.0, 1.0))
|
|
result = self._spl(t)
|
|
return result[0] if scalar else result
|
|
|
|
def sample(self, n: int = 100) -> np.ndarray:
|
|
"""Muestrea *n* puntos uniformes en t ∈ [0, 1].
|
|
|
|
Retorna array (n, dim).
|
|
"""
|
|
t = np.linspace(0.0, 1.0, max(2, n))
|
|
return self.evaluate(t)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Métricas
|
|
# ------------------------------------------------------------------
|
|
|
|
def arc_length(self, t0: float = 0.0, t1: float = 1.0) -> float:
|
|
"""Longitud de arco numérica entre los parámetros *t0* y *t1*."""
|
|
dspl = self._spl.derivative()
|
|
|
|
def integrand(t: float) -> float:
|
|
return float(np.linalg.norm(dspl(float(t))))
|
|
|
|
length, _ = quad(integrand, t0, t1, limit=100)
|
|
return float(length)
|
|
|
|
def tangent(self, t: float) -> np.ndarray:
|
|
"""Vector tangente unitario en el parámetro *t*."""
|
|
d = self._spl.derivative()(float(np.clip(t, 0.0, 1.0)))
|
|
norm = np.linalg.norm(d)
|
|
return d / norm if norm > 1e-12 else np.zeros(self._dim)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Propiedades
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def degree(self) -> int:
|
|
return self._k
|
|
|
|
@property
|
|
def dim(self) -> int:
|
|
return self._dim
|
|
|
|
@property
|
|
def control_points(self) -> np.ndarray:
|
|
"""Puntos de datos originales."""
|
|
return self._pts.copy()
|
|
|
|
def __repr__(self) -> str:
|
|
n = self._pts.shape[0]
|
|
return f"BSplineCurve(degree={self._k}, dim={self._dim}, n_points={n})"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _chord_length_parameterization(pts: np.ndarray) -> np.ndarray:
|
|
"""Parametrización por longitud de cuerda normalizada a [0, 1]."""
|
|
diffs = np.diff(pts, axis=0)
|
|
chord_lens = np.linalg.norm(diffs, axis=1)
|
|
cumulative = np.concatenate([[0.0], np.cumsum(chord_lens)])
|
|
total = cumulative[-1]
|
|
if total < 1e-12:
|
|
return np.linspace(0.0, 1.0, len(pts))
|
|
return cumulative / total
|