axiolid_nurbs/
certified_surface_inversion.rs

1//! Globally certified closest-point inversion for B-spline surfaces (#7).
2//!
3//! # Why inversion is not projection
4//!
5//! `project_surface_certified` answers "how far is this point from the
6//! surface". Inversion answers "which parameters name this point", and that
7//! is a strictly stronger claim: it requires the answer to be UNIQUE.
8//!
9//! A projection certificate retains EVERY box that could hold a global
10//! minimizer. On a sphere-like patch a pole point is equidistant from a whole
11//! parameter circle, so many boxes survive and no single (u, v) names the
12//! point. Returning one of them would be an arbitrary choice dressed up as a
13//! certified answer. This module therefore refuses unless the surviving
14//! cover is a single connected box: ambiguity is reported, never resolved by
15//! picking a representative.
16//!
17//! Inversion also asserts the point lies ON the surface. That is a separate
18//! obligation from uniqueness, checked against the certified distance LOWER
19//! bound so a miss cannot be absorbed by a loose representative evaluation.
20
21use axiolid_contracts::GeomResult;
22use axiolid_core::{Point3, Scalar};
23use axiolid_surface::BSplineSurface;
24
25use crate::certified_projection::{
26    CertifiedSurfaceProjection3, CertifiedSurfaceProjectionOptions, ParameterInterval,
27    SurfaceParameterBox, SurfaceProjectionCertificate3, SurfaceProjectionUnresolvedReason,
28};
29use crate::certified_surface_projection::{
30    project_periodic_surface_certified, project_surface_certified,
31};
32use crate::periodic_surface::PeriodicBSplineSurface;
33
34/// Why a sound inversion query did not yield unique parameters.
35#[non_exhaustive]
36#[derive(Debug, Clone, PartialEq)]
37pub enum SurfaceInversionRefusal {
38    /// The point is off the surface by more than the linear tolerance.
39    ///
40    /// Reported with the certified LOWER bound, so this is a proof of
41    /// separation rather than one representative evaluation missing.
42    OffSurface {
43        /// Certified lower bound on the distance to the whole surface.
44        distance_lower_bound: Scalar,
45    },
46    /// Several disjoint parameter regions attain the same minimum distance.
47    ///
48    /// A pole, a seam, or a self-touching patch. The retained cover is
49    /// returned so callers can inspect the ambiguity instead of guessing.
50    Ambiguous {
51        /// Every closed box that may contain a global minimizer.
52        candidates: Vec<SurfaceParameterBox>,
53    },
54    /// The search stayed sound but could not resolve to the requested
55    /// accuracy within the configured budget.
56    Unresolved {
57        /// Exact reason the underlying projection stopped.
58        reason: SurfaceProjectionUnresolvedReason,
59    },
60}
61
62/// Unique native parameters proven to name a point on the surface.
63#[derive(Debug, Clone, PartialEq)]
64pub struct SurfaceInversionCertificate3 {
65    /// Native U parameter of the unique closest point.
66    pub u: Scalar,
67    /// Native V parameter of the unique closest point.
68    pub v: Scalar,
69    /// The single retained box proven to contain every global minimizer.
70    pub enclosure: SurfaceParameterBox,
71    /// Certified upper bound on how far the point is from the surface.
72    ///
73    /// Retained rather than discarded: an on-surface claim is only as good
74    /// as the residual it was accepted with.
75    pub residual_upper_bound: Scalar,
76    /// Full underlying global projection certificate.
77    pub projection: SurfaceProjectionCertificate3,
78}
79
80/// Globally invert a point against a clamped B-spline surface.
81///
82/// Returns `Ok(Ok(..))` only when the point is proven on the surface AND the
83/// minimizer cover proves the parameters are unique.
84///
85/// A structurally sound refusal is `Ok(Err(..))`, not `Err`: an ambiguous or
86/// off-surface point is a real answer about the geometry. `Err` is reserved
87/// for invalid input and exhausted budgets.
88pub fn invert_surface_certified(
89    surface: &BSplineSurface,
90    point: Point3,
91    options: CertifiedSurfaceProjectionOptions,
92) -> GeomResult<Result<SurfaceInversionCertificate3, SurfaceInversionRefusal>> {
93    decide(project_surface_certified(surface, point, options)?, options)
94}
95
96/// Globally invert a point against one canonical period of a cyclic surface.
97///
98/// The seam is searched as part of the quotient domain, so a point exactly ON
99/// the seam yields one enclosure rather than two rival endpoint boxes.
100pub fn invert_periodic_surface_certified(
101    surface: &PeriodicBSplineSurface,
102    point: Point3,
103    options: CertifiedSurfaceProjectionOptions,
104) -> GeomResult<Result<SurfaceInversionCertificate3, SurfaceInversionRefusal>> {
105    decide(
106        project_periodic_surface_certified(surface, point, options)?,
107        options,
108    )
109}
110
111/// Turn a global projection certificate into an inversion verdict.
112///
113/// Order matters. Uniqueness is checked BEFORE the on-surface residual: a
114/// point at a pole is ambiguous whether or not it lies on the surface, and
115/// reporting it as merely "off surface" would hide the structural defect.
116fn decide(
117    outcome: CertifiedSurfaceProjection3,
118    options: CertifiedSurfaceProjectionOptions,
119) -> GeomResult<Result<SurfaceInversionCertificate3, SurfaceInversionRefusal>> {
120    let certificate = match outcome {
121        CertifiedSurfaceProjection3::Complete(certificate) => certificate,
122        // An unresolved projection has sound bounds but has NOT proven the
123        // cover is final. Uniqueness cannot be concluded from it -- but a
124        // multi-component cover already PROVES ambiguity, and refining
125        // further can only split boxes, never reconnect them. A pole must
126        // therefore be named as ambiguous, not excused as slow.
127        CertifiedSurfaceProjection3::Unresolved {
128            certificate,
129            reason,
130        } => {
131            // A wide or disconnected cover already PROVES the answer is not
132            // unique. Further refinement can only split boxes, never shrink
133            // the span of the whole region, so this verdict is final.
134            if unique_enclosure(&certificate.possible_minimizer_boxes, options).is_none() {
135                return Ok(Err(SurfaceInversionRefusal::Ambiguous {
136                    candidates: certificate.possible_minimizer_boxes,
137                }));
138            }
139            return Ok(Err(SurfaceInversionRefusal::Unresolved { reason }));
140        }
141    };
142
143    // On-surface FIRST. The search legitimately stops once the distance
144    // gap is met, which for a far-away point leaves a wide parameter box;
145    // judging uniqueness first would then mislabel a plainly off-surface
146    // point as 'ambiguous' and hide the real reason.
147    //
148    // The verdict uses the certified LOWER bound: the representative
149    // distance is one lucky evaluation and could accept a point the
150    // surface provably never reaches.
151    let linear = options.distance_tolerance().linear();
152    if certificate.distance_lower_bound > linear {
153        return Ok(Err(SurfaceInversionRefusal::OffSurface {
154            distance_lower_bound: certificate.distance_lower_bound,
155        }));
156    }
157
158    // Uniqueness needs BOTH connectedness and localization, so it is one
159    // shared predicate: subdivision splits a genuine minimizer across
160    // adjacent cells (so counting boxes is wrong), while a pole yields a
161    // single CONNECTED strip spanning the whole domain (so connectivity
162    // alone is not enough either).
163    let Some(enclosure) = unique_enclosure(&certificate.possible_minimizer_boxes, options) else {
164        return Ok(Err(SurfaceInversionRefusal::Ambiguous {
165            candidates: certificate.possible_minimizer_boxes,
166        }));
167    };
168
169    Ok(Ok(SurfaceInversionCertificate3 {
170        u: certificate.u,
171        v: certificate.v,
172        enclosure,
173        residual_upper_bound: certificate.distance_upper_bound,
174        projection: certificate,
175    }))
176}
177
178/// The single connected, localized region owning every global minimizer.
179///
180/// Returns `None` when the cover has two separated components (rival
181/// minimizers) OR when the single component is wider than the parameter
182/// tolerance (a pole, whose whole family ties).
183///
184/// Connectivity is computed by PAIRWISE touching, then transitively closed.
185/// Growing a running hull instead would be unsound in the dangerous
186/// direction: a hull spans the gap between two disjoint regions, so a third
187/// box touching only that empty span would fuse rival minimizers and let an
188/// ambiguous point be reported as uniquely invertible.
189///
190/// A leftover box therefore means two separated minimizer regions really
191/// exist, and the point has no unique inverse.
192fn unique_enclosure(
193    boxes: &[SurfaceParameterBox],
194    options: CertifiedSurfaceProjectionOptions,
195) -> Option<SurfaceParameterBox> {
196    let enclosure = single_component(boxes)?;
197    let parameter = options.parameter_tolerance();
198    // Each retained cell is already within the parameter tolerance, and a
199    // point can lie on at most ONE cell boundary per axis, so a genuinely
200    // unique minimizer spans at most two cells per axis. A pole spans the
201    // whole family and is far wider, so this bound separates the two.
202    let span = 2.0 * parameter;
203    if width(enclosure.u) > span || width(enclosure.v) > span {
204        return None;
205    }
206    Some(enclosure)
207}
208
209fn single_component(boxes: &[SurfaceParameterBox]) -> Option<SurfaceParameterBox> {
210    let (first, rest) = boxes.split_first()?;
211    let mut component = vec![*first];
212    let mut remaining: Vec<SurfaceParameterBox> = rest.to_vec();
213    let mut cursor = 0;
214    while cursor < component.len() {
215        let current = component[cursor];
216        remaining.retain(|candidate| {
217            if boxes_touch(&current, candidate) {
218                component.push(*candidate);
219                return false;
220            }
221            true
222        });
223        cursor += 1;
224    }
225    // Anything unreachable from the first box is a separate minimizer.
226    if !remaining.is_empty() {
227        return None;
228    }
229    component
230        .into_iter()
231        .reduce(|left, right| SurfaceParameterBox {
232            u: hull(left.u, right.u),
233            v: hull(left.v, right.v),
234        })
235}
236
237/// Two closed boxes share at least a boundary point.
238fn boxes_touch(left: &SurfaceParameterBox, right: &SurfaceParameterBox) -> bool {
239    intervals_touch(left.u, right.u) && intervals_touch(left.v, right.v)
240}
241
242fn intervals_touch(left: ParameterInterval, right: ParameterInterval) -> bool {
243    left.start <= right.end && right.start <= left.end
244}
245
246fn hull(left: ParameterInterval, right: ParameterInterval) -> ParameterInterval {
247    ParameterInterval {
248        start: left.start.min(right.start),
249        end: left.end.max(right.end),
250    }
251}
252
253fn width(interval: ParameterInterval) -> Scalar {
254    (interval.end - interval.start).max(0.0)
255}