1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct MicOptions {}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MicUsedEngine {
33 Grid,
35 Geos,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum MicApproach {
42 FastTriangle,
44 FastRectangle,
46 FastConvexQuad,
48 FastConvexN,
50 GridSolver,
52 Geos,
54}
55
56#[derive(Debug, Clone)]
58pub struct MicResult {
59 pub center: Point<f64>,
61 pub radius: f64,
63 pub radius_sq: f64,
65 pub support_segments: Vec<usize>,
67 pub candidate_count: usize,
69 pub used_engine: MicUsedEngine,
71 pub component_index: Option<usize>,
73 pub approach: MicApproach,
75}
76
77#[derive(Debug, Error)]
79pub enum MicError {
80 #[error("invalid polygon input: {0}")]
82 InvalidInput(String),
83 #[error("no valid MIC candidate found")]
85 NoCircleFound,
86 #[error("GEOS comparison solve failed: {0}")]
88 GeosFailed(String),
89 #[error("unsupported GEOS MIC output: {0}")]
91 UnsupportedGeosOutput(String),
92}
93
94#[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#[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#[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#[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 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 solver::fast::fast_triangle(&host)
183 } else if outer_len == 5 {
184 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 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 seg_idx = Some(table);
215 }
216 }
217
218 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 Err(MicError::NoCircleFound)
240}
241
242fn 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
253pub 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 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 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 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(¬ched, &MicOptions::default()).expect("solve");
487 assert!(r.radius > 1.0, "radius={} lost the notch", r.radius);
488 assert_fits(¬ched, &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}