Skip to main content

max_inscribed_circle/mic/
mod.rs

1//! Maximum Inscribed Circle (MIC) solvers for polygonal inputs.
2//!
3//! One pipeline: closed-form fast paths for the shape classes where the
4//! optimum is known, then the grid solver for everything else. There is no
5//! external solver in the pipeline; the GEOS comparison lives behind the
6//! `bench-geos` feature in `solver::bench_geos` and is never called from here.
7
8pub mod index;
9pub mod input;
10pub mod solver;
11pub mod workspace;
12
13use crate::tuning;
14use geo_types::{MultiPolygon, Point, Polygon};
15use rayon::prelude::*;
16use thiserror::Error;
17
18use self::input::HostPolygon;
19use self::solver::grid::solve_grid;
20use self::workspace::MicWorkspace;
21
22/// Solver configuration.
23///
24/// The solve has no tunable knobs: the grid tolerance scales with the input
25/// diagonal and the fast paths are closed form. The type is kept so callers
26/// pass options by reference and future knobs stay source compatible.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct MicOptions {}
29
30/// Solver that produced the final result.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MicUsedEngine {
33    /// The grid solver produced the circle.
34    Grid,
35    /// The GEOS comparison solver produced the circle, behind `bench-geos`.
36    Geos,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40/// Solver path that produced the result.
41pub enum MicApproach {
42    /// Analytical incenter fast path for a triangular ring.
43    FastTriangle,
44    /// Fast path for an axis-aligned rectangular ring.
45    FastRectangle,
46    /// Chebyshev-center fast path for a convex quadrilateral ring.
47    FastConvexQuad,
48    /// Fast path for a convex n-gon, solved by half-plane feasibility search.
49    FastConvexN,
50    /// Uniform grid search over interior cell centers.
51    GridSolver,
52    /// GEOS maximum inscribed circle, behind the `bench-geos` feature.
53    Geos,
54}
55
56/// MIC solve result.
57#[derive(Debug, Clone)]
58pub struct MicResult {
59    /// Circle center point.
60    pub center: Point<f64>,
61    /// Circle radius in input coordinate units.
62    pub radius: f64,
63    /// Squared radius, radius * radius.
64    pub radius_sq: f64,
65    /// Indices of the boundary segments the circle touches.
66    pub support_segments: Vec<usize>,
67    /// Number of candidate centers evaluated during the solve.
68    pub candidate_count: usize,
69    /// Solver that produced this result.
70    pub used_engine: MicUsedEngine,
71    /// Index of the multipolygon component that holds the circle, if any.
72    pub component_index: Option<usize>,
73    /// Solver path that produced this result.
74    pub approach: MicApproach,
75}
76
77/// MIC solver error.
78#[derive(Debug, Error)]
79pub enum MicError {
80    /// Input polygon is malformed, empty, or degenerate.
81    #[error("invalid polygon input: {0}")]
82    InvalidInput(String),
83    /// No interior point was found, so no circle exists.
84    #[error("no valid MIC candidate found")]
85    NoCircleFound,
86    /// The GEOS comparison solve failed, behind the `bench-geos` feature.
87    #[error("GEOS comparison solve failed: {0}")]
88    GeosFailed(String),
89    /// The GEOS result could not be parsed into a circle.
90    #[error("unsupported GEOS MIC output: {0}")]
91    UnsupportedGeosOutput(String),
92}
93
94/// Solve MIC on a single polygon.
95#[cfg_attr(feature = "hotpath", hotpath::measure)]
96pub fn maximum_inscribed_circle(
97    poly: &Polygon<f64>,
98    opts: &MicOptions,
99) -> Result<MicResult, MicError> {
100    let host = HostPolygon::from_polygon(poly)?;
101    solve_on_host_polygon(host, opts)
102}
103
104/// Solve MIC reusing a workspace, which holds the indexes built for its polygon.
105///
106/// The workspace owns the polygon it was built for, so this solves that
107/// polygon. Build a fresh [`MicWorkspace`] to solve a different one.
108#[cfg_attr(feature = "hotpath", hotpath::measure)]
109pub fn maximum_inscribed_circle_with_workspace(
110    workspace: &mut MicWorkspace,
111    opts: &MicOptions,
112) -> Result<MicResult, MicError> {
113    solve_with_indexes(
114        &workspace.host,
115        &workspace.pip_index,
116        &workspace.nb_index,
117        opts,
118    )
119}
120
121/// Solve MIC on a multipolygon by solving each component and keeping the best.
122#[cfg_attr(feature = "hotpath", hotpath::measure)]
123pub fn maximum_inscribed_circle_multipolygon(
124    multi: &MultiPolygon<f64>,
125    opts: &MicOptions,
126) -> Result<MicResult, MicError> {
127    if multi.0.is_empty() {
128        return Err(MicError::InvalidInput(
129            "multipolygon has no components".to_string(),
130        ));
131    }
132
133    let mut best: Option<MicResult> = None;
134    let mut last_error: Option<MicError> = None;
135
136    for (idx, poly) in multi.0.iter().enumerate() {
137        match maximum_inscribed_circle(poly, opts) {
138            Ok(mut result) => {
139                result.component_index = Some(idx);
140                let replace = best
141                    .as_ref()
142                    .map(|current| result.radius_sq > current.radius_sq)
143                    .unwrap_or(true);
144                if replace {
145                    best = Some(result);
146                }
147            }
148            Err(err) => {
149                last_error = Some(err);
150            }
151        }
152    }
153
154    best.ok_or_else(|| last_error.unwrap_or(MicError::NoCircleFound))
155}
156
157/// Solve MIC on many polygons in parallel using rayon.
158///
159/// Each polygon is solved independently on a rayon thread pool.
160/// Returns results in the same order as input.
161#[cfg_attr(feature = "hotpath", hotpath::measure)]
162pub fn maximum_inscribed_circle_slice(
163    polys: &[Polygon<f64>],
164    opts: &MicOptions,
165) -> Vec<Result<MicResult, MicError>> {
166    polys
167        .par_iter()
168        .map(|poly| maximum_inscribed_circle(poly, opts))
169        .collect()
170}
171
172fn solve_on_host_polygon(host: HostPolygon, _opts: &MicOptions) -> Result<MicResult, MicError> {
173    // Phase 0: Try analytical fast path for simple shapes (triangle, rectangle, convex quad).
174    // We intentionally do NOT dispatch arbitrary N-gons to fast_convex_n here:
175    // real-world data has near-collinear reflex vertices and numerical noise that
176    // can fool convexity checks, producing kernel circles smaller than the true MIC.
177    let mut seg_idx = None;
178    if host.ring_count() == 1 {
179        let outer_len = host.outer_ring().len();
180        let result = if outer_len == 4 {
181            // Triangle - exact incenter formula
182            solver::fast::fast_triangle(&host)
183        } else if outer_len == 5 {
184            // Rectangle (cheapest O(1)), then general convex quad (LP solver)
185            solver::fast::fast_rectangle(&host).or_else(|| solver::fast::fast_convex_quad(&host))
186        } else {
187            None
188        };
189        if let Some(mut result) = result {
190            // The reported radius must be the distance the segment table reports
191            // at the returned centre, so the circle always fits. Near-collinear
192            // reflex vertices in real data can fool the convexity tests, and a
193            // shape that only looks like the class must fall through.
194            let table = input::SegmentIndex::from_host(&host);
195            let actual_sq = table.batch_point_segment_distance_sq(
196                result.center.x(),
197                result.center.y(),
198                0,
199                table.len(),
200            );
201            let actual = actual_sq.max(0.0).sqrt();
202            let relative = if actual > 0.0 {
203                (result.radius - actual).abs() / actual
204            } else {
205                f64::INFINITY
206            };
207            if relative <= tuning::MIC_FAST_PATH_REL_TOL {
208                result.radius = actual;
209                result.radius_sq = actual_sq;
210                return Ok(result);
211            }
212            // The table is already built, so hand it to the grid rather than
213            // rebuilding it there.
214            seg_idx = Some(table);
215        }
216    }
217
218    // Phase 1: grid search over the bounding box, which answers every shape
219    // the fast paths did not claim. The workspace takes ownership of the host
220    // polygon, so the solve reads it back from there instead of cloning it.
221    let workspace = match seg_idx {
222        Some(table) => MicWorkspace::with_segments(host, table)?,
223        None => MicWorkspace::new(host)?,
224    };
225    let tolerance = grid_tolerance(&workspace.host);
226
227    if let Some(grid_result) = solve_grid(
228        &workspace.host,
229        &workspace.pip_index,
230        &workspace.nb_index,
231        tolerance,
232    ) {
233        return Ok(grid_result);
234    }
235
236    // The seeds cover the whole box, so reaching here means the polygon has no
237    // interior point at all, which a zero-area ring catches earlier. Report it
238    // rather than return a circle the input does not contain.
239    Err(MicError::NoCircleFound)
240}
241
242/// Solve with indexes the caller already built, skipping index construction.
243fn solve_with_indexes(
244    host: &HostPolygon,
245    pip: &index::PipIndex,
246    nb: &index::NearestBoundaryIndex,
247    _opts: &MicOptions,
248) -> Result<MicResult, MicError> {
249    let tolerance = grid_tolerance(host);
250    solve_grid(host, pip, nb, tolerance).ok_or(MicError::NoCircleFound)
251}
252
253/// Absolute refinement tolerance for the grid stage on a host polygon:
254/// `diag * 1e-7`, where `diag` is the bounding box diagonal as it stands.
255///
256/// Exposed so a comparison harness can hand another solver the same tolerance
257/// the native pipeline uses, instead of comparing solvers at mixed tolerances.
258///
259/// The diagonal carries no floor, because a floor would tie the accuracy of the
260/// answer to the coordinate units of the input: a polygon a few hundred metres
261/// wide stored in degrees has a diagonal near `1e-3`, so a floor of one unit
262/// makes the tolerance a whole percent of its own inscribed radius and the
263/// answer stops meaning anything. Scale invariance here means the same polygon
264/// in degrees and in metres converges to the same circle.
265pub fn grid_tolerance(host: &HostPolygon) -> f64 {
266    let Some((min_x, min_y, max_x, max_y)) = host.bounds() else {
267        return 0.0;
268    };
269    let diag = (max_x - min_x).hypot(max_y - min_y);
270    diag * 1e-7
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use geo_types::{Coord, LineString, Polygon};
277
278    fn square(size: f64) -> Polygon<f64> {
279        Polygon::new(
280            LineString::from(vec![
281                Coord { x: 0.0, y: 0.0 },
282                Coord { x: size, y: 0.0 },
283                Coord { x: size, y: size },
284                Coord { x: 0.0, y: size },
285                Coord { x: 0.0, y: 0.0 },
286            ]),
287            vec![],
288        )
289    }
290
291    #[test]
292    fn mic_exact_square_radius() {
293        let poly = square(10.0);
294        let opts = MicOptions::default();
295        let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
296        assert!((r.radius - 5.0).abs() < 1e-6, "radius={}", r.radius);
297        assert!((r.center.x() - 5.0).abs() < 1e-6);
298        assert!((r.center.y() - 5.0).abs() < 1e-6);
299    }
300
301    #[test]
302    fn mic_exact_rectangle_radius_equal_half_width() {
303        let poly = Polygon::new(
304            LineString::from(vec![
305                Coord { x: 0.0, y: 0.0 },
306                Coord { x: 8.0, y: 0.0 },
307                Coord { x: 8.0, y: 2.0 },
308                Coord { x: 0.0, y: 2.0 },
309                Coord { x: 0.0, y: 0.0 },
310            ]),
311            vec![],
312        );
313        let opts = MicOptions::default();
314        let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
315        assert!((r.radius - 1.0).abs() < 1e-6, "radius={}", r.radius);
316    }
317
318    #[test]
319    fn mic_exact_triangle_center_inside() {
320        let poly = Polygon::new(
321            LineString::from(vec![
322                Coord { x: 0.0, y: 0.0 },
323                Coord { x: 10.0, y: 0.0 },
324                Coord { x: 5.0, y: 10.0 },
325                Coord { x: 0.0, y: 0.0 },
326            ]),
327            vec![],
328        );
329        let opts = MicOptions::default();
330        let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
331        assert!(r.radius > 1.0, "radius={}", r.radius);
332        use geo::Contains;
333        assert!(poly.contains(&r.center));
334    }
335
336    #[test]
337    fn mic_degenerate_zero_area_returns_err() {
338        let poly = Polygon::new(
339            LineString::from(vec![
340                Coord { x: 0.0, y: 0.0 },
341                Coord { x: 1.0, y: 0.0 },
342                Coord { x: 0.0, y: 0.0 },
343            ]),
344            vec![],
345        );
346        let opts = MicOptions::default();
347        assert!(maximum_inscribed_circle(&poly, &opts).is_err());
348    }
349
350    fn poly(pts: &[(f64, f64)]) -> Polygon<f64> {
351        let mut v: Vec<Coord> = pts.iter().map(|p| Coord { x: p.0, y: p.1 }).collect();
352        v.push(v[0]);
353        Polygon::new(LineString::from(v), vec![])
354    }
355
356    fn assert_fits(poly: &Polygon<f64>, r: &MicResult) {
357        use crate::mic::input::{HostPolygon, SegmentIndex};
358        let host = HostPolygon::from_polygon(poly).expect("host");
359        let seg = SegmentIndex::from_host(&host);
360        let measured = seg
361            .batch_point_segment_distance_sq(r.center.x(), r.center.y(), 0, seg.len())
362            .sqrt();
363        let rel = (r.radius - measured).abs() / measured.max(1e-300);
364        assert!(
365            rel <= 1e-9,
366            "reported radius {} differs from measured clearance {}",
367            r.radius,
368            measured
369        );
370    }
371
372    #[test]
373    fn mic_rejects_nan_coordinate() {
374        let bad = Polygon::new(
375            LineString::from(vec![
376                Coord { x: 0.0, y: 0.0 },
377                Coord {
378                    x: f64::NAN,
379                    y: 0.0,
380                },
381                Coord { x: 10.0, y: 10.0 },
382                Coord { x: 0.0, y: 10.0 },
383                Coord { x: 0.0, y: 0.0 },
384            ]),
385            vec![],
386        );
387        let err = maximum_inscribed_circle(&bad, &MicOptions::default()).unwrap_err();
388        assert!(err.to_string().contains("NaN"), "got {err}");
389    }
390
391    #[test]
392    fn mic_rejects_infinite_coordinate() {
393        let bad = Polygon::new(
394            LineString::from(vec![
395                Coord { x: 0.0, y: 0.0 },
396                Coord {
397                    x: f64::INFINITY,
398                    y: 0.0,
399                },
400                Coord { x: 10.0, y: 10.0 },
401                Coord { x: 0.0, y: 10.0 },
402                Coord { x: 0.0, y: 0.0 },
403            ]),
404            vec![],
405        );
406        assert!(maximum_inscribed_circle(&bad, &MicOptions::default()).is_err());
407    }
408
409    #[test]
410    fn mic_respects_hole() {
411        // 10x10 frame with a 4x4 hole: the circle must clear the hole, not
412        // span the frame. Analytic value is sqrt(2) * (5 - 2) / 1 .. = 1.75736.
413        let frame = Polygon::new(
414            LineString::from(vec![
415                Coord { x: 0.0, y: 0.0 },
416                Coord { x: 10.0, y: 0.0 },
417                Coord { x: 10.0, y: 10.0 },
418                Coord { x: 0.0, y: 10.0 },
419                Coord { x: 0.0, y: 0.0 },
420            ]),
421            vec![LineString::from(vec![
422                Coord { x: 3.0, y: 3.0 },
423                Coord { x: 7.0, y: 3.0 },
424                Coord { x: 7.0, y: 7.0 },
425                Coord { x: 3.0, y: 7.0 },
426                Coord { x: 3.0, y: 3.0 },
427            ])],
428        );
429        let r = maximum_inscribed_circle(&frame, &MicOptions::default()).expect("solve");
430        assert!(
431            (r.radius - 1.757_359_312_8).abs() < 1e-6,
432            "radius={} expected the hole-limited value",
433            r.radius
434        );
435        assert_fits(&frame, &r);
436    }
437
438    #[test]
439    fn mic_concave_shape_fits() {
440        let l = poly(&[
441            (0.0, 0.0),
442            (4.0, 0.0),
443            (4.0, 1.0),
444            (1.0, 1.0),
445            (1.0, 3.0),
446            (0.0, 3.0),
447        ]);
448        let r = maximum_inscribed_circle(&l, &MicOptions::default()).expect("solve");
449        assert!(
450            (r.radius - 0.585_786_437_6).abs() < 1e-6,
451            "radius={}",
452            r.radius
453        );
454        assert_fits(&l, &r);
455    }
456
457    #[test]
458    fn mic_thin_corridor_is_fast_and_fits() {
459        // A ridge of equal clearance used to cost millions of cells here.
460        let corridor = poly(&[(0.0, 0.0), (1000.0, 0.0), (1000.0, 2.0), (0.0, 2.0)]);
461        let started = std::time::Instant::now();
462        let r = maximum_inscribed_circle(&corridor, &MicOptions::default()).expect("solve");
463        let elapsed = started.elapsed();
464        assert!((r.radius - 1.0).abs() < 1e-9, "radius={}", r.radius);
465        assert_fits(&corridor, &r);
466        assert!(
467            elapsed < std::time::Duration::from_millis(500),
468            "thin corridor took {elapsed:?}"
469        );
470    }
471
472    #[test]
473    fn mic_notched_corridor_fits() {
474        // The optimum sits in the notch basin, which the candidate stage alone
475        // does not reach. Whatever stage answers, the circle must fit.
476        let notched = poly(&[
477            (0.0, 0.0),
478            (100.0, 0.0),
479            (100.0, 2.0),
480            (51.0, 2.0),
481            (51.0, 3.0),
482            (50.0, 3.0),
483            (50.0, 2.0),
484            (0.0, 2.0),
485        ]);
486        let r = maximum_inscribed_circle(&notched, &MicOptions::default()).expect("solve");
487        assert!(r.radius > 1.0, "radius={} lost the notch", r.radius);
488        assert_fits(&notched, &r);
489    }
490
491    #[test]
492    fn mic_multipolygon_reports_component() {
493        let small = poly(&[(20.0, 20.0), (22.0, 20.0), (22.0, 22.0), (20.0, 22.0)]);
494        let big = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
495        let multi = MultiPolygon(vec![small, big]);
496        let r =
497            maximum_inscribed_circle_multipolygon(&multi, &MicOptions::default()).expect("solve");
498        assert!((r.radius - 5.0).abs() < 1e-6);
499        assert_eq!(r.component_index, Some(1));
500    }
501
502    #[test]
503    fn mic_batch_preserves_order() {
504        let shapes: Vec<Polygon<f64>> = (1..=4)
505            .map(|k| {
506                poly(&[
507                    (0.0, 0.0),
508                    (k as f64, 0.0),
509                    (k as f64, k as f64),
510                    (0.0, k as f64),
511                ])
512            })
513            .collect();
514        let out = maximum_inscribed_circle_slice(&shapes, &MicOptions::default());
515        assert_eq!(out.len(), 4);
516        for (k, r) in out.iter().enumerate() {
517            let side = (k + 1) as f64;
518            assert!((r.as_ref().expect("solve").radius - side / 2.0).abs() < 1e-6);
519        }
520    }
521}