Files
cadSet/SimpleCADAPI/src/simplecadapi/math.py
T
2026-07-22 19:38:36 +08:00

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