pub mod index;
pub mod input;
pub mod solver;
pub mod workspace;
use crate::tuning;
use geo_types::{MultiPolygon, Point, Polygon};
use rayon::prelude::*;
use thiserror::Error;
use self::input::HostPolygon;
use self::solver::grid::solve_grid;
use self::workspace::MicWorkspace;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct MicOptions {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MicUsedEngine {
Grid,
Geos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MicApproach {
FastTriangle,
FastRectangle,
FastConvexQuad,
FastConvexN,
GridSolver,
Geos,
}
#[derive(Debug, Clone)]
pub struct MicResult {
pub center: Point<f64>,
pub radius: f64,
pub radius_sq: f64,
pub support_segments: Vec<usize>,
pub candidate_count: usize,
pub used_engine: MicUsedEngine,
pub component_index: Option<usize>,
pub approach: MicApproach,
}
#[derive(Debug, Error)]
pub enum MicError {
#[error("invalid polygon input: {0}")]
InvalidInput(String),
#[error("no valid MIC candidate found")]
NoCircleFound,
#[error("GEOS comparison solve failed: {0}")]
GeosFailed(String),
#[error("unsupported GEOS MIC output: {0}")]
UnsupportedGeosOutput(String),
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
pub fn maximum_inscribed_circle(
poly: &Polygon<f64>,
opts: &MicOptions,
) -> Result<MicResult, MicError> {
let host = HostPolygon::from_polygon(poly)?;
solve_on_host_polygon(host, opts)
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
pub fn maximum_inscribed_circle_with_workspace(
workspace: &mut MicWorkspace,
opts: &MicOptions,
) -> Result<MicResult, MicError> {
solve_with_indexes(
&workspace.host,
&workspace.pip_index,
&workspace.nb_index,
opts,
)
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
pub fn maximum_inscribed_circle_multipolygon(
multi: &MultiPolygon<f64>,
opts: &MicOptions,
) -> Result<MicResult, MicError> {
if multi.0.is_empty() {
return Err(MicError::InvalidInput(
"multipolygon has no components".to_string(),
));
}
let mut best: Option<MicResult> = None;
let mut last_error: Option<MicError> = None;
for (idx, poly) in multi.0.iter().enumerate() {
match maximum_inscribed_circle(poly, opts) {
Ok(mut result) => {
result.component_index = Some(idx);
let replace = best
.as_ref()
.map(|current| result.radius_sq > current.radius_sq)
.unwrap_or(true);
if replace {
best = Some(result);
}
}
Err(err) => {
last_error = Some(err);
}
}
}
best.ok_or_else(|| last_error.unwrap_or(MicError::NoCircleFound))
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
pub fn maximum_inscribed_circle_slice(
polys: &[Polygon<f64>],
opts: &MicOptions,
) -> Vec<Result<MicResult, MicError>> {
polys
.par_iter()
.map(|poly| maximum_inscribed_circle(poly, opts))
.collect()
}
fn solve_on_host_polygon(host: HostPolygon, _opts: &MicOptions) -> Result<MicResult, MicError> {
let mut seg_idx = None;
if host.ring_count() == 1 {
let outer_len = host.outer_ring().len();
let result = if outer_len == 4 {
solver::fast::fast_triangle(&host)
} else if outer_len == 5 {
solver::fast::fast_rectangle(&host).or_else(|| solver::fast::fast_convex_quad(&host))
} else {
None
};
if let Some(mut result) = result {
let table = input::SegmentIndex::from_host(&host);
let actual_sq = table.batch_point_segment_distance_sq(
result.center.x(),
result.center.y(),
0,
table.len(),
);
let actual = actual_sq.max(0.0).sqrt();
let relative = if actual > 0.0 {
(result.radius - actual).abs() / actual
} else {
f64::INFINITY
};
if relative <= tuning::MIC_FAST_PATH_REL_TOL {
result.radius = actual;
result.radius_sq = actual_sq;
return Ok(result);
}
seg_idx = Some(table);
}
}
let workspace = match seg_idx {
Some(table) => MicWorkspace::with_segments(host, table)?,
None => MicWorkspace::new(host)?,
};
let tolerance = grid_tolerance(&workspace.host);
if let Some(grid_result) = solve_grid(
&workspace.host,
&workspace.pip_index,
&workspace.nb_index,
tolerance,
) {
return Ok(grid_result);
}
Err(MicError::NoCircleFound)
}
fn solve_with_indexes(
host: &HostPolygon,
pip: &index::PipIndex,
nb: &index::NearestBoundaryIndex,
_opts: &MicOptions,
) -> Result<MicResult, MicError> {
let tolerance = grid_tolerance(host);
solve_grid(host, pip, nb, tolerance).ok_or(MicError::NoCircleFound)
}
pub fn grid_tolerance(host: &HostPolygon) -> f64 {
let Some((min_x, min_y, max_x, max_y)) = host.bounds() else {
return 0.0;
};
let diag = (max_x - min_x).hypot(max_y - min_y);
diag * 1e-7
}
#[cfg(test)]
mod tests {
use super::*;
use geo_types::{Coord, LineString, Polygon};
fn square(size: f64) -> Polygon<f64> {
Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord { x: size, y: 0.0 },
Coord { x: size, y: size },
Coord { x: 0.0, y: size },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
)
}
#[test]
fn mic_exact_square_radius() {
let poly = square(10.0);
let opts = MicOptions::default();
let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
assert!((r.radius - 5.0).abs() < 1e-6, "radius={}", r.radius);
assert!((r.center.x() - 5.0).abs() < 1e-6);
assert!((r.center.y() - 5.0).abs() < 1e-6);
}
#[test]
fn mic_exact_rectangle_radius_equal_half_width() {
let poly = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord { x: 8.0, y: 0.0 },
Coord { x: 8.0, y: 2.0 },
Coord { x: 0.0, y: 2.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
);
let opts = MicOptions::default();
let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
assert!((r.radius - 1.0).abs() < 1e-6, "radius={}", r.radius);
}
#[test]
fn mic_exact_triangle_center_inside() {
let poly = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord { x: 10.0, y: 0.0 },
Coord { x: 5.0, y: 10.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
);
let opts = MicOptions::default();
let r = maximum_inscribed_circle(&poly, &opts).expect("solve failed");
assert!(r.radius > 1.0, "radius={}", r.radius);
use geo::Contains;
assert!(poly.contains(&r.center));
}
#[test]
fn mic_degenerate_zero_area_returns_err() {
let poly = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord { x: 1.0, y: 0.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
);
let opts = MicOptions::default();
assert!(maximum_inscribed_circle(&poly, &opts).is_err());
}
fn poly(pts: &[(f64, f64)]) -> Polygon<f64> {
let mut v: Vec<Coord> = pts.iter().map(|p| Coord { x: p.0, y: p.1 }).collect();
v.push(v[0]);
Polygon::new(LineString::from(v), vec![])
}
fn assert_fits(poly: &Polygon<f64>, r: &MicResult) {
use crate::mic::input::{HostPolygon, SegmentIndex};
let host = HostPolygon::from_polygon(poly).expect("host");
let seg = SegmentIndex::from_host(&host);
let measured = seg
.batch_point_segment_distance_sq(r.center.x(), r.center.y(), 0, seg.len())
.sqrt();
let rel = (r.radius - measured).abs() / measured.max(1e-300);
assert!(
rel <= 1e-9,
"reported radius {} differs from measured clearance {}",
r.radius,
measured
);
}
#[test]
fn mic_rejects_nan_coordinate() {
let bad = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord {
x: f64::NAN,
y: 0.0,
},
Coord { x: 10.0, y: 10.0 },
Coord { x: 0.0, y: 10.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
);
let err = maximum_inscribed_circle(&bad, &MicOptions::default()).unwrap_err();
assert!(err.to_string().contains("NaN"), "got {err}");
}
#[test]
fn mic_rejects_infinite_coordinate() {
let bad = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord {
x: f64::INFINITY,
y: 0.0,
},
Coord { x: 10.0, y: 10.0 },
Coord { x: 0.0, y: 10.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![],
);
assert!(maximum_inscribed_circle(&bad, &MicOptions::default()).is_err());
}
#[test]
fn mic_respects_hole() {
let frame = Polygon::new(
LineString::from(vec![
Coord { x: 0.0, y: 0.0 },
Coord { x: 10.0, y: 0.0 },
Coord { x: 10.0, y: 10.0 },
Coord { x: 0.0, y: 10.0 },
Coord { x: 0.0, y: 0.0 },
]),
vec![LineString::from(vec![
Coord { x: 3.0, y: 3.0 },
Coord { x: 7.0, y: 3.0 },
Coord { x: 7.0, y: 7.0 },
Coord { x: 3.0, y: 7.0 },
Coord { x: 3.0, y: 3.0 },
])],
);
let r = maximum_inscribed_circle(&frame, &MicOptions::default()).expect("solve");
assert!(
(r.radius - 1.757_359_312_8).abs() < 1e-6,
"radius={} expected the hole-limited value",
r.radius
);
assert_fits(&frame, &r);
}
#[test]
fn mic_concave_shape_fits() {
let l = poly(&[
(0.0, 0.0),
(4.0, 0.0),
(4.0, 1.0),
(1.0, 1.0),
(1.0, 3.0),
(0.0, 3.0),
]);
let r = maximum_inscribed_circle(&l, &MicOptions::default()).expect("solve");
assert!(
(r.radius - 0.585_786_437_6).abs() < 1e-6,
"radius={}",
r.radius
);
assert_fits(&l, &r);
}
#[test]
fn mic_thin_corridor_is_fast_and_fits() {
let corridor = poly(&[(0.0, 0.0), (1000.0, 0.0), (1000.0, 2.0), (0.0, 2.0)]);
let started = std::time::Instant::now();
let r = maximum_inscribed_circle(&corridor, &MicOptions::default()).expect("solve");
let elapsed = started.elapsed();
assert!((r.radius - 1.0).abs() < 1e-9, "radius={}", r.radius);
assert_fits(&corridor, &r);
assert!(
elapsed < std::time::Duration::from_millis(500),
"thin corridor took {elapsed:?}"
);
}
#[test]
fn mic_notched_corridor_fits() {
let notched = poly(&[
(0.0, 0.0),
(100.0, 0.0),
(100.0, 2.0),
(51.0, 2.0),
(51.0, 3.0),
(50.0, 3.0),
(50.0, 2.0),
(0.0, 2.0),
]);
let r = maximum_inscribed_circle(¬ched, &MicOptions::default()).expect("solve");
assert!(r.radius > 1.0, "radius={} lost the notch", r.radius);
assert_fits(¬ched, &r);
}
#[test]
fn mic_multipolygon_reports_component() {
let small = poly(&[(20.0, 20.0), (22.0, 20.0), (22.0, 22.0), (20.0, 22.0)]);
let big = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
let multi = MultiPolygon(vec![small, big]);
let r =
maximum_inscribed_circle_multipolygon(&multi, &MicOptions::default()).expect("solve");
assert!((r.radius - 5.0).abs() < 1e-6);
assert_eq!(r.component_index, Some(1));
}
#[test]
fn mic_batch_preserves_order() {
let shapes: Vec<Polygon<f64>> = (1..=4)
.map(|k| {
poly(&[
(0.0, 0.0),
(k as f64, 0.0),
(k as f64, k as f64),
(0.0, k as f64),
])
})
.collect();
let out = maximum_inscribed_circle_slice(&shapes, &MicOptions::default());
assert_eq!(out.len(), 4);
for (k, r) in out.iter().enumerate() {
let side = (k + 1) as f64;
assert!((r.as_ref().expect("solve").radius - side / 2.0).abs() < 1e-6);
}
}
}