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