575 lines
21 KiB
Python
575 lines
21 KiB
Python
"""Math helpers for CAD-friendly curve parameterization."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import math as _math
|
|
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
|
|
|
|
PointTuple = Tuple[float, ...]
|
|
|
|
|
|
def _as_sample_array(
|
|
sample_points: Iterable[Sequence[float]], *, duplicate_tolerance: float
|
|
) -> np.ndarray:
|
|
if duplicate_tolerance < 0.0 or not _math.isfinite(float(duplicate_tolerance)):
|
|
raise ValueError("duplicate_tolerance must be a finite non-negative number")
|
|
|
|
raw_points = list(sample_points)
|
|
if not raw_points:
|
|
raise ValueError("sample_points must contain at least two distinct points")
|
|
|
|
converted: List[PointTuple] = []
|
|
dimension: Optional[int] = None
|
|
for index, point in enumerate(raw_points):
|
|
try:
|
|
values = tuple(float(value) for value in point)
|
|
except Exception as exc:
|
|
raise ValueError(f"sample point {index} is not a numeric sequence") from exc
|
|
if dimension is None:
|
|
dimension = len(values)
|
|
if dimension not in {2, 3}:
|
|
raise ValueError("sample points must be 2D or 3D")
|
|
elif len(values) != dimension:
|
|
raise ValueError("all sample points must have the same dimension")
|
|
if not all(_math.isfinite(value) for value in values):
|
|
raise ValueError("all sample point coordinates must be finite")
|
|
converted.append(values)
|
|
|
|
deduped: List[PointTuple] = []
|
|
for point in converted:
|
|
if not deduped:
|
|
deduped.append(point)
|
|
continue
|
|
previous = deduped[-1]
|
|
distance = _math.sqrt(sum((a - b) ** 2 for a, b in zip(point, previous)))
|
|
if distance <= duplicate_tolerance:
|
|
continue
|
|
deduped.append(point)
|
|
|
|
if len(deduped) < 2:
|
|
raise ValueError("sample_points must contain at least two distinct points")
|
|
return np.asarray(deduped, dtype=float)
|
|
|
|
|
|
def _chord_length_parameters(points: np.ndarray) -> np.ndarray:
|
|
deltas = np.linalg.norm(np.diff(points, axis=0), axis=1)
|
|
total = float(np.sum(deltas))
|
|
if total <= 0.0 or not _math.isfinite(total):
|
|
raise ValueError("sample_points must span a non-zero chord length")
|
|
parameters = np.concatenate(([0.0], np.cumsum(deltas) / total))
|
|
parameters[-1] = 1.0
|
|
return parameters
|
|
|
|
|
|
def _full_knot_vector(degree: int, interior_knots: Sequence[float]) -> Tuple[float, ...]:
|
|
return (
|
|
tuple(0.0 for _ in range(degree + 1))
|
|
+ tuple(float(knot) for knot in sorted(interior_knots))
|
|
+ tuple(1.0 for _ in range(degree + 1))
|
|
)
|
|
|
|
|
|
def _validate_interior_knots(interior_knots: Sequence[float], *, knot_tolerance: float) -> Tuple[float, ...]:
|
|
validated: List[float] = []
|
|
for knot in sorted(float(value) for value in interior_knots):
|
|
if not _math.isfinite(knot):
|
|
raise ValueError("adaptive knot values must be finite")
|
|
if knot <= knot_tolerance or knot >= 1.0 - knot_tolerance:
|
|
continue
|
|
if validated and abs(knot - validated[-1]) <= knot_tolerance:
|
|
continue
|
|
validated.append(knot)
|
|
return tuple(validated)
|
|
|
|
|
|
def _bspline_basis_row(parameter: float, degree: int, knots: Sequence[float]) -> np.ndarray:
|
|
control_count = len(knots) - degree - 1
|
|
if control_count <= 0:
|
|
raise ValueError("invalid knot vector/control count combination")
|
|
|
|
u = min(max(float(parameter), float(knots[degree])), float(knots[-degree - 1]))
|
|
if u >= float(knots[-degree - 1]):
|
|
row = np.zeros(control_count, dtype=float)
|
|
row[-1] = 1.0
|
|
return row
|
|
|
|
basis = np.zeros(len(knots) - 1, dtype=float)
|
|
for index in range(len(basis)):
|
|
if float(knots[index]) <= u < float(knots[index + 1]):
|
|
basis[index] = 1.0
|
|
|
|
active = basis
|
|
for current_degree in range(1, degree + 1):
|
|
next_basis = np.zeros(len(knots) - 1 - current_degree, dtype=float)
|
|
for index in range(len(next_basis)):
|
|
left_den = float(knots[index + current_degree] - knots[index])
|
|
right_den = float(knots[index + current_degree + 1] - knots[index + 1])
|
|
left = 0.0
|
|
right = 0.0
|
|
if left_den > 0.0:
|
|
left = (u - float(knots[index])) / left_den * active[index]
|
|
if right_den > 0.0:
|
|
right = (float(knots[index + current_degree + 1]) - u) / right_den * active[index + 1]
|
|
next_basis[index] = left + right
|
|
active = next_basis
|
|
return active[:control_count]
|
|
|
|
|
|
def _basis_matrix(parameters: Sequence[float], degree: int, knots: Sequence[float]) -> np.ndarray:
|
|
return np.vstack([_bspline_basis_row(float(parameter), degree, knots) for parameter in parameters])
|
|
|
|
|
|
def _straight_line_result(
|
|
points: np.ndarray,
|
|
parameters: np.ndarray,
|
|
*,
|
|
tolerance: float,
|
|
fairing: float,
|
|
) -> "BSplineFitResult":
|
|
start = points[0]
|
|
end = points[-1]
|
|
delta = end - start
|
|
control_points = np.vstack(
|
|
[
|
|
start,
|
|
start + delta / 3.0,
|
|
start + 2.0 * delta / 3.0,
|
|
end,
|
|
]
|
|
)
|
|
knots = _full_knot_vector(3, ())
|
|
fitted = _basis_matrix(parameters, 3, knots) @ control_points
|
|
errors = np.linalg.norm(fitted - points, axis=1)
|
|
return BSplineFitResult(
|
|
degree=3,
|
|
control_points=_array_to_points(control_points),
|
|
knots=tuple(float(knot) for knot in knots),
|
|
sample_parameters=tuple(float(value) for value in parameters),
|
|
max_error=float(np.max(errors)) if len(errors) else 0.0,
|
|
rms_error=float(np.sqrt(np.mean(errors**2))) if len(errors) else 0.0,
|
|
tolerance=float(tolerance),
|
|
fairing=float(fairing),
|
|
iterations=0,
|
|
converged=True,
|
|
)
|
|
|
|
|
|
def _array_to_points(values: np.ndarray) -> Tuple[PointTuple, ...]:
|
|
return tuple(tuple(float(component) for component in row) for row in values.tolist())
|
|
|
|
|
|
def _fit_for_knots(
|
|
points: np.ndarray,
|
|
parameters: np.ndarray,
|
|
*,
|
|
degree: int,
|
|
interior_knots: Sequence[float],
|
|
fairing: float,
|
|
tolerance: float,
|
|
iterations: int,
|
|
) -> "BSplineFitResult":
|
|
knots = _full_knot_vector(degree, interior_knots)
|
|
basis = _basis_matrix(parameters, degree, knots)
|
|
control_count = basis.shape[1]
|
|
dimension = points.shape[1]
|
|
|
|
controls = np.zeros((control_count, dimension), dtype=float)
|
|
controls[0, :] = points[0, :]
|
|
controls[-1, :] = points[-1, :]
|
|
|
|
unknown_indices = list(range(1, control_count - 1))
|
|
unknown_map = {control_index: index for index, control_index in enumerate(unknown_indices)}
|
|
fixed_rhs = (
|
|
np.outer(basis[:, 0], controls[0, :])
|
|
+ np.outer(basis[:, -1], controls[-1, :])
|
|
)
|
|
matrix = basis[:, unknown_indices]
|
|
rhs = points - fixed_rhs
|
|
|
|
if fairing > 0.0 and unknown_indices:
|
|
fairing_rows: List[np.ndarray] = []
|
|
fairing_rhs: List[np.ndarray] = []
|
|
scale = _math.sqrt(float(fairing))
|
|
for start_index in range(control_count - 2):
|
|
row = np.zeros(len(unknown_indices), dtype=float)
|
|
target = np.zeros(dimension, dtype=float)
|
|
for control_index, coefficient in (
|
|
(start_index, 1.0),
|
|
(start_index + 1, -2.0),
|
|
(start_index + 2, 1.0),
|
|
):
|
|
mapped = unknown_map.get(control_index)
|
|
if mapped is None:
|
|
target -= coefficient * controls[control_index, :]
|
|
else:
|
|
row[mapped] += coefficient
|
|
fairing_rows.append(row * scale)
|
|
fairing_rhs.append(target * scale)
|
|
if fairing_rows:
|
|
matrix = np.vstack((matrix, np.vstack(fairing_rows)))
|
|
rhs = np.vstack((rhs, np.vstack(fairing_rhs)))
|
|
|
|
if unknown_indices:
|
|
solved, *_unused = np.linalg.lstsq(matrix, rhs, rcond=None)
|
|
for control_index, solved_index in unknown_map.items():
|
|
controls[control_index, :] = solved[solved_index, :]
|
|
|
|
fitted = basis @ controls
|
|
errors = np.linalg.norm(fitted - points, axis=1)
|
|
max_error = float(np.max(errors)) if len(errors) else 0.0
|
|
rms_error = float(np.sqrt(np.mean(errors**2))) if len(errors) else 0.0
|
|
|
|
return BSplineFitResult(
|
|
degree=int(degree),
|
|
control_points=_array_to_points(controls),
|
|
knots=tuple(float(knot) for knot in knots),
|
|
sample_parameters=tuple(float(value) for value in parameters),
|
|
max_error=max_error,
|
|
rms_error=rms_error,
|
|
tolerance=float(tolerance),
|
|
fairing=float(fairing),
|
|
iterations=int(iterations),
|
|
converged=max_error <= float(tolerance),
|
|
)
|
|
|
|
|
|
def _insert_adaptive_knot(
|
|
interior_knots: Sequence[float],
|
|
parameters: np.ndarray,
|
|
errors: np.ndarray,
|
|
*,
|
|
knot_tolerance: float,
|
|
) -> Tuple[float, ...]:
|
|
current = _validate_interior_knots(interior_knots, knot_tolerance=knot_tolerance)
|
|
boundaries = (0.0,) + current + (1.0,)
|
|
|
|
best_span: Optional[Tuple[float, float, int, float]] = None
|
|
for left, right in zip(boundaries, boundaries[1:]):
|
|
if right - left <= 2.0 * knot_tolerance:
|
|
continue
|
|
mask = (parameters > left + knot_tolerance) & (parameters < right - knot_tolerance)
|
|
if not np.any(mask):
|
|
span_error = -1.0
|
|
local_index = -1
|
|
else:
|
|
masked_indices = np.nonzero(mask)[0]
|
|
local_offset = int(np.argmax(errors[masked_indices]))
|
|
local_index = int(masked_indices[local_offset])
|
|
span_error = float(errors[local_index])
|
|
if best_span is None or span_error > best_span[3] or (
|
|
span_error == best_span[3] and (right - left) > (best_span[1] - best_span[0])
|
|
):
|
|
best_span = (float(left), float(right), local_index, span_error)
|
|
|
|
if best_span is None:
|
|
return current
|
|
|
|
left, right, local_index, _span_error = best_span
|
|
if local_index >= 0:
|
|
candidate = float(parameters[local_index])
|
|
else:
|
|
candidate = 0.5 * (left + right)
|
|
candidate = min(max(candidate, left + knot_tolerance), right - knot_tolerance)
|
|
if any(abs(candidate - knot) <= knot_tolerance for knot in current):
|
|
candidate = 0.5 * (left + right)
|
|
if candidate <= knot_tolerance or candidate >= 1.0 - knot_tolerance:
|
|
candidate = 0.5 * (left + right)
|
|
if any(abs(candidate - knot) <= knot_tolerance for knot in current):
|
|
for left, right in sorted(
|
|
zip(boundaries, boundaries[1:]), key=lambda span: span[1] - span[0], reverse=True
|
|
):
|
|
if right - left > 2.0 * knot_tolerance:
|
|
candidate = 0.5 * (left + right)
|
|
break
|
|
return _validate_interior_knots((*current, candidate), knot_tolerance=knot_tolerance)
|
|
|
|
|
|
def _prune_knots(
|
|
points: np.ndarray,
|
|
parameters: np.ndarray,
|
|
interior_knots: Sequence[float],
|
|
*,
|
|
degree: int,
|
|
fairing: float,
|
|
tolerance: float,
|
|
knot_tolerance: float,
|
|
iterations: int,
|
|
) -> Tuple[Tuple[float, ...], "BSplineFitResult"]:
|
|
current = _validate_interior_knots(interior_knots, knot_tolerance=knot_tolerance)
|
|
current_result = _fit_for_knots(
|
|
points,
|
|
parameters,
|
|
degree=degree,
|
|
interior_knots=current,
|
|
fairing=fairing,
|
|
tolerance=tolerance,
|
|
iterations=iterations,
|
|
)
|
|
changed = True
|
|
while changed and current:
|
|
changed = False
|
|
best_candidate: Optional[Tuple[Tuple[float, ...], BSplineFitResult]] = None
|
|
for index in range(len(current)):
|
|
candidate = current[:index] + current[index + 1 :]
|
|
result = _fit_for_knots(
|
|
points,
|
|
parameters,
|
|
degree=degree,
|
|
interior_knots=candidate,
|
|
fairing=fairing,
|
|
tolerance=tolerance,
|
|
iterations=iterations,
|
|
)
|
|
if result.max_error <= tolerance:
|
|
if best_candidate is None or result.max_error < best_candidate[1].max_error:
|
|
best_candidate = (candidate, result)
|
|
if best_candidate is not None:
|
|
current, current_result = best_candidate
|
|
changed = True
|
|
return current, current_result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BSplineFitResult:
|
|
"""Result from fitting a cubic B-spline to sampled curve points.
|
|
|
|
The result stores a complete, normalized B-spline definition suitable for
|
|
passing into the exact B-spline edge/wire APIs: cubic degree, control
|
|
points, and a full clamped knot vector.
|
|
"""
|
|
|
|
degree: int
|
|
control_points: Tuple[PointTuple, ...]
|
|
knots: Tuple[float, ...]
|
|
sample_parameters: Tuple[float, ...]
|
|
max_error: float
|
|
rms_error: float
|
|
tolerance: float
|
|
fairing: float
|
|
iterations: int
|
|
converged: bool
|
|
|
|
@property
|
|
def control_count(self) -> int:
|
|
"""Number of fitted B-spline control points."""
|
|
|
|
return len(self.control_points)
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
"""Coordinate dimension of each fitted control point."""
|
|
|
|
return len(self.control_points[0]) if self.control_points else 0
|
|
|
|
@property
|
|
def unique_knots(self) -> Tuple[float, ...]:
|
|
"""Return knot values with repeated entries collapsed."""
|
|
|
|
unique: List[float] = []
|
|
for knot in self.knots:
|
|
if not unique or abs(float(knot) - unique[-1]) > 1e-12:
|
|
unique.append(float(knot))
|
|
return tuple(unique)
|
|
|
|
@property
|
|
def multiplicities(self) -> Tuple[int, ...]:
|
|
"""Return knot multiplicities aligned with `unique_knots`."""
|
|
|
|
if not self.knots:
|
|
return ()
|
|
multiplicities: List[int] = []
|
|
current = float(self.knots[0])
|
|
count = 0
|
|
for knot in self.knots:
|
|
value = float(knot)
|
|
if abs(value - current) <= 1e-12:
|
|
count += 1
|
|
continue
|
|
multiplicities.append(count)
|
|
current = value
|
|
count = 1
|
|
multiplicities.append(count)
|
|
return tuple(multiplicities)
|
|
|
|
def evaluate(self, parameter: float) -> PointTuple:
|
|
"""Evaluate the fitted B-spline at a normalized parameter in `[0, 1]`."""
|
|
|
|
controls = np.asarray(self.control_points, dtype=float)
|
|
row = _bspline_basis_row(float(parameter), int(self.degree), self.knots)
|
|
point = row @ controls
|
|
return tuple(float(component) for component in point.tolist())
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Return a JSON-serializable representation of the fit result."""
|
|
|
|
return {
|
|
"degree": int(self.degree),
|
|
"control_points": [list(point) for point in self.control_points],
|
|
"knots": list(self.knots),
|
|
"unique_knots": list(self.unique_knots),
|
|
"multiplicities": list(self.multiplicities),
|
|
"sample_parameters": list(self.sample_parameters),
|
|
"max_error": float(self.max_error),
|
|
"rms_error": float(self.rms_error),
|
|
"tolerance": float(self.tolerance),
|
|
"fairing": float(self.fairing),
|
|
"iterations": int(self.iterations),
|
|
"converged": bool(self.converged),
|
|
}
|
|
|
|
|
|
def fit_cubic_bspline_control_points(
|
|
sample_points: Sequence[Sequence[float]],
|
|
*,
|
|
tolerance: float = 1e-3,
|
|
max_control_points: Optional[int] = None,
|
|
fairing: float = 1e-6,
|
|
duplicate_tolerance: float = 1e-12,
|
|
knot_tolerance: float = 1e-9,
|
|
raise_on_failure: bool = True,
|
|
) -> BSplineFitResult:
|
|
"""Fit a minimal cubic B-spline control polygon to sampled curve points.
|
|
|
|
Uses chord-length parameterization, cubic clamped B-spline least squares,
|
|
second-difference fairing regularization, and adaptive simple knot insertion
|
|
until the maximum sample error is within `tolerance`. Only simple interior
|
|
knots are inserted, so a cubic result remains C2-continuous at every interior
|
|
knot.
|
|
|
|
Args:
|
|
sample_points: Ordered 2D or 3D points sampled along the intended curve.
|
|
Consecutive duplicate points within `duplicate_tolerance` are ignored.
|
|
tolerance: Maximum allowed Euclidean fitting error at the input samples.
|
|
max_control_points: Upper bound for fitted control points. Defaults to the
|
|
cleaned sample count, with a cubic minimum of four controls.
|
|
fairing: Non-negative second-difference regularization weight. Larger
|
|
values prefer smoother control polygons while still respecting the
|
|
error tolerance when possible.
|
|
duplicate_tolerance: Distance threshold for removing consecutive duplicate
|
|
sample points before chord-length parameterization.
|
|
knot_tolerance: Normalized parameter spacing threshold used to avoid
|
|
duplicate or near-boundary interior knots.
|
|
raise_on_failure: Raise `ValueError` when the tolerance cannot be reached
|
|
within `max_control_points`. If false, return the best non-converged
|
|
result instead.
|
|
|
|
Returns:
|
|
`BSplineFitResult` containing cubic degree, control points, a full clamped
|
|
knot vector, knot multiplicities, sample parameters, and fitting error.
|
|
|
|
Raises:
|
|
ValueError: If inputs are invalid, or if the tolerance cannot be met and
|
|
`raise_on_failure=True`.
|
|
|
|
Examples:
|
|
```python
|
|
from simplecadapi.math import fit_cubic_bspline_control_points
|
|
|
|
samples = [(0.0, 0.0, 0.0), (1.0, 0.4, 0.0), (2.0, 0.0, 0.0)]
|
|
fit = fit_cubic_bspline_control_points(samples, tolerance=0.01)
|
|
print(fit.control_points)
|
|
print(fit.knots, fit.multiplicities)
|
|
```
|
|
"""
|
|
|
|
tolerance = float(tolerance)
|
|
fairing = float(fairing)
|
|
knot_tolerance = float(knot_tolerance)
|
|
if tolerance <= 0.0 or not _math.isfinite(tolerance):
|
|
raise ValueError("tolerance must be a finite positive number")
|
|
if fairing < 0.0 or not _math.isfinite(fairing):
|
|
raise ValueError("fairing must be a finite non-negative number")
|
|
if knot_tolerance <= 0.0 or not _math.isfinite(knot_tolerance):
|
|
raise ValueError("knot_tolerance must be a finite positive number")
|
|
|
|
points = _as_sample_array(sample_points, duplicate_tolerance=float(duplicate_tolerance))
|
|
parameters = _chord_length_parameters(points)
|
|
degree = 3
|
|
|
|
if len(points) == 2:
|
|
result = _straight_line_result(
|
|
points,
|
|
parameters,
|
|
tolerance=tolerance,
|
|
fairing=fairing,
|
|
)
|
|
if result.max_error <= tolerance or not raise_on_failure:
|
|
return result
|
|
raise ValueError("failed to fit a straight cubic B-spline within tolerance")
|
|
|
|
default_max = max(degree + 1, len(points))
|
|
max_controls = default_max if max_control_points is None else int(max_control_points)
|
|
if max_controls < degree + 1:
|
|
raise ValueError("max_control_points must be at least 4 for a cubic B-spline")
|
|
|
|
interior_knots: Tuple[float, ...] = ()
|
|
best_result: Optional[BSplineFitResult] = None
|
|
max_insertions = max_controls - (degree + 1)
|
|
|
|
for iteration in range(max_insertions + 1):
|
|
result = _fit_for_knots(
|
|
points,
|
|
parameters,
|
|
degree=degree,
|
|
interior_knots=interior_knots,
|
|
fairing=fairing,
|
|
tolerance=tolerance,
|
|
iterations=iteration,
|
|
)
|
|
if best_result is None or result.max_error < best_result.max_error:
|
|
best_result = result
|
|
if result.max_error <= tolerance:
|
|
_pruned_knots, pruned_result = _prune_knots(
|
|
points,
|
|
parameters,
|
|
interior_knots,
|
|
degree=degree,
|
|
fairing=fairing,
|
|
tolerance=tolerance,
|
|
knot_tolerance=knot_tolerance,
|
|
iterations=iteration,
|
|
)
|
|
return pruned_result
|
|
if iteration >= max_insertions:
|
|
break
|
|
|
|
basis = _basis_matrix(parameters, degree, result.knots)
|
|
fitted = basis @ np.asarray(result.control_points, dtype=float)
|
|
errors = np.linalg.norm(fitted - points, axis=1)
|
|
next_knots = _insert_adaptive_knot(
|
|
interior_knots,
|
|
parameters,
|
|
errors,
|
|
knot_tolerance=knot_tolerance,
|
|
)
|
|
if len(next_knots) == len(interior_knots):
|
|
break
|
|
interior_knots = next_knots
|
|
|
|
assert best_result is not None
|
|
if not raise_on_failure:
|
|
return BSplineFitResult(
|
|
degree=best_result.degree,
|
|
control_points=best_result.control_points,
|
|
knots=best_result.knots,
|
|
sample_parameters=best_result.sample_parameters,
|
|
max_error=best_result.max_error,
|
|
rms_error=best_result.rms_error,
|
|
tolerance=best_result.tolerance,
|
|
fairing=best_result.fairing,
|
|
iterations=best_result.iterations,
|
|
converged=False,
|
|
)
|
|
raise ValueError(
|
|
"failed to fit a cubic B-spline within tolerance "
|
|
f"{tolerance:g}; best max_error={best_result.max_error:g} with "
|
|
f"{best_result.control_count} control points"
|
|
)
|
|
|
|
|
|
__all__ = ["BSplineFitResult", "fit_cubic_bspline_control_points"]
|