axiolid_nurbs/
periodic.rs

1//! Verified periodic parameter semantics for closed NURBS curves.
2//!
3//! A `closed` flag is metadata, not proof of periodicity. Wrapping a
4//! parameter goes through a view whose constructor has checked the seam
5//! (at least positional continuity), never through the flag alone.
6
7use crate::{
8    axis::active_spans,
9    transform::{insert_knot2, insert_knot3, split2, split3},
10};
11use axiolid_contracts::{GeomError, GeomResult};
12use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec2, Vec3};
13use axiolid_curve::{BSplineCurve2, BSplineCurve3};
14use axiolid_evaluate::curve::{bspline_jet2, bspline_jet3, CurveJet};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17#[non_exhaustive]
18/// Highest endpoint continuity verified in the curve's native parameter.
19pub enum SeamContinuity {
20    /// Endpoint positions differ beyond tolerance.
21    Discontinuous,
22    /// Endpoint positions agree, but first derivatives do not.
23    Position,
24    /// Positions and first derivatives agree, but second derivatives do not.
25    FirstDerivative,
26    /// Positions and first two derivatives agree.
27    SecondDerivative,
28}
29
30/// Verified closed-seam extension of a planar B-spline curve.
31///
32/// This view does not reinterpret the curve as an algebraically periodic knot
33/// vector. It validates the declared geometric seam and wraps only through the
34/// explicit methods below. The borrowed neutral curve remains unchanged.
35#[derive(Debug, Clone, Copy)]
36pub struct PeriodicCurve2<'a> {
37    curve: &'a BSplineCurve2,
38    tolerance: Tolerance,
39    domain: (Scalar, Scalar),
40    continuity: SeamContinuity,
41}
42
43impl<'a> PeriodicCurve2<'a> {
44    /// Validate `closed` metadata and at least positional seam continuity.
45    pub fn try_new(curve: &'a BSplineCurve2, tolerance: Tolerance) -> GeomResult<Self> {
46        let domain = domain(
47            curve.degree,
48            curve.control_points.len(),
49            &curve.knots,
50            &curve.multiplicities,
51        )?;
52        let continuity = curve2_seam_continuity(curve, tolerance)?;
53        require_verified_seam(curve.closed, continuity)?;
54        Ok(Self {
55            curve,
56            tolerance,
57            domain,
58            continuity,
59        })
60    }
61
62    /// Native active parameter domain retained by this view.
63    pub const fn domain(self) -> (Scalar, Scalar) {
64        self.domain
65    }
66
67    /// Highest endpoint continuity verified during construction.
68    pub const fn seam_continuity(self) -> SeamContinuity {
69        self.continuity
70    }
71
72    /// Wrap a finite parameter into the native active domain.
73    ///
74    /// An in-domain upper endpoint remains the upper endpoint so one-sided
75    /// endpoint jets retain the neutral evaluator's existing semantics.
76    pub fn wrap_parameter(self, parameter: Scalar) -> GeomResult<Scalar> {
77        wrap(parameter, self.domain)
78    }
79
80    /// Evaluate the closed-seam extension at any finite parameter.
81    pub fn evaluate(self, parameter: Scalar) -> GeomResult<Point2> {
82        Ok(self.jet(parameter)?.point)
83    }
84
85    /// Evaluate point, first derivative, and second derivative after wrapping.
86    pub fn jet(self, parameter: Scalar) -> GeomResult<CurveJet<Point2, Vec2>> {
87        bspline_jet2(self.curve, self.wrap_parameter(parameter)?)
88    }
89
90    /// Insert a shape-preserving knot at a periodic-equivalent interior parameter.
91    ///
92    /// Exterior parameters are canonicalized. Parameters equivalent to either
93    /// seam endpoint are rejected because clamped endpoint multiplicity cannot
94    /// be increased as an interior edit.
95    pub fn insert_knot(self, parameter: Scalar) -> GeomResult<BSplineCurve2> {
96        let native = interior_periodic_parameter(parameter, self.domain)?;
97        let edited = insert_knot2(self.curve, native)?;
98        PeriodicCurve2::try_new(&edited, self.tolerance)?;
99        Ok(edited)
100    }
101
102    /// Split at a periodic-equivalent interior parameter into two open curves.
103    ///
104    /// The operation cuts the cycle; neither output retains `closed` metadata.
105    pub fn split_at(self, parameter: Scalar) -> GeomResult<(BSplineCurve2, BSplineCurve2)> {
106        split2(
107            self.curve,
108            interior_periodic_parameter(parameter, self.domain)?,
109        )
110    }
111}
112
113/// Verified closed-seam extension of a spatial B-spline curve.
114///
115/// As with [`PeriodicCurve2`], this is an explicit evaluator/editing view over
116/// the existing clamped representation, not an inferred periodic knot schema.
117#[derive(Debug, Clone, Copy)]
118pub struct PeriodicCurve3<'a> {
119    curve: &'a BSplineCurve3,
120    tolerance: Tolerance,
121    domain: (Scalar, Scalar),
122    continuity: SeamContinuity,
123}
124
125impl<'a> PeriodicCurve3<'a> {
126    /// Validate `closed` metadata and at least positional seam continuity.
127    pub fn try_new(curve: &'a BSplineCurve3, tolerance: Tolerance) -> GeomResult<Self> {
128        let domain = domain(
129            curve.degree,
130            curve.control_points.len(),
131            &curve.knots,
132            &curve.multiplicities,
133        )?;
134        let continuity = curve3_seam_continuity(curve, tolerance)?;
135        require_verified_seam(curve.closed, continuity)?;
136        Ok(Self {
137            curve,
138            tolerance,
139            domain,
140            continuity,
141        })
142    }
143
144    /// Native active parameter domain retained by this view.
145    pub const fn domain(self) -> (Scalar, Scalar) {
146        self.domain
147    }
148
149    /// Highest endpoint continuity verified during construction.
150    pub const fn seam_continuity(self) -> SeamContinuity {
151        self.continuity
152    }
153
154    /// Wrap a finite parameter into the native active domain.
155    pub fn wrap_parameter(self, parameter: Scalar) -> GeomResult<Scalar> {
156        wrap(parameter, self.domain)
157    }
158
159    /// Evaluate the closed-seam extension at any finite parameter.
160    pub fn evaluate(self, parameter: Scalar) -> GeomResult<Point3> {
161        Ok(self.jet(parameter)?.point)
162    }
163
164    /// Evaluate point, first derivative, and second derivative after wrapping.
165    pub fn jet(self, parameter: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
166        bspline_jet3(self.curve, self.wrap_parameter(parameter)?)
167    }
168
169    /// Insert a shape-preserving knot at a periodic-equivalent interior parameter.
170    pub fn insert_knot(self, parameter: Scalar) -> GeomResult<BSplineCurve3> {
171        let native = interior_periodic_parameter(parameter, self.domain)?;
172        let edited = insert_knot3(self.curve, native)?;
173        PeriodicCurve3::try_new(&edited, self.tolerance)?;
174        Ok(edited)
175    }
176
177    /// Split at a periodic-equivalent interior parameter into two open curves.
178    pub fn split_at(self, parameter: Scalar) -> GeomResult<(BSplineCurve3, BSplineCurve3)> {
179        split3(
180            self.curve,
181            interior_periodic_parameter(parameter, self.domain)?,
182        )
183    }
184}
185
186fn require_verified_seam(closed: bool, continuity: SeamContinuity) -> GeomResult<()> {
187    if !closed || continuity < SeamContinuity::Position {
188        return Err(GeomError::InvalidInput(
189            "curve is not a verified closed seam".to_owned(),
190        ));
191    }
192    Ok(())
193}
194
195fn interior_periodic_parameter(parameter: Scalar, domain: (Scalar, Scalar)) -> GeomResult<Scalar> {
196    let native = wrap(parameter, domain)?;
197    if native <= domain.0 || native >= domain.1 {
198        return Err(GeomError::InvalidInput(
199            "periodic edit parameter must not be seam-equivalent".to_owned(),
200        ));
201    }
202    Ok(native)
203}
204
205/// Verify native-parameter endpoint continuity of a planar B-spline curve.
206pub fn curve2_seam_continuity(
207    curve: &BSplineCurve2,
208    tolerance: Tolerance,
209) -> GeomResult<SeamContinuity> {
210    let (lo, hi) = domain(
211        curve.degree,
212        curve.control_points.len(),
213        &curve.knots,
214        &curve.multiplicities,
215    )?;
216    let a = bspline_jet2(curve, lo)?;
217    let b = bspline_jet2(curve, hi)?;
218    Ok(classify(a, b, tolerance))
219}
220
221/// Verify native-parameter endpoint continuity of a spatial B-spline curve.
222pub fn curve3_seam_continuity(
223    curve: &BSplineCurve3,
224    tolerance: Tolerance,
225) -> GeomResult<SeamContinuity> {
226    let (lo, hi) = domain(
227        curve.degree,
228        curve.control_points.len(),
229        &curve.knots,
230        &curve.multiplicities,
231    )?;
232    let a = bspline_jet3(curve, lo)?;
233    let b = bspline_jet3(curve, hi)?;
234    Ok(classify(a, b, tolerance))
235}
236
237/// Wrap a finite planar-curve parameter into its active domain.
238///
239/// Wrapping is rejected unless `closed` is set and position continuity is
240/// independently verified. An in-domain upper endpoint remains the upper
241/// endpoint rather than being remapped to the lower endpoint.
242pub fn wrap_curve2_parameter(
243    curve: &BSplineCurve2,
244    parameter: Scalar,
245    tolerance: Tolerance,
246) -> GeomResult<Scalar> {
247    if !curve.closed || curve2_seam_continuity(curve, tolerance)? < SeamContinuity::Position {
248        return Err(GeomError::InvalidInput(
249            "curve is not a verified closed seam".to_owned(),
250        ));
251    }
252    wrap(
253        parameter,
254        domain(
255            curve.degree,
256            curve.control_points.len(),
257            &curve.knots,
258            &curve.multiplicities,
259        )?,
260    )
261}
262
263/// Wrap a finite spatial-curve parameter into its active domain.
264///
265/// The same verified-closed precondition as [`wrap_curve2_parameter`] applies.
266pub fn wrap_curve3_parameter(
267    curve: &BSplineCurve3,
268    parameter: Scalar,
269    tolerance: Tolerance,
270) -> GeomResult<Scalar> {
271    if !curve.closed || curve3_seam_continuity(curve, tolerance)? < SeamContinuity::Position {
272        return Err(GeomError::InvalidInput(
273            "curve is not a verified closed seam".to_owned(),
274        ));
275    }
276    wrap(
277        parameter,
278        domain(
279            curve.degree,
280            curve.control_points.len(),
281            &curve.knots,
282            &curve.multiplicities,
283        )?,
284    )
285}
286
287fn classify<P, D>(a: CurveJet<P, D>, b: CurveJet<P, D>, tolerance: Tolerance) -> SeamContinuity
288where
289    P: Copy + core::ops::Sub<P, Output = D>,
290    D: Copy + core::ops::Sub<D, Output = D> + Length,
291{
292    if (a.point - b.point).length() > tolerance.linear() {
293        return SeamContinuity::Discontinuous;
294    }
295    if !close_vector(a.first, b.first, tolerance) {
296        return SeamContinuity::Position;
297    }
298    if !close_vector(a.second, b.second, tolerance) {
299        return SeamContinuity::FirstDerivative;
300    }
301    SeamContinuity::SecondDerivative
302}
303
304fn close_vector<D: Copy + core::ops::Sub<D, Output = D> + Length>(
305    a: D,
306    b: D,
307    tolerance: Tolerance,
308) -> bool {
309    let scale = a.length().max(b.length()).max(1.0);
310    (a - b).length() <= tolerance.linear() + tolerance.angular() * scale
311}
312
313trait Length {
314    fn length(self) -> Scalar;
315}
316impl Length for axiolid_core::Vec2 {
317    fn length(self) -> Scalar {
318        self.length()
319    }
320}
321impl Length for axiolid_core::Vec3 {
322    fn length(self) -> Scalar {
323        self.length()
324    }
325}
326
327fn domain(
328    degree: u16,
329    count: usize,
330    knots: &[Scalar],
331    multiplicities: &[u32],
332) -> GeomResult<(Scalar, Scalar)> {
333    let spans = active_spans(knots, multiplicities, degree, count)?;
334    Ok((spans[0].0, spans[spans.len() - 1].1))
335}
336
337fn wrap(parameter: Scalar, (lo, hi): (Scalar, Scalar)) -> GeomResult<Scalar> {
338    if !parameter.is_finite() {
339        return Err(GeomError::InvalidInput(
340            "parameter must be finite".to_owned(),
341        ));
342    }
343    let period = hi - lo;
344    if !period.is_finite() || period <= 0.0 {
345        return Err(GeomError::InvalidInput(
346            "periodic domain must have finite positive length".to_owned(),
347        ));
348    }
349    if parameter >= lo && parameter <= hi {
350        return Ok(parameter);
351    }
352    let offset = parameter - lo;
353    if !offset.is_finite() {
354        return Err(GeomError::InvalidInput(
355            "parameter offset exceeds finite periodic arithmetic".to_owned(),
356        ));
357    }
358    let wrapped = lo + offset.rem_euclid(period);
359    if !wrapped.is_finite() || wrapped < lo || wrapped >= hi {
360        return Err(GeomError::InvalidInput(
361            "parameter could not be wrapped into the periodic domain".to_owned(),
362        ));
363    }
364    Ok(wrapped)
365}