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(¤t, 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}