axiolid_nurbs/
periodic_surface.rs

1//! Explicit cyclic B-spline surface semantics.
2
3use axiolid_contracts::{GeomError, GeomResult};
4use axiolid_core::{Point3, Scalar};
5use axiolid_evaluate::surface::{bspline_jet, SurfaceJet};
6use axiolid_surface::BSplineSurface;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9struct Axis {
10    domain: (Scalar, Scalar),
11    degree: usize,
12    unique_count: usize,
13    periodic: bool,
14    seam_continuity_order: Option<u16>,
15}
16
17/// An owned tensor-product B-spline surface with validated cyclic axes.
18///
19/// A periodic axis uses the standard expanded periodic representation: the
20/// final `degree` control rows or columns exactly repeat the first `degree`,
21/// and the expanded knot vector repeats after one active period. Parameters on
22/// that axis are evaluated modulo the half-open active domain. The wrapped
23/// neutral [`BSplineSurface`] remains available for serialization and for
24/// algorithms that understand the same explicit representation.
25///
26/// Construction is additive: the neutral evaluator continues to clamp native
27/// parameters and does not infer cyclic behavior from closure metadata.
28#[derive(Debug, Clone, PartialEq)]
29pub struct PeriodicBSplineSurface {
30    surface: BSplineSurface,
31    u: Axis,
32    v: Axis,
33}
34
35impl PeriodicBSplineSurface {
36    /// Validate and own an explicitly periodic surface.
37    ///
38    /// At least one neutral closure flag must be set. Every declared periodic
39    /// axis must satisfy exact control/weight aliasing and periodic knot
40    /// extension invariants. Rational weights must be finite and positive.
41    pub fn new(surface: BSplineSurface) -> GeomResult<Self> {
42        if !surface.u_closed && !surface.v_closed {
43            return Err(invalid(
44                "PeriodicBSplineSurface requires at least one declared periodic axis",
45            ));
46        }
47        validate_periodic_multiplicities(
48            &surface.u_multiplicities,
49            surface.u_degree,
50            surface.u_closed,
51            "U",
52        )?;
53        validate_periodic_multiplicities(
54            &surface.v_multiplicities,
55            surface.v_degree,
56            surface.v_closed,
57            "V",
58        )?;
59        let (u_count, v_count) = validate_control_net(&surface)?;
60        let u_knots = expand_axis(
61            &surface.u_knots,
62            &surface.u_multiplicities,
63            surface.u_degree,
64            u_count,
65            "periodic surface U axis",
66        )?;
67        let v_knots = expand_axis(
68            &surface.v_knots,
69            &surface.v_multiplicities,
70            surface.v_degree,
71            v_count,
72            "periodic surface V axis",
73        )?;
74        let u = validate_axis(&u_knots, surface.u_degree, u_count, surface.u_closed, "U")?;
75        let v = validate_axis(&v_knots, surface.v_degree, v_count, surface.v_closed, "V")?;
76        validate_aliases(&surface, u, v)?;
77
78        let u_mid = midpoint(u.domain)?;
79        let v_mid = midpoint(v.domain)?;
80        bspline_jet(&surface, u_mid, v_mid)?;
81        Ok(Self { surface, u, v })
82    }
83
84    /// Borrow the canonical expanded neutral representation.
85    pub const fn as_bspline_surface(&self) -> &BSplineSurface {
86        &self.surface
87    }
88
89    /// Consume the capability type and return its canonical expanded value.
90    pub fn into_bspline_surface(self) -> BSplineSurface {
91        self.surface
92    }
93
94    /// Whether the U axis has explicit cyclic semantics.
95    pub const fn u_is_periodic(&self) -> bool {
96        self.u.periodic
97    }
98
99    /// Whether the V axis has explicit cyclic semantics.
100    pub const fn v_is_periodic(&self) -> bool {
101        self.v.periodic
102    }
103
104    /// Native active U domain; periodic evaluation treats it as half-open.
105    pub const fn u_domain(&self) -> (Scalar, Scalar) {
106        self.u.domain
107    }
108
109    /// Native active V domain; periodic evaluation treats it as half-open.
110    pub const fn v_domain(&self) -> (Scalar, Scalar) {
111        self.v.domain
112    }
113
114    /// Algebraic continuity order across the U seam, or `None` when U is open.
115    #[must_use]
116    pub const fn u_seam_continuity_order(&self) -> Option<u16> {
117        self.u.seam_continuity_order
118    }
119
120    /// Algebraic continuity order across the V seam, or `None` when V is open.
121    #[must_use]
122    pub const fn v_seam_continuity_order(&self) -> Option<u16> {
123        self.v.seam_continuity_order
124    }
125
126    /// Number of topologically unique U control rows.
127    pub const fn unique_u_control_count(&self) -> usize {
128        self.u.unique_count
129    }
130
131    /// Number of topologically unique V control columns.
132    pub const fn unique_v_control_count(&self) -> usize {
133        self.v.unique_count
134    }
135
136    /// Canonicalize finite parameters on periodic axes.
137    ///
138    /// Non-periodic parameters are left unchanged and retain the neutral
139    /// evaluator's established clamping behavior. Periodic offsets whose
140    /// binary64 spacing cannot resolve one complete period are refused rather
141    /// than reduced modulo an under-resolved quotient coordinate.
142    pub fn wrap_parameters(&self, u: Scalar, v: Scalar) -> GeomResult<(Scalar, Scalar)> {
143        Ok((wrap_axis(u, self.u)?, wrap_axis(v, self.v)?))
144    }
145
146    /// Evaluate a point after periodic parameter canonicalization.
147    pub fn point(&self, u: Scalar, v: Scalar) -> GeomResult<Point3> {
148        Ok(self.jet(u, v)?.point)
149    }
150
151    /// Evaluate the full second-order jet after periodic canonicalization.
152    pub fn jet(&self, u: Scalar, v: Scalar) -> GeomResult<SurfaceJet> {
153        let (u, v) = self.wrap_parameters(u, v)?;
154        bspline_jet(&self.surface, u, v)
155    }
156
157    /// Replace one topologically unique control point and all seam aliases.
158    ///
159    /// Indices address the unique cyclic net, never the duplicated expansion.
160    /// Doubly periodic corner aliases are updated atomically with the primary
161    /// control. Knot topology and rational weights are unchanged.
162    pub fn set_control_point(&mut self, u: usize, v: usize, point: Point3) -> GeomResult<()> {
163        if !point.is_finite() {
164            return Err(invalid("periodic surface control point must be finite"));
165        }
166        let u_aliases = aliases(u, self.u, "U")?;
167        let v_aliases = aliases(v, self.v, "V")?;
168        for &row in &u_aliases {
169            for &column in &v_aliases {
170                self.surface.control_points[row][column] = point;
171            }
172        }
173        Ok(())
174    }
175
176    /// Replace a control point using signed cyclic indices.
177    ///
178    /// Periodic-axis indices wrap modulo the topologically unique control count,
179    /// so `-1` and `unique_count - 1` edit the same seam-adjacent control. Indices
180    /// on non-periodic axes remain strict.
181    pub fn set_control_point_wrapped(&mut self, u: i64, v: i64, point: Point3) -> GeomResult<()> {
182        let u = wrapped_control_index(u, self.u, "U")?;
183        let v = wrapped_control_index(v, self.v, "V")?;
184        self.set_control_point(u, v, point)
185    }
186
187    /// Replace one topologically unique rational weight and all seam aliases.
188    ///
189    /// Polynomial surfaces reject this operation. The replacement must remain
190    /// finite and strictly positive so rational certification stays well posed.
191    pub fn set_weight(&mut self, u: usize, v: usize, weight: Scalar) -> GeomResult<()> {
192        if !weight.is_finite() || weight <= 0.0 {
193            return Err(invalid(
194                "periodic surface weight must be finite and positive",
195            ));
196        }
197        let u_aliases = aliases(u, self.u, "U")?;
198        let v_aliases = aliases(v, self.v, "V")?;
199        let weights = self
200            .surface
201            .weights
202            .as_mut()
203            .ok_or_else(|| invalid("polynomial periodic surface has no rational weights"))?;
204        for &row in &u_aliases {
205            for &column in &v_aliases {
206                weights[row][column] = weight;
207            }
208        }
209        Ok(())
210    }
211
212    /// Replace a rational weight using signed cyclic indices.
213    pub fn set_weight_wrapped(&mut self, u: i64, v: i64, weight: Scalar) -> GeomResult<()> {
214        let u = wrapped_control_index(u, self.u, "U")?;
215        let v = wrapped_control_index(v, self.v, "V")?;
216        self.set_weight(u, v, weight)
217    }
218}
219
220fn wrapped_control_index(index: i64, axis: Axis, label: &str) -> GeomResult<usize> {
221    if axis.periodic {
222        let count = i64::try_from(axis.unique_count)
223            .map_err(|_| invalid(&format!("{label} unique control count does not fit i64")))?;
224        return usize::try_from(index.rem_euclid(count))
225            .map_err(|_| invalid(&format!("{label} wrapped control index does not fit usize")));
226    }
227    let index = usize::try_from(index)
228        .map_err(|_| invalid(&format!("{label} control index is negative")))?;
229    if index >= axis.unique_count {
230        return Err(invalid(&format!(
231            "{label} control index {index} is outside 0..{}",
232            axis.unique_count
233        )));
234    }
235    Ok(index)
236}
237
238fn validate_periodic_multiplicities(
239    multiplicities: &[u32],
240    degree: u16,
241    periodic: bool,
242    label: &str,
243) -> GeomResult<()> {
244    if periodic
245        && multiplicities
246            .iter()
247            .any(|&value| value == 0 || value > u32::from(degree))
248    {
249        return Err(invalid(&format!(
250            "periodic surface {label} multiplicities must be in 1..={degree}"
251        )));
252    }
253    Ok(())
254}
255
256fn validate_control_net(surface: &BSplineSurface) -> GeomResult<(usize, usize)> {
257    let u_count = surface.control_points.len();
258    let v_count = surface.control_points.first().map_or(0, Vec::len);
259    if u_count == 0 || v_count == 0 {
260        return Err(invalid("periodic surface control net must be nonempty"));
261    }
262    if surface
263        .control_points
264        .iter()
265        .any(|row| row.len() != v_count)
266    {
267        return Err(invalid("periodic surface control net must be rectangular"));
268    }
269    if surface
270        .control_points
271        .iter()
272        .flatten()
273        .any(|point| !point.is_finite())
274    {
275        return Err(invalid("periodic surface control points must be finite"));
276    }
277    if let Some(weights) = &surface.weights {
278        if weights.len() != u_count || weights.iter().any(|row| row.len() != v_count) {
279            return Err(invalid(
280                "periodic surface weight net must match the control net",
281            ));
282        }
283        if weights
284            .iter()
285            .flatten()
286            .any(|weight| !weight.is_finite() || *weight <= 0.0)
287        {
288            return Err(invalid(
289                "periodic surface weights must be finite and positive",
290            ));
291        }
292    }
293    Ok((u_count, v_count))
294}
295
296fn expand_axis(
297    knots: &[Scalar],
298    multiplicities: &[u32],
299    degree: u16,
300    count: usize,
301    label: &str,
302) -> GeomResult<Vec<Scalar>> {
303    if knots.len() != multiplicities.len() || knots.len() < 2 {
304        return Err(invalid(&format!(
305            "{label} compact knot data is inconsistent"
306        )));
307    }
308    if knots.iter().any(|knot| !knot.is_finite()) || knots.windows(2).any(|pair| pair[1] <= pair[0])
309    {
310        return Err(invalid(&format!(
311            "{label} knots must be finite and strictly increasing"
312        )));
313    }
314    let degree = usize::from(degree);
315    if degree == 0 || count <= degree {
316        return Err(invalid(&format!("{label} degree/control count is invalid")));
317    }
318    let expected = count
319        .checked_add(degree)
320        .and_then(|value| value.checked_add(1))
321        .ok_or_else(|| invalid(&format!("{label} size overflows usize")))?;
322    let maximum = degree
323        .checked_add(1)
324        .ok_or_else(|| invalid(&format!("{label} degree overflows usize")))?;
325    let mut total = 0usize;
326    for &multiplicity in multiplicities {
327        let multiplicity = usize::try_from(multiplicity)
328            .map_err(|_| invalid(&format!("{label} multiplicity does not fit usize")))?;
329        if multiplicity == 0 || multiplicity > maximum {
330            return Err(invalid(&format!(
331                "{label} multiplicity is outside 1..={maximum}"
332            )));
333        }
334        total = total
335            .checked_add(multiplicity)
336            .ok_or_else(|| invalid(&format!("{label} multiplicity sum overflows usize")))?;
337        if total > expected {
338            return Err(invalid(&format!("{label} has too many expanded knots")));
339        }
340    }
341    if total != expected {
342        return Err(invalid(&format!(
343            "{label} has {total} expanded knots, expected {expected}"
344        )));
345    }
346    let mut expanded = Vec::new();
347    expanded
348        .try_reserve_exact(expected)
349        .map_err(|_| GeomError::BudgetExceeded {
350            resource: "periodic surface knot expansion",
351        })?;
352    for (&knot, &multiplicity) in knots.iter().zip(multiplicities) {
353        let multiplicity = usize::try_from(multiplicity)
354            .map_err(|_| invalid(&format!("{label} multiplicity overflows usize")))?;
355        expanded.extend(core::iter::repeat_n(knot, multiplicity));
356    }
357    Ok(expanded)
358}
359
360fn validate_axis(
361    knots: &[Scalar],
362    degree: u16,
363    count: usize,
364    periodic: bool,
365    name: &str,
366) -> GeomResult<Axis> {
367    let degree = usize::from(degree);
368    let start = knots[degree];
369    let end = knots[count];
370    if start >= end {
371        return Err(invalid(&format!(
372            "periodic surface {name} domain must be finite and positive"
373        )));
374    }
375    let unique_count = if periodic {
376        let period = exact_scalar_subtract(end, start).ok_or_else(|| {
377            invalid(&format!(
378                "periodic surface {name} period is not an exact binary64 difference"
379            ))
380        })?;
381        let unique = count
382            .checked_sub(degree)
383            .ok_or_else(|| invalid(&format!("periodic surface {name} has no unique controls")))?;
384        if unique <= degree {
385            return Err(invalid(&format!(
386                "periodic surface {name} requires more unique controls than its degree"
387            )));
388        }
389        let extension = degree
390            .checked_mul(2)
391            .ok_or_else(|| invalid(&format!("periodic surface {name} degree overflows usize")))?;
392        for index in 0..=extension {
393            let shifted = unique
394                .checked_add(index)
395                .ok_or_else(|| invalid(&format!("periodic surface {name} knot index overflows")))?;
396            if exact_scalar_subtract(knots[shifted], knots[index]) != Some(period)
397                || exact_scalar_add(knots[index], period) != Some(knots[shifted])
398            {
399                return Err(invalid(&format!(
400                    "periodic surface {name} knot extension is not an exact binary64 translation"
401                )));
402            }
403        }
404        for offset in 0..degree {
405            let prefix_source = unique
406                .checked_sub(degree)
407                .and_then(|index| index.checked_add(offset))
408                .ok_or_else(|| {
409                    invalid(&format!("periodic surface {name} prefix index overflows"))
410                })?;
411            let suffix_source = degree
412                .checked_mul(2)
413                .and_then(|index| index.checked_add(1))
414                .and_then(|index| index.checked_add(offset))
415                .ok_or_else(|| {
416                    invalid(&format!("periodic surface {name} suffix index overflows"))
417                })?;
418            if exact_scalar_subtract(knots[prefix_source], period).is_none()
419                || exact_scalar_add(knots[suffix_source], period).is_none()
420            {
421                return Err(invalid(&format!(
422                    "periodic surface {name} outer knot extension is not exact in binary64"
423                )));
424            }
425        }
426        unique
427    } else {
428        count
429    };
430    let seam_continuity_order =
431        if periodic {
432            let seam_multiplicity = knots.iter().filter(|&&knot| knot == start).count();
433            let continuity = degree.checked_sub(seam_multiplicity).ok_or_else(|| {
434                invalid(&format!(
435                    "periodic surface {name} seam multiplicity exceeds degree"
436                ))
437            })?;
438            Some(u16::try_from(continuity).map_err(|_| {
439                invalid(&format!("periodic surface {name} continuity overflows u16"))
440            })?)
441        } else {
442            None
443        };
444    Ok(Axis {
445        domain: (start, end),
446        degree,
447        unique_count,
448        periodic,
449        seam_continuity_order,
450    })
451}
452
453fn validate_aliases(surface: &BSplineSurface, u: Axis, v: Axis) -> GeomResult<()> {
454    if u.periodic {
455        for offset in 0..u.degree {
456            let duplicate = u.unique_count + offset;
457            if surface.control_points[offset] != surface.control_points[duplicate] {
458                return Err(invalid(
459                    "periodic surface U control rows do not exactly repeat",
460                ));
461            }
462            if let Some(weights) = &surface.weights {
463                if weights[offset] != weights[duplicate] {
464                    return Err(invalid(
465                        "periodic surface U weight rows do not exactly repeat",
466                    ));
467                }
468            }
469        }
470    }
471    if v.periodic {
472        for row in 0..surface.control_points.len() {
473            for offset in 0..v.degree {
474                let duplicate = v.unique_count + offset;
475                if surface.control_points[row][offset] != surface.control_points[row][duplicate] {
476                    return Err(invalid(
477                        "periodic surface V control columns do not exactly repeat",
478                    ));
479                }
480                if let Some(weights) = &surface.weights {
481                    if weights[row][offset] != weights[row][duplicate] {
482                        return Err(invalid(
483                            "periodic surface V weight columns do not exactly repeat",
484                        ));
485                    }
486                }
487            }
488        }
489    }
490    Ok(())
491}
492
493fn aliases(index: usize, axis: Axis, name: &str) -> GeomResult<Vec<usize>> {
494    if index >= axis.unique_count {
495        return Err(invalid(&format!(
496            "periodic surface {name} control index is outside the unique net"
497        )));
498    }
499    let mut result = Vec::new();
500    result
501        .try_reserve_exact(2)
502        .map_err(|_| GeomError::BudgetExceeded {
503            resource: "periodic surface control aliases",
504        })?;
505    result.push(index);
506    if axis.periodic && index < axis.degree {
507        result.push(axis.unique_count + index);
508    }
509    Ok(result)
510}
511
512fn wrap_axis(parameter: Scalar, axis: Axis) -> GeomResult<Scalar> {
513    if !parameter.is_finite() {
514        return Err(invalid("periodic surface parameter must be finite"));
515    }
516    if !axis.periodic {
517        return Ok(parameter);
518    }
519    let (start, end) = axis.domain;
520    if parameter >= start && parameter < end {
521        return Ok(parameter);
522    }
523    let period = exact_scalar_subtract(end, start)
524        .ok_or_else(|| invalid("periodic surface period is not exact in binary64"))?;
525    let offset = parameter - start;
526    if !offset.is_finite() {
527        return Err(invalid(
528            "periodic surface parameter offset exceeds finite arithmetic",
529        ));
530    }
531    let spacing = binary64_spacing(offset).ok_or_else(|| {
532        invalid("periodic surface parameter offset has no finite binary64 spacing")
533    })?;
534    if spacing >= period {
535        return Err(invalid(
536            "periodic surface parameter offset cannot resolve one period",
537        ));
538    }
539    let wrapped = start + offset.rem_euclid(period);
540    if !wrapped.is_finite() || wrapped < start || wrapped >= end {
541        return Err(invalid("periodic surface parameter could not be wrapped"));
542    }
543    Ok(wrapped)
544}
545
546fn binary64_spacing(value: Scalar) -> Option<Scalar> {
547    let magnitude = value.abs();
548    let next_bits = magnitude.to_bits().checked_add(1)?;
549    let next = Scalar::from_bits(next_bits);
550    let spacing = next - magnitude;
551    (spacing.is_finite() && spacing > 0.0).then_some(spacing)
552}
553
554fn midpoint((start, end): (Scalar, Scalar)) -> GeomResult<Scalar> {
555    let midpoint = start + (end - start) * 0.5;
556    if midpoint.is_finite() {
557        Ok(midpoint)
558    } else {
559        Err(invalid("periodic surface domain midpoint is non-finite"))
560    }
561}
562
563pub(crate) fn exact_scalar_add(left: Scalar, right: Scalar) -> Option<Scalar> {
564    let sum = left + right;
565    if !sum.is_finite() {
566        return None;
567    }
568    // Knuth TwoSum: `error` is the exact residual of the rounded binary64 sum.
569    let right_virtual = sum - left;
570    let error = (left - (sum - right_virtual)) + (right - right_virtual);
571    (error == 0.0).then_some(sum)
572}
573
574pub(crate) fn exact_scalar_subtract(left: Scalar, right: Scalar) -> Option<Scalar> {
575    exact_scalar_add(left, -right)
576}
577
578fn invalid(message: &str) -> GeomError {
579    GeomError::InvalidInput(message.to_owned())
580}