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:
@@ -1,2 +1,140 @@
|
||||
"""Curva NURBS. Stub — Sprint 1."""
|
||||
raise NotImplementedError("nurbs_curve — Sprint 1")
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -1,2 +1,184 @@
|
||||
"""Superficie NURBS geomdl. Stub — Sprint 1."""
|
||||
raise NotImplementedError("nurbs_surface — Sprint 1")
|
||||
"""
|
||||
LoftedSurface — superficie B-spline lofteada a partir de secciones transversales.
|
||||
|
||||
El espacio paramétrico es (u, v):
|
||||
u ∈ [0, 1]: dirección longitudinal (0 = AP, 1 = FP)
|
||||
v ∈ [0, 1]: dirección de cuaderna (0 = quilla, 1 = cubierta / línea de agua)
|
||||
|
||||
Autor: Álvaro Romero
|
||||
Sprint 1 — AR-ShipDesign
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
from .nurbs_curve import BSplineCurve
|
||||
|
||||
|
||||
class LoftedSurface:
|
||||
"""Superficie lofteada interpolando B-splines entre secciones transversales.
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
sections : sequence of (u_pos, points)
|
||||
Cada elemento es ``(u_pos, points)`` donde:
|
||||
- *u_pos* ∈ [0, 1]: posición longitudinal de la sección.
|
||||
- *points*: array (m, 2) con columnas [y_half_breadth, z_height].
|
||||
Los puntos deben estar ordenados de quilla (v=0) a cubierta (v=1).
|
||||
degree_u : int
|
||||
Grado B-spline en dirección longitudinal (por defecto 3).
|
||||
degree_v : int
|
||||
Grado B-spline en dirección de cuaderna (por defecto 3).
|
||||
n_v : int
|
||||
Número de puntos uniformes en v al que se reparametrizan todas las
|
||||
secciones antes de construir la superficie bivariate.
|
||||
|
||||
Notas
|
||||
-----
|
||||
Internamente se usa ``scipy.interpolate.RectBivariateSpline`` que exige
|
||||
una grilla rectangular (u_i, v_j). Para ello todas las secciones se
|
||||
evalúan en el mismo vector v uniforme con ``n_v`` puntos.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sections: Sequence[Tuple[float, np.ndarray]],
|
||||
degree_u: int = 3,
|
||||
degree_v: int = 3,
|
||||
n_v: int = 50,
|
||||
) -> None:
|
||||
if len(sections) < degree_u + 1:
|
||||
raise ValueError(
|
||||
f"Se necesitan al menos {degree_u + 1} secciones para grado_u={degree_u}"
|
||||
)
|
||||
|
||||
# Ordenar secciones por posición u
|
||||
sections = sorted(sections, key=lambda s: s[0])
|
||||
u_positions = np.array([s[0] for s in sections], dtype=float)
|
||||
if not np.all(np.diff(u_positions) > 0):
|
||||
raise ValueError("Las posiciones u de las secciones deben ser estrictamente crecientes")
|
||||
|
||||
self._u_positions = u_positions
|
||||
self._degree_u = degree_u
|
||||
self._degree_v = degree_v
|
||||
self._n_v = n_v
|
||||
self._v_grid = np.linspace(0.0, 1.0, n_v)
|
||||
|
||||
# Construir curvas individuales por sección y remuestrear en v_grid
|
||||
self._section_curves: list[BSplineCurve] = []
|
||||
y_grid = np.zeros((len(sections), n_v))
|
||||
z_grid = np.zeros((len(sections), n_v))
|
||||
|
||||
for i, (_, pts) in enumerate(sections):
|
||||
pts = np.asarray(pts, dtype=float)
|
||||
if pts.ndim != 2 or pts.shape[1] != 2:
|
||||
raise ValueError(
|
||||
f"La sección {i} debe ser array (m, 2) con columnas [y, z]"
|
||||
)
|
||||
k = min(degree_v, pts.shape[0] - 1)
|
||||
curve = BSplineCurve(pts, degree=k)
|
||||
self._section_curves.append(curve)
|
||||
sampled = curve.evaluate(self._v_grid) # (n_v, 2)
|
||||
y_grid[i, :] = sampled[:, 0] # half-breadth
|
||||
z_grid[i, :] = sampled[:, 1] # height
|
||||
|
||||
# Superficies bivariadas separadas para y y z
|
||||
self._spline_y = RectBivariateSpline(
|
||||
u_positions, self._v_grid, y_grid, kx=degree_u, ky=degree_v
|
||||
)
|
||||
self._spline_z = RectBivariateSpline(
|
||||
u_positions, self._v_grid, z_grid, kx=degree_u, ky=degree_v
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Evaluación
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self, u: float | np.ndarray, v: float | np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""Evalúa la superficie en (u, v).
|
||||
|
||||
Para escalares retorna array (2,) = [y, z].
|
||||
Para arrays 1-D de igual longitud retorna (n, 2).
|
||||
Para escalares cruzados con arrays retorna la malla completa (nu, nv, 2).
|
||||
"""
|
||||
u = np.asarray(u, dtype=float)
|
||||
v = np.asarray(v, dtype=float)
|
||||
scalar = u.ndim == 0 and v.ndim == 0
|
||||
|
||||
u = np.atleast_1d(np.clip(u, self._u_positions[0], self._u_positions[-1]))
|
||||
v = np.atleast_1d(np.clip(v, 0.0, 1.0))
|
||||
|
||||
y = self._spline_y(u, v) # (nu, nv)
|
||||
z = self._spline_z(u, v) # (nu, nv)
|
||||
|
||||
if scalar:
|
||||
return np.array([y[0, 0], z[0, 0]])
|
||||
|
||||
# Si u y v tienen la misma longitud → puntos pareados
|
||||
if u.shape == v.shape:
|
||||
pts = np.stack(
|
||||
[self._spline_y(u[i:i+1], v[i:i+1])[0, 0]
|
||||
for i in range(len(u))],
|
||||
)
|
||||
# reconstruir como (n, 2)
|
||||
n = len(u)
|
||||
result = np.empty((n, 2))
|
||||
for i in range(n):
|
||||
result[i, 0] = self._spline_y(
|
||||
np.array([u[i]]), np.array([v[i]])
|
||||
)[0, 0]
|
||||
result[i, 1] = self._spline_z(
|
||||
np.array([u[i]]), np.array([v[i]])
|
||||
)[0, 0]
|
||||
return result
|
||||
|
||||
# Caso general → malla (nu, nv, 2)
|
||||
return np.stack([y, z], axis=-1)
|
||||
|
||||
def section_at(self, u: float) -> BSplineCurve:
|
||||
"""Sección transversal (y, z) a la posición longitudinal *u* ∈ [0, 1]."""
|
||||
u_clip = float(np.clip(u, self._u_positions[0], self._u_positions[-1]))
|
||||
pts = np.column_stack([
|
||||
self._spline_y(np.array([u_clip]), self._v_grid)[0],
|
||||
self._spline_z(np.array([u_clip]), self._v_grid)[0],
|
||||
])
|
||||
k = min(self._degree_v, pts.shape[0] - 1)
|
||||
return BSplineCurve(pts, degree=k)
|
||||
|
||||
def waterplane(self, v: float = 1.0) -> BSplineCurve:
|
||||
"""Línea de agua a la posición de cuaderna *v* ∈ [0, 1].
|
||||
|
||||
v=1.0 → línea de agua en la cubierta / calado máximo.
|
||||
Retorna curva (u, y) en el plano horizontal.
|
||||
"""
|
||||
v_clip = float(np.clip(v, 0.0, 1.0))
|
||||
u_arr = np.linspace(
|
||||
self._u_positions[0], self._u_positions[-1], 200
|
||||
)
|
||||
y_arr = self._spline_y(u_arr, np.array([v_clip]))[:, 0]
|
||||
pts = np.column_stack([u_arr, y_arr])
|
||||
k = min(self._degree_u, pts.shape[0] - 1)
|
||||
return BSplineCurve(pts, degree=k)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Propiedades
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def u_range(self) -> tuple[float, float]:
|
||||
return float(self._u_positions[0]), float(self._u_positions[-1])
|
||||
|
||||
@property
|
||||
def n_sections(self) -> int:
|
||||
return len(self._section_curves)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"LoftedSurface(n_sections={self.n_sections}, "
|
||||
f"degree_u={self._degree_u}, degree_v={self._degree_v})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user