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}