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