axiolid_nurbs/
certified_surface_projection.rs

1use std::cmp::Ordering;
2
3use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
4use axiolid_core::{Point3, Scalar};
5use axiolid_evaluate::surface::bspline_jet;
6use axiolid_surface::BSplineSurface;
7
8use crate::{
9    certified_bezier::{
10        distance_to_box_lower, distance_to_point_interval_upper, next_up, representative_distance,
11    },
12    certified_projection::{
13        CertifiedSurfaceProjection3, CertifiedSurfaceProjectionOptions, ParameterInterval,
14        SurfaceParameterBox, SurfaceProjectionCertificate3, SurfaceProjectionUnresolvedReason,
15    },
16    certified_refinement::RefinementBudget,
17    certified_surface_bezier::{
18        piecewise_bezier_patches, piecewise_periodic_bezier_patches, Patch,
19    },
20    PeriodicBSplineSurface,
21};
22
23const DIMENSIONS: usize = 3;
24
25#[derive(Debug, Clone, Copy)]
26struct Pending {
27    patch_index: usize,
28    domain: SurfaceParameterBox,
29    lower: Scalar,
30    depth: u16,
31    serial: u32,
32}
33
34#[derive(Debug, Clone, Copy)]
35struct Candidate {
36    u: Scalar,
37    v: Scalar,
38    point: Point3,
39    distance: Scalar,
40    upper: Scalar,
41}
42
43#[derive(Debug, Clone, Copy)]
44enum SplitAxis {
45    U,
46    V,
47}
48
49/// Exhaustively bound the global point-to-surface minimum over the closed
50/// native domain of a finite clamped NURBS surface.
51///
52/// This first certified slice rejects closed/periodic axes because closed
53/// metadata alone does not define a verified periodic control-net topology.
54/// `Complete` proves both the requested global distance gap and the requested
55/// native U/V widths for every retained minimizer box. Depth/no-progress
56/// termination returns a sound `Unresolved`; shared work or allocation
57/// exhaustion returns `GeomError::BudgetExceeded`.
58pub fn project_surface_certified(
59    surface: &BSplineSurface,
60    target: Point3,
61    options: CertifiedSurfaceProjectionOptions,
62) -> GeomResult<CertifiedSurfaceProjection3> {
63    validate_query(surface, target)?;
64    project_surface_certified_with_modes(surface, target, options, false, false)
65}
66
67/// Exhaustively bound the global point-to-surface minimum over one canonical
68/// period of an explicitly validated cyclic B-spline surface.
69///
70/// Periodic axes are searched over their complete native period, including the
71/// seam-equivalent endpoints. The returned minimizer boxes therefore form a
72/// sound cover on the quotient domain; callers can canonicalize witness
73/// parameters with [`PeriodicBSplineSurface::wrap_parameters`].
74pub fn project_periodic_surface_certified(
75    surface: &PeriodicBSplineSurface,
76    target: Point3,
77    options: CertifiedSurfaceProjectionOptions,
78) -> GeomResult<CertifiedSurfaceProjection3> {
79    validate_target(target)?;
80    project_surface_certified_with_modes(
81        surface.as_bspline_surface(),
82        target,
83        options,
84        surface.u_is_periodic(),
85        surface.v_is_periodic(),
86    )
87}
88
89fn project_surface_certified_with_modes(
90    surface: &BSplineSurface,
91    target: Point3,
92    options: CertifiedSurfaceProjectionOptions,
93    u_periodic: bool,
94    v_periodic: bool,
95) -> GeomResult<CertifiedSurfaceProjection3> {
96    let target_array = target.to_array();
97    let mut budget = RefinementBudget::new(options.max_work(), "certified surface projection");
98    let patches = if u_periodic || v_periodic {
99        piecewise_periodic_bezier_patches(surface, u_periodic, v_periodic, &mut budget)?
100    } else {
101        piecewise_bezier_patches(surface, &mut budget)?
102    };
103    if patches.is_empty() {
104        return Err(GeomError::InvalidInput(
105            "certified surface projection requires at least one Bezier patch".to_owned(),
106        ));
107    }
108
109    let root_work = u128::try_from(patches.len()).map_err(|_| search_overflow())?;
110    budget.charge(Some(root_work))?;
111    let mut pending = Vec::new();
112    pending
113        .try_reserve_exact(patches.len())
114        .map_err(|_| allocation_error())?;
115    let mut candidate = None;
116    let mut visited_nodes = 0_u32;
117    let mut serial = 0_u32;
118
119    for (patch_index, patch) in patches.iter().enumerate() {
120        let domain = patch_domain(patch);
121        budget.charge(Some(patch.representative_bound_work()?))?;
122        let lower = patch_lower(patch, target_array)?;
123        update_candidate(
124            &mut candidate,
125            sample(surface, patch, domain, target_array)?,
126        );
127        pending.push(Pending {
128            patch_index,
129            domain,
130            lower,
131            depth: 0,
132            serial,
133        });
134        serial = serial.checked_add(1).ok_or_else(search_overflow)?;
135        visited_nodes = visited_nodes.checked_add(1).ok_or_else(search_overflow)?;
136    }
137
138    let mut candidate = candidate.ok_or_else(|| {
139        GeomError::Degenerate("surface projection could not construct an upper witness".to_owned())
140    })?;
141
142    loop {
143        pending.retain(|record| can_contain_global_minimizer(record.lower, candidate.upper));
144        if pending.is_empty() {
145            return Err(GeomError::Degenerate(
146                "outward surface projection bounds excluded every candidate".to_owned(),
147            ));
148        }
149
150        let lower = global_lower(&pending)?;
151        let parameter_ready = pending.iter().try_fold(true, |ready, record| {
152            Ok::<_, GeomError>(
153                ready
154                    && interval_width(record.domain.u)? <= options.parameter_tolerance()
155                    && interval_width(record.domain.v)? <= options.parameter_tolerance(),
156            )
157        })?;
158        let gap = certified_gap(candidate.upper, lower)?;
159        if gap <= options.distance_tolerance().linear() && parameter_ready {
160            let boxes = take_sorted_boxes(pending)?;
161            return Ok(CertifiedSurfaceProjection3::Complete(certificate(
162                candidate,
163                lower,
164                boxes,
165                visited_nodes,
166            )));
167        }
168
169        let selected = select_refinable(
170            &pending,
171            options.max_depth(),
172            gap > options.distance_tolerance().linear(),
173        )?;
174        let Some(selected_index) = selected else {
175            let reason = if pending
176                .iter()
177                .any(|record| record.depth >= options.max_depth())
178            {
179                SurfaceProjectionUnresolvedReason::DepthLimit
180            } else {
181                SurfaceProjectionUnresolvedReason::FloatingPointNoProgress
182            };
183            let boxes = take_sorted_boxes(pending)?;
184            return Ok(CertifiedSurfaceProjection3::Unresolved {
185                certificate: certificate(candidate, lower, boxes, visited_nodes),
186                reason,
187            });
188        };
189
190        let record = pending.swap_remove(selected_index);
191        let (axis, midpoint) = split_choice(record.domain)?.ok_or_else(|| {
192            GeomError::Degenerate("selected surface parameter box cannot advance".to_owned())
193        })?;
194        let child_depth = record.depth.checked_add(1).ok_or_else(search_overflow)?;
195        let patch = patches.get(record.patch_index).ok_or_else(|| {
196            GeomError::Degenerate("surface projection patch index escaped its catalog".to_owned())
197        })?;
198        let child_node_work = patch
199            .restriction_bound_work()?
200            .checked_add(patch.representative_bound_work()?)
201            .and_then(|work| work.checked_add(1))
202            .ok_or_else(search_overflow)?;
203        let child_work = child_node_work.checked_mul(2).ok_or_else(search_overflow)?;
204        budget.charge(Some(child_work))?;
205        let child_domains = split_domain(record.domain, axis, midpoint);
206        let children = [
207            make_pending(
208                &patches,
209                record.patch_index,
210                child_domains.0,
211                child_depth,
212                serial,
213                target_array,
214            )?,
215            make_pending(
216                &patches,
217                record.patch_index,
218                child_domains.1,
219                child_depth,
220                serial.checked_add(1).ok_or_else(search_overflow)?,
221                target_array,
222            )?,
223        ];
224        serial = serial.checked_add(2).ok_or_else(search_overflow)?;
225        visited_nodes = visited_nodes.checked_add(2).ok_or_else(search_overflow)?;
226
227        for child in &children {
228            update_resolved_candidate(
229                &mut candidate,
230                sample(
231                    surface,
232                    &patches[child.patch_index],
233                    child.domain,
234                    target_array,
235                )?,
236            );
237        }
238        for child in children {
239            if can_contain_global_minimizer(child.lower, candidate.upper) {
240                try_push(&mut pending, child)?;
241            }
242        }
243    }
244}
245
246fn validate_query(surface: &BSplineSurface, target: Point3) -> GeomResult<()> {
247    validate_target(target)?;
248    if surface.u_closed || surface.v_closed {
249        return Err(GeomError::Unsupported {
250            backend: BackendId::new("axiolid-nurbs"),
251            operation: Operation::SpatialQuery,
252        });
253    }
254    Ok(())
255}
256
257fn validate_target(target: Point3) -> GeomResult<()> {
258    if !target.is_finite() {
259        return Err(GeomError::InvalidInput(
260            "surface projection target must be finite".to_owned(),
261        ));
262    }
263    Ok(())
264}
265
266fn patch_domain(patch: &Patch) -> SurfaceParameterBox {
267    SurfaceParameterBox {
268        u: ParameterInterval {
269            start: patch.u_start,
270            end: patch.u_end,
271        },
272        v: ParameterInterval {
273            start: patch.v_start,
274            end: patch.v_end,
275        },
276    }
277}
278
279fn patch_lower(patch: &Patch, target: [Scalar; 3]) -> GeomResult<Scalar> {
280    let bounds = patch.coordinate_intervals()?;
281    distance_to_box_lower(
282        target,
283        [bounds[0].lower(), bounds[1].lower(), bounds[2].lower()],
284        [bounds[0].upper(), bounds[1].upper(), bounds[2].upper()],
285        DIMENSIONS,
286    )
287}
288
289fn restricted_lower(
290    patch: &Patch,
291    domain: SurfaceParameterBox,
292    target: [Scalar; 3],
293) -> GeomResult<Scalar> {
294    let restricted = patch.restrict(domain.u.start, domain.u.end, domain.v.start, domain.v.end)?;
295    patch_lower(&restricted, target)
296}
297
298fn sample(
299    surface: &BSplineSurface,
300    patch: &Patch,
301    domain: SurfaceParameterBox,
302    target: [Scalar; 3],
303) -> GeomResult<Candidate> {
304    let u = representative_parameter(domain.u);
305    let v = representative_parameter(domain.v);
306    let enclosed = patch.point_at(u, v)?.euclidean()?;
307    let upper = distance_to_point_interval_upper(target, enclosed, DIMENSIONS)?;
308    let point = bspline_jet(surface, u, v)?.point;
309    if !point.is_finite() {
310        return Err(GeomError::Degenerate(
311            "surface projection scalar representative is non-finite".to_owned(),
312        ));
313    }
314    let distance = representative_distance(point.to_array(), target, DIMENSIONS)?;
315    Ok(Candidate {
316        u,
317        v,
318        point,
319        distance,
320        upper,
321    })
322}
323
324fn representative_parameter(interval: ParameterInterval) -> Scalar {
325    let middle = interval.start * 0.5 + interval.end * 0.5;
326    if middle > interval.start && middle < interval.end {
327        middle
328    } else {
329        interval.start
330    }
331}
332
333fn update_candidate(current: &mut Option<Candidate>, next: Candidate) {
334    let replace = current.is_none_or(|best| candidate_order(next, best) == Ordering::Less);
335    if replace {
336        *current = Some(next);
337    }
338}
339
340fn update_resolved_candidate(current: &mut Candidate, next: Candidate) {
341    if candidate_order(next, *current) == Ordering::Less {
342        *current = next;
343    }
344}
345
346fn candidate_order(first: Candidate, second: Candidate) -> Ordering {
347    first
348        .upper
349        .total_cmp(&second.upper)
350        .then_with(|| first.u.total_cmp(&second.u))
351        .then_with(|| first.v.total_cmp(&second.v))
352}
353
354fn make_pending(
355    patches: &[Patch],
356    patch_index: usize,
357    domain: SurfaceParameterBox,
358    depth: u16,
359    serial: u32,
360    target: [Scalar; 3],
361) -> GeomResult<Pending> {
362    let patch = patches.get(patch_index).ok_or_else(|| {
363        GeomError::Degenerate("surface projection patch index escaped its catalog".to_owned())
364    })?;
365    Ok(Pending {
366        patch_index,
367        domain,
368        lower: restricted_lower(patch, domain, target)?,
369        depth,
370        serial,
371    })
372}
373
374fn select_refinable(
375    pending: &[Pending],
376    max_depth: u16,
377    improve_gap: bool,
378) -> GeomResult<Option<usize>> {
379    let mut selected = None;
380    for (index, record) in pending.iter().enumerate() {
381        if record.depth >= max_depth || split_choice(record.domain)?.is_none() {
382            continue;
383        }
384        selected = match selected {
385            None => Some(index),
386            Some(before) => {
387                let ordering = if improve_gap {
388                    lower_order(record, &pending[before])?
389                } else {
390                    widest_order(record, &pending[before])?
391                };
392                Some(if ordering == Ordering::Less {
393                    index
394                } else {
395                    before
396                })
397            }
398        };
399    }
400    Ok(selected)
401}
402
403fn lower_order(first: &Pending, second: &Pending) -> GeomResult<Ordering> {
404    Ok(first
405        .lower
406        .total_cmp(&second.lower)
407        .then_with(|| first.depth.cmp(&second.depth))
408        .then_with(|| first.patch_index.cmp(&second.patch_index))
409        .then_with(|| first.domain.u.start.total_cmp(&second.domain.u.start))
410        .then_with(|| first.domain.v.start.total_cmp(&second.domain.v.start))
411        .then_with(|| first.serial.cmp(&second.serial)))
412}
413
414fn widest_order(first: &Pending, second: &Pending) -> GeomResult<Ordering> {
415    let first_width = interval_width(first.domain.u)?.max(interval_width(first.domain.v)?);
416    let second_width = interval_width(second.domain.u)?.max(interval_width(second.domain.v)?);
417    Ok(second_width
418        .total_cmp(&first_width)
419        .then_with(|| first.patch_index.cmp(&second.patch_index))
420        .then_with(|| first.domain.u.start.total_cmp(&second.domain.u.start))
421        .then_with(|| first.domain.v.start.total_cmp(&second.domain.v.start))
422        .then_with(|| first.serial.cmp(&second.serial)))
423}
424
425fn split_choice(domain: SurfaceParameterBox) -> GeomResult<Option<(SplitAxis, Scalar)>> {
426    let u_width = interval_width(domain.u)?;
427    let v_width = interval_width(domain.v)?;
428    let u_midpoint = advancing_midpoint(domain.u);
429    let v_midpoint = advancing_midpoint(domain.v);
430    Ok(match (u_midpoint, v_midpoint) {
431        (Some(u), Some(_)) if u_width >= v_width => Some((SplitAxis::U, u)),
432        (Some(_), Some(v)) => Some((SplitAxis::V, v)),
433        (Some(u), None) => Some((SplitAxis::U, u)),
434        (None, Some(v)) => Some((SplitAxis::V, v)),
435        (None, None) => None,
436    })
437}
438
439fn advancing_midpoint(interval: ParameterInterval) -> Option<Scalar> {
440    let middle = interval.start * 0.5 + interval.end * 0.5;
441    (middle > interval.start && middle < interval.end).then_some(middle)
442}
443
444fn split_domain(
445    domain: SurfaceParameterBox,
446    axis: SplitAxis,
447    midpoint: Scalar,
448) -> (SurfaceParameterBox, SurfaceParameterBox) {
449    match axis {
450        SplitAxis::U => (
451            SurfaceParameterBox {
452                u: ParameterInterval {
453                    start: domain.u.start,
454                    end: midpoint,
455                },
456                v: domain.v,
457            },
458            SurfaceParameterBox {
459                u: ParameterInterval {
460                    start: midpoint,
461                    end: domain.u.end,
462                },
463                v: domain.v,
464            },
465        ),
466        SplitAxis::V => (
467            SurfaceParameterBox {
468                u: domain.u,
469                v: ParameterInterval {
470                    start: domain.v.start,
471                    end: midpoint,
472                },
473            },
474            SurfaceParameterBox {
475                u: domain.u,
476                v: ParameterInterval {
477                    start: midpoint,
478                    end: domain.v.end,
479                },
480            },
481        ),
482    }
483}
484
485fn interval_width(interval: ParameterInterval) -> GeomResult<Scalar> {
486    let width = interval.end - interval.start;
487    if width.is_finite() && width >= 0.0 {
488        Ok(width)
489    } else {
490        Err(GeomError::Degenerate(
491            "surface projection native parameter width is non-finite".to_owned(),
492        ))
493    }
494}
495
496fn global_lower(pending: &[Pending]) -> GeomResult<Scalar> {
497    let lower = pending
498        .iter()
499        .map(|record| record.lower)
500        .fold(Scalar::INFINITY, Scalar::min);
501    if lower.is_finite() {
502        Ok(lower)
503    } else {
504        Err(GeomError::Degenerate(
505            "surface projection global lower bound is non-finite".to_owned(),
506        ))
507    }
508}
509
510fn can_contain_global_minimizer(lower: Scalar, attained_upper: Scalar) -> bool {
511    lower <= attained_upper
512}
513
514fn certified_gap(upper: Scalar, lower: Scalar) -> GeomResult<Scalar> {
515    if lower > upper {
516        return Err(GeomError::Degenerate(
517            "surface projection lower bound exceeds its attained upper bound".to_owned(),
518        ));
519    }
520    let gap = next_up((upper - lower).max(0.0));
521    if gap.is_finite() {
522        Ok(gap)
523    } else {
524        Err(GeomError::Degenerate(
525            "surface projection distance gap is non-finite".to_owned(),
526        ))
527    }
528}
529
530fn take_sorted_boxes(pending: Vec<Pending>) -> GeomResult<Vec<SurfaceParameterBox>> {
531    let mut boxes = Vec::new();
532    boxes
533        .try_reserve_exact(pending.len())
534        .map_err(|_| allocation_error())?;
535    for record in pending {
536        boxes.push(record.domain);
537    }
538    boxes.sort_by(|first, second| {
539        first
540            .u
541            .start
542            .total_cmp(&second.u.start)
543            .then_with(|| first.v.start.total_cmp(&second.v.start))
544            .then_with(|| first.u.end.total_cmp(&second.u.end))
545            .then_with(|| first.v.end.total_cmp(&second.v.end))
546    });
547    Ok(boxes)
548}
549
550fn certificate(
551    candidate: Candidate,
552    lower: Scalar,
553    boxes: Vec<SurfaceParameterBox>,
554    visited_nodes: u32,
555) -> SurfaceProjectionCertificate3 {
556    SurfaceProjectionCertificate3 {
557        u: candidate.u,
558        v: candidate.v,
559        point: candidate.point,
560        distance: candidate.distance,
561        distance_lower_bound: lower,
562        distance_upper_bound: candidate.upper,
563        possible_minimizer_boxes: boxes,
564        visited_nodes,
565    }
566}
567
568fn try_push<T>(values: &mut Vec<T>, value: T) -> GeomResult<()> {
569    values.try_reserve(1).map_err(|_| allocation_error())?;
570    values.push(value);
571    Ok(())
572}
573
574fn allocation_error() -> GeomError {
575    GeomError::BudgetExceeded {
576        resource: "certified surface projection allocation",
577    }
578}
579
580fn search_overflow() -> GeomError {
581    GeomError::BudgetExceeded {
582        resource: "certified surface projection search nodes",
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::can_contain_global_minimizer;
589
590    #[test]
591    fn equality_is_retained() {
592        assert!(can_contain_global_minimizer(1.0, 1.0));
593    }
594}