Skip to main content

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