axiolid_nurbs/certified_surface_arcs.rs
1//! Certified curved surface/surface analysis with explicit accounting.
2//!
3//! A whole-patch transversality bound cannot certify a curved pair: the
4//! surface normal sweeps, so its interval hull straddles the other normal
5//! and `|n1 x n2|` has lower bound zero. Affine patches escape this only
6//! because a constant normal makes the bound sharp.
7//!
8//! So transversality is certified PER CELL. Cells that cannot be certified
9//! are not dropped and not subdivided forever -- they are returned as
10//! located [`RegionKind::Tangential`] or [`RegionKind::BudgetExhausted`]
11//! regions, so a caller always learns which part of the domain is still
12//! unproven.
13//!
14//! The leaves tile the root parameter box exactly. See
15//! [`audit_coverage`] for the machine-checkable statement of that.
16
17use axiolid_contracts::{GeomError, GeomResult};
18use axiolid_core::Scalar;
19use axiolid_surface::BSplineSurface;
20
21use crate::certified_refinement::RefinementBudget;
22use crate::certified_surface_bezier::{piecewise_bezier_patches, Patch};
23use crate::certified_surface_surface_intersection::{
24 normal_cross_squared_lower_bound, patches_are_disjoint, SurfaceSurfaceParameterBox,
25};
26use crate::ParameterInterval;
27
28/// Largest subdivision depth the coverage audit can represent exactly.
29///
30/// Each subdivision splits all four parameter axes, so a leaf at depth `d`
31/// covers `16^-d` of its root box. The audit sums `16^(MAX_AUDIT_DEPTH - d)`
32/// as `u128`, needing `4 * MAX_AUDIT_DEPTH <= 127` to stay exact. A larger
33/// bound would silently saturate, and a saturated sum can mask a real gap.
34pub const MAX_AUDIT_DEPTH: u32 = 31;
35
36/// What was certified about one leaf region of the parameter domain.
37///
38/// Every leaf carries exactly one kind. A caller that treats
39/// [`Self::Transversal`] as "the answer" and ignores the rest is
40/// reading an incomplete result, which is why the unproven kinds carry
41/// their own located boxes rather than being folded into a count.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum RegionKind {
44 /// Proven to contain no intersection: the patch bounding boxes are
45 /// disjoint in at least one coordinate.
46 Empty,
47 /// Proven transversal: `|n1 x n2|` is bounded strictly away from zero,
48 /// so any intersection here is a regular curve, never a surface patch
49 /// or an isolated tangential touch.
50 Transversal,
51 /// Not proven transversal, and shrinking the cell will not help.
52 ///
53 /// The surfaces are tangent, near-tangent, or coincident somewhere in
54 /// this box. This is a located refusal, not a failure: the region is
55 /// returned so the caller can decide, refine by other means, or
56 /// report it. It is never silently discarded.
57 Tangential,
58 /// Subdivision stopped on policy (depth or work budget) before the
59 /// region could be classified either way.
60 ///
61 /// Distinct from [`Self::Tangential`]: that is a statement about the
62 /// geometry, this is a statement about the budget. Raising the budget
63 /// may resolve it; raising it cannot resolve a tangency.
64 BudgetExhausted,
65}
66
67/// One classified leaf of the certified subdivision.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct CertifiedRegion {
70 /// The parameter box this statement applies to.
71 pub box_: SurfaceSurfaceParameterBox,
72 /// What was proven about it.
73 pub kind: RegionKind,
74 /// Bisection depth, where the root pair is depth 0.
75 ///
76 /// Each step splits all four parameter axes, so a leaf at depth `d`
77 /// covers `2^-d` of the root box. The coverage audit relies on this.
78 pub depth: u32,
79 /// Certified lower bound on `|n1 x n2|^2` over the box.
80 ///
81 /// Strictly positive exactly when `kind` is [`RegionKind::Transversal`].
82 pub normal_separation_lower_bound: Scalar,
83}
84
85/// Result of certified curved surface/surface analysis.
86///
87/// Deliberately not an `Option`-like "answer or nothing": a curved pair
88/// is routinely *partly* provable, and collapsing that to a single
89/// verdict is what loses geometry. The caller gets the transversal
90/// regions and the unproven ones together, and can always ask whether
91/// the analysis was complete.
92#[derive(Debug, Clone, PartialEq)]
93pub struct CertifiedSurfaceArcs3 {
94 /// Every leaf, in deterministic order, tiling the root box exactly.
95 pub regions: Vec<CertifiedRegion>,
96 /// Patch pairs examined.
97 pub visited_patch_pairs: u32,
98 /// Deepest leaf produced.
99 pub max_depth_reached: u32,
100}
101
102impl CertifiedSurfaceArcs3 {
103 /// Whether every leaf was proven either empty or transversal.
104 ///
105 /// `false` means part of the domain is still unproven; the regions
106 /// say which part. Callers that need a total answer must branch on
107 /// this rather than assuming the arcs are exhaustive.
108 pub fn is_fully_certified(&self) -> bool {
109 self.regions
110 .iter()
111 .all(|region| matches!(region.kind, RegionKind::Empty | RegionKind::Transversal))
112 }
113
114 /// Regions that remain unproven, in deterministic order.
115 pub fn unproven(&self) -> impl Iterator<Item = &CertifiedRegion> {
116 self.regions.iter().filter(|region| {
117 matches!(
118 region.kind,
119 RegionKind::Tangential | RegionKind::BudgetExhausted
120 )
121 })
122 }
123}
124
125/// Why a set of regions failed to account for the whole domain.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum CoverageFault {
128 /// The leaves cover less than the root box: geometry was dropped.
129 ///
130 /// This is the failure that silently loses an intersection, so it is
131 /// reported as a fault rather than tolerated.
132 Gap,
133 /// The leaves cover more than the root box: a region was emitted twice
134 /// or a parent was kept alongside its children.
135 Overlap,
136 /// A leaf is deeper than the audit can represent exactly.
137 ///
138 /// Reported instead of silently saturating, because a saturated sum
139 /// could mask a real gap.
140 DepthOverflow,
141}
142
143/// Verify that the regions tile the root parameter box exactly.
144///
145/// # The invariant
146///
147/// Subdivision bisects all four parameter axes, so a leaf at depth `d`
148/// covers exactly `16^-d` of its root box -- a dyadic rational, never a
149/// rounded quantity. Summing `16^(MAX_AUDIT_DEPTH - d)` over the leaves
150/// gives exactly `16^MAX_AUDIT_DEPTH` per root box if and only if the
151/// leaves tile every root with no gap and no overlap.
152///
153/// The arithmetic is integer `u128` throughout: no tolerance, no
154/// accumulated float error, no judgement call. A dropped leaf makes the
155/// sum too small, a duplicated one makes it too large, and either way
156/// this returns a fault.
157///
158/// # Why this check outlives the code it checks
159///
160/// It constrains only *completeness*, never *content*. Future work may
161/// add refusal kinds, sharpen bounds, or change how tangency is handled;
162/// none of that is allowed to stop accounting for the domain. So this
163/// keeps catching the invisible bug class without needing a rewrite.
164pub fn audit_coverage(
165 regions: &[CertifiedRegion],
166 root_pair_count: u32,
167) -> Result<(), CoverageFault> {
168 let mut total: u128 = 0;
169 for region in regions {
170 if region.depth > MAX_AUDIT_DEPTH {
171 return Err(CoverageFault::DepthOverflow);
172 }
173 // 16^(MAX - depth), exact: each level splits all four axes.
174 total += 1u128 << (4 * (MAX_AUDIT_DEPTH - region.depth));
175 }
176 let per_root: u128 = 1u128 << (4 * MAX_AUDIT_DEPTH);
177 let expected = per_root * u128::from(root_pair_count);
178 match total.cmp(&expected) {
179 std::cmp::Ordering::Equal => Ok(()),
180 std::cmp::Ordering::Less => Err(CoverageFault::Gap),
181 std::cmp::Ordering::Greater => Err(CoverageFault::Overlap),
182 }
183}
184
185/// Explicit policy for certified curved analysis.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct CertifiedSurfaceArcsOptions {
188 max_depth: u32,
189}
190
191impl CertifiedSurfaceArcsOptions {
192 /// Validate and construct a policy.
193 ///
194 /// `max_depth` is capped at [`MAX_AUDIT_DEPTH`] so the coverage audit
195 /// stays exact by construction rather than by caller discipline.
196 pub fn new(max_depth: u32) -> GeomResult<Self> {
197 if max_depth == 0 || max_depth > MAX_AUDIT_DEPTH {
198 return Err(GeomError::InvalidInput(format!(
199 "certified arc max_depth must be in 1..={MAX_AUDIT_DEPTH}"
200 )));
201 }
202 Ok(Self { max_depth })
203 }
204}
205
206impl Default for CertifiedSurfaceArcsOptions {
207 fn default() -> Self {
208 Self { max_depth: 8 }
209 }
210}
211
212/// Certify the transversality structure of a curved surface pair.
213///
214/// Subdivides the parameter domain until each leaf is provably empty,
215/// provably transversal, or provably not worth subdividing further, and
216/// returns every leaf. Unlike the affine path this never collapses to a
217/// single verdict, because a curved pair is routinely part-provable and
218/// collapsing is what loses geometry.
219///
220/// The returned regions always tile the domain exactly; see
221/// [`audit_coverage`].
222pub fn certify_surface_arcs(
223 first: &BSplineSurface,
224 second: &BSplineSurface,
225 options: CertifiedSurfaceArcsOptions,
226) -> GeomResult<CertifiedSurfaceArcs3> {
227 let mut budget = RefinementBudget::new(1_000_000, "certified surface arc refinement");
228 let first_patches = piecewise_bezier_patches(first, &mut budget)?;
229 let second_patches = piecewise_bezier_patches(second, &mut budget)?;
230 let pair_count = first_patches
231 .len()
232 .checked_mul(second_patches.len())
233 .ok_or(GeomError::BudgetExceeded {
234 resource: "certified surface arc pair count",
235 })?;
236 let visited_patch_pairs = u32::try_from(pair_count).map_err(|_| GeomError::BudgetExceeded {
237 resource: "certified surface arc pair count",
238 })?;
239
240 let mut regions = Vec::new();
241 let mut max_depth_reached = 0;
242 for first_patch in &first_patches {
243 for second_patch in &second_patches {
244 classify_recursive(
245 first_patch,
246 second_patch,
247 0,
248 options.max_depth,
249 &mut regions,
250 &mut max_depth_reached,
251 )?;
252 }
253 }
254 Ok(CertifiedSurfaceArcs3 {
255 regions,
256 visited_patch_pairs,
257 max_depth_reached,
258 })
259}
260
261/// Classify one cell pair, subdividing only when that can settle it.
262///
263/// Every control-flow path emits exactly one leaf or recurses into four
264/// children. That is what makes exact coverage a property of the
265/// structure rather than something a test has to hope for.
266fn classify_recursive(
267 first: &Patch,
268 second: &Patch,
269 depth: u32,
270 max_depth: u32,
271 regions: &mut Vec<CertifiedRegion>,
272 max_depth_reached: &mut u32,
273) -> GeomResult<()> {
274 *max_depth_reached = (*max_depth_reached).max(depth);
275 let box_ = pair_box(first, second);
276
277 // Disjoint bounding boxes prove emptiness outright, at any depth.
278 if patches_are_disjoint(first, second)? {
279 push_region(regions, box_, RegionKind::Empty, depth, 0.0)?;
280 return Ok(());
281 }
282
283 // A strictly positive normal separation proves the intersection here
284 // is a regular curve. This is the same bound the affine path uses;
285 // only the scope changed, from whole-patch to per-cell.
286 let separation = normal_cross_squared_lower_bound(first, second)?;
287 if separation > 0.0 {
288 push_region(regions, box_, RegionKind::Transversal, depth, separation)?;
289 return Ok(());
290 }
291
292 if depth >= max_depth {
293 // Out of budget, not out of geometry. Distinguishing the two lets
294 // a caller tell "raise the budget" from "this will never resolve".
295 let kind = if is_persistently_tangential(first, second)? {
296 RegionKind::Tangential
297 } else {
298 RegionKind::BudgetExhausted
299 };
300 push_region(regions, box_, kind, depth, 0.0)?;
301 return Ok(());
302 }
303
304 let (first_children, second_children) = (split_patch(first)?, split_patch(second)?);
305 for first_child in &first_children {
306 for second_child in &second_children {
307 classify_recursive(
308 first_child,
309 second_child,
310 depth + 1,
311 max_depth,
312 regions,
313 max_depth_reached,
314 )?;
315 }
316 }
317 Ok(())
318}
319
320/// Whether a cell pair looks tangential rather than merely under-refined.
321///
322/// Probes the four quadrant children: if none of them recovers a positive
323/// normal separation, the obstruction is geometric (a tangency curve runs
324/// through the cell) rather than a matter of cell size. This is a
325/// heuristic for *labelling* an already-refused region, never a
326/// certificate -- both labels mean "unproven", and the distinction only
327/// tells the caller whether raising the budget could help.
328fn is_persistently_tangential(first: &Patch, second: &Patch) -> GeomResult<bool> {
329 let first_children = split_patch(first)?;
330 let second_children = split_patch(second)?;
331 for first_child in &first_children {
332 for second_child in &second_children {
333 if patches_are_disjoint(first_child, second_child)? {
334 continue;
335 }
336 if normal_cross_squared_lower_bound(first_child, second_child)? > 0.0 {
337 return Ok(false);
338 }
339 }
340 }
341 Ok(true)
342}
343
344/// Split a patch at its parameter midpoint in both directions.
345fn split_patch(patch: &Patch) -> GeomResult<[Patch; 4]> {
346 let u_mid = patch.u_start * 0.5 + patch.u_end * 0.5;
347 let v_mid = patch.v_start * 0.5 + patch.v_end * 0.5;
348 Ok([
349 patch.restrict(patch.u_start, u_mid, patch.v_start, v_mid)?,
350 patch.restrict(u_mid, patch.u_end, patch.v_start, v_mid)?,
351 patch.restrict(patch.u_start, u_mid, v_mid, patch.v_end)?,
352 patch.restrict(u_mid, patch.u_end, v_mid, patch.v_end)?,
353 ])
354}
355
356fn pair_box(first: &Patch, second: &Patch) -> SurfaceSurfaceParameterBox {
357 SurfaceSurfaceParameterBox {
358 first_u: ParameterInterval {
359 start: first.u_start,
360 end: first.u_end,
361 },
362 first_v: ParameterInterval {
363 start: first.v_start,
364 end: first.v_end,
365 },
366 second_u: ParameterInterval {
367 start: second.u_start,
368 end: second.u_end,
369 },
370 second_v: ParameterInterval {
371 start: second.v_start,
372 end: second.v_end,
373 },
374 }
375}
376
377fn push_region(
378 regions: &mut Vec<CertifiedRegion>,
379 box_: SurfaceSurfaceParameterBox,
380 kind: RegionKind,
381 depth: u32,
382 normal_separation_lower_bound: Scalar,
383) -> GeomResult<()> {
384 if regions.len() == regions.capacity() {
385 regions
386 .try_reserve(1)
387 .map_err(|_| GeomError::BudgetExceeded {
388 resource: "certified surface arc region allocation",
389 })?;
390 }
391 regions.push(CertifiedRegion {
392 box_,
393 kind,
394 depth,
395 normal_separation_lower_bound,
396 });
397 Ok(())
398}