Skip to main content

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