use crate::geometry::diagram::{discover_regions, IntersectionPoint};
use crate::geometry::primitives::Point;
use crate::geometry::shapes::Polygon;
use crate::geometry::traits::{Closed, DiagramShape, Polygonize};
use crate::plotting::clip::{polygon_clip_many, ClipOperation};
use crate::spec::{Combination, DiagramSpec};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct RegionPiece {
pub outer: Polygon,
pub holes: Vec<Polygon>,
}
impl RegionPiece {
pub fn area(&self) -> f64 {
let outer_area = self.outer.area();
let hole_area: f64 = self.holes.iter().map(|h| h.area()).sum();
(outer_area - hole_area).max(0.0)
}
}
#[derive(Debug, Clone)]
pub struct RegionPolygons {
regions: HashMap<Combination, Vec<RegionPiece>>,
}
impl RegionPolygons {
pub(crate) fn new() -> Self {
Self {
regions: HashMap::new(),
}
}
pub(crate) fn insert(&mut self, combination: Combination, pieces: Vec<RegionPiece>) {
self.regions.insert(combination, pieces);
}
pub fn get(&self, combination: &Combination) -> Option<&Vec<RegionPiece>> {
self.regions.get(combination)
}
pub fn iter(&self) -> impl Iterator<Item = (&Combination, &Vec<RegionPiece>)> {
self.regions.iter()
}
pub fn len(&self) -> usize {
self.regions.len()
}
pub fn is_empty(&self) -> bool {
self.regions.is_empty()
}
pub fn areas(&self) -> HashMap<Combination, f64> {
self.regions
.iter()
.map(|(combo, pieces)| {
let area = pieces.iter().map(|p| p.area()).sum();
(combo.clone(), area)
})
.collect()
}
pub fn label_points(&self, precision: f64) -> HashMap<Combination, Point> {
self.regions
.iter()
.filter_map(|(combo, pieces)| {
let (point, _) = poi_with_holes(pieces, precision)?;
Some((combo.clone(), point))
})
.collect()
}
pub fn set_label_points(&self, set_names: &[String], precision: f64) -> HashMap<String, Point> {
let mut result = HashMap::new();
for name in set_names {
let mut owned: Vec<Polygon> = Vec::new();
for (combo, pieces) in self.regions.iter() {
if !combo.sets().iter().any(|s| s == name) {
continue;
}
for piece in pieces {
owned.push(piece.outer.clone());
owned.extend(piece.holes.iter().cloned());
}
}
if owned.is_empty() {
continue;
}
let mut merged = vec![owned.remove(0)];
for p in owned {
merged = polygon_clip_many(&merged, &p, ClipOperation::Union);
if merged.is_empty() {
break;
}
}
let pieces = classify_into_pieces(merged);
if let Some((point, _)) = poi_with_holes(&pieces, precision) {
result.insert(name.clone(), point);
}
}
result
}
}
fn signed_polygon_area(p: &Polygon) -> f64 {
let v = p.vertices();
let n = v.len();
if n < 3 {
return 0.0;
}
let mut s = 0.0;
for i in 0..n {
let j = (i + 1) % n;
s += v[i].x() * v[j].y() - v[j].x() * v[i].y();
}
0.5 * s
}
fn point_in_polygon(p: &Point, poly: &Polygon) -> bool {
let v = poly.vertices();
let n = v.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = (v[i].x(), v[i].y());
let (xj, yj) = (v[j].x(), v[j].y());
let intersect = ((yi > p.y()) != (yj > p.y()))
&& (p.x() < (xj - xi) * (p.y() - yi) / (yj - yi + f64::EPSILON) + xi);
if intersect {
inside = !inside;
}
j = i;
}
inside
}
fn reverse_polygon(p: &Polygon) -> Polygon {
let mut v = p.vertices().to_vec();
v.reverse();
Polygon::new(v)
}
pub(crate) fn classify_into_pieces(rings: Vec<Polygon>) -> Vec<RegionPiece> {
let kept: Vec<(Polygon, f64)> = rings
.into_iter()
.filter_map(|p| {
let area = signed_polygon_area(&p).abs();
if p.vertices().len() < 3 || area < 1e-12 {
None
} else {
Some((p, area))
}
})
.collect();
if kept.is_empty() {
return Vec::new();
}
let n = kept.len();
let mut containment_depth = vec![0usize; n];
let mut smallest_container: Vec<Option<usize>> = vec![None; n];
for i in 0..n {
let probe = match kept[i].0.vertices().first() {
Some(p) => *p,
None => continue,
};
for (j, (other, area_j)) in kept.iter().enumerate() {
if i == j {
continue;
}
if point_in_polygon(&probe, other) {
containment_depth[i] += 1;
let cur_area = smallest_container[i]
.and_then(|k| kept.get(k).map(|(_, a)| *a))
.unwrap_or(f64::INFINITY);
if *area_j < cur_area {
smallest_container[i] = Some(j);
}
}
}
}
let mut outer_idx_to_piece_idx: Vec<Option<usize>> = vec![None; n];
let mut pieces: Vec<RegionPiece> = Vec::new();
for (i, (ring, _)) in kept.iter().enumerate() {
if containment_depth[i] % 2 == 0 {
outer_idx_to_piece_idx[i] = Some(pieces.len());
let outer = if signed_polygon_area(ring) >= 0.0 {
ring.clone()
} else {
reverse_polygon(ring)
};
pieces.push(RegionPiece {
outer,
holes: Vec::new(),
});
}
}
for (i, (ring, _)) in kept.iter().enumerate() {
if containment_depth[i] % 2 == 1 {
if let Some(parent_idx) = smallest_container[i].and_then(|k| outer_idx_to_piece_idx[k])
{
let hole = if signed_polygon_area(ring) <= 0.0 {
ring.clone()
} else {
reverse_polygon(ring)
};
pieces[parent_idx].holes.push(hole);
}
}
}
pieces
}
#[cfg(feature = "plotting")]
pub(crate) fn poi_with_holes(pieces: &[RegionPiece], precision: f64) -> Option<(Point, f64)> {
fn min_dist_to_rings(px: f64, py: f64, rings: &[&[Point]]) -> f64 {
let mut best = f64::INFINITY;
for ring in rings {
let n = ring.len();
if n < 2 {
continue;
}
for i in 0..n {
let a = ring[i];
let b = ring[(i + 1) % n];
let dx = b.x() - a.x();
let dy = b.y() - a.y();
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 {
(((px - a.x()) * dx + (py - a.y()) * dy) / len2).clamp(0.0, 1.0)
} else {
0.0
};
let qx = a.x() + t * dx;
let qy = a.y() + t * dy;
let d = ((px - qx).powi(2) + (py - qy).powi(2)).sqrt();
if d < best {
best = d;
}
}
}
best
}
fn signed_clearance(px: f64, py: f64, piece: &RegionPiece) -> f64 {
let mut all: Vec<&[Point]> = Vec::with_capacity(1 + piece.holes.len());
all.push(piece.outer.vertices());
for h in &piece.holes {
all.push(h.vertices());
}
let dist = min_dist_to_rings(px, py, &all);
let probe = Point::new(px, py);
let in_outer = point_in_polygon(&probe, &piece.outer);
let in_any_hole = piece.holes.iter().any(|h| point_in_polygon(&probe, h));
if in_outer && !in_any_hole {
dist
} else {
-dist
}
}
let mut best_overall: Option<(Point, f64)> = None;
for piece in pieces {
let outer_verts = piece.outer.vertices();
if outer_verts.len() < 3 {
continue;
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for p in outer_verts {
min_x = min_x.min(p.x());
max_x = max_x.max(p.x());
min_y = min_y.min(p.y());
max_y = max_y.max(p.y());
}
let width = max_x - min_x;
let height = max_y - min_y;
if width <= 0.0 || height <= 0.0 {
continue;
}
let cell_size = width.min(height);
#[derive(Copy, Clone)]
struct Cell {
x: f64,
y: f64,
h: f64,
d: f64, ub: f64, }
impl Eq for Cell {}
impl PartialEq for Cell {
fn eq(&self, other: &Self) -> bool {
self.ub == other.ub
}
}
impl PartialOrd for Cell {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Cell {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.ub
.partial_cmp(&other.ub)
.unwrap_or(std::cmp::Ordering::Equal)
}
}
let make_cell = |x: f64, y: f64, h: f64| -> Cell {
let d = signed_clearance(x, y, piece);
Cell {
x,
y,
h,
d,
ub: d + h * std::f64::consts::SQRT_2,
}
};
let mut queue: std::collections::BinaryHeap<Cell> = std::collections::BinaryHeap::new();
let mut x = min_x;
let h0 = cell_size / 2.0;
while x < max_x {
let mut y = min_y;
while y < max_y {
queue.push(make_cell(x + h0, y + h0, h0));
y += cell_size;
}
x += cell_size;
}
let cx = (min_x + max_x) / 2.0;
let cy = (min_y + max_y) / 2.0;
let mut best = make_cell(cx, cy, 0.0);
while let Some(cell) = queue.pop() {
if cell.d > best.d {
best = cell;
}
if cell.ub - best.d <= precision {
continue;
}
let nh = cell.h / 2.0;
queue.push(make_cell(cell.x - nh, cell.y - nh, nh));
queue.push(make_cell(cell.x + nh, cell.y - nh, nh));
queue.push(make_cell(cell.x - nh, cell.y + nh, nh));
queue.push(make_cell(cell.x + nh, cell.y + nh, nh));
}
if best.d > 0.0 {
let pt = Point::new(best.x, best.y);
if best_overall.map(|(_, d)| best.d > d).unwrap_or(true) {
best_overall = Some((pt, best.d));
}
}
}
best_overall
}
pub fn decompose_regions<S: DiagramShape + Polygonize>(
shapes: &[S],
set_names: &[String],
spec: &DiagramSpec,
n_vertices: usize,
) -> RegionPolygons {
decompose_regions_with(
shapes,
set_names,
spec,
n_vertices,
DEFAULT_SLIVER_THRESHOLD,
)
}
pub const DEFAULT_SLIVER_THRESHOLD: f64 = 1e-3;
pub fn decompose_regions_with<S: DiagramShape + Polygonize>(
shapes: &[S],
set_names: &[String],
_spec: &DiagramSpec,
n_vertices: usize,
sliver_threshold: f64,
) -> RegionPolygons {
if shapes.is_empty() {
return RegionPolygons::new();
}
let shape_polygons: Vec<Polygon> = shapes.iter().map(|s| s.polygonize(n_vertices)).collect();
let mut raw: Vec<(Combination, Vec<RegionPiece>)> = Vec::new();
let n = shapes.len();
let intersections = collect_intersections_generic(shapes, n);
let mut region_masks = discover_regions(shapes, &intersections, n);
region_masks.sort_unstable();
for mask in region_masks {
let set_indices_in_combo: Vec<usize> = (0..n).filter(|&i| (mask >> i) & 1 == 1).collect();
if set_indices_in_combo.is_empty() {
continue;
}
let mut current_polygons = vec![shape_polygons[set_indices_in_combo[0]].clone()];
for &idx in &set_indices_in_combo[1..] {
current_polygons = polygon_clip_many(
¤t_polygons,
&shape_polygons[idx],
ClipOperation::Intersection,
);
if current_polygons.is_empty() {
break;
}
}
if current_polygons.is_empty() {
continue;
}
for (idx, _) in shapes.iter().enumerate() {
if !set_indices_in_combo.contains(&idx) {
current_polygons = polygon_clip_many(
¤t_polygons,
&shape_polygons[idx],
ClipOperation::Difference,
);
if current_polygons.is_empty() {
break;
}
}
}
let pieces = classify_into_pieces(current_polygons);
if pieces.is_empty() {
continue;
}
let combo_sets: Vec<&str> = set_indices_in_combo
.iter()
.map(|&i| set_names[i].as_str())
.collect();
let combination = Combination::new(&combo_sets);
raw.push((combination, pieces));
}
let max_piece_area = raw
.iter()
.flat_map(|(_, pieces)| pieces.iter().map(|p| p.area()))
.fold(0.0_f64, f64::max);
let min_keep = if sliver_threshold > 0.0 {
max_piece_area * sliver_threshold
} else {
0.0
};
let mut result = RegionPolygons::new();
for (combo, pieces) in raw {
let kept: Vec<RegionPiece> = pieces
.into_iter()
.filter(|p| p.area() >= min_keep)
.collect();
if !kept.is_empty() {
result.insert(combo, kept);
}
}
result
}
fn collect_intersections_generic<S: Closed>(shapes: &[S], n_sets: usize) -> Vec<IntersectionPoint> {
let mut intersections = Vec::new();
for i in 0..n_sets {
for j in (i + 1)..n_sets {
let pts = shapes[i].intersection_points(&shapes[j]);
for point in pts {
let mut adopters = vec![i, j];
for (k, shape) in shapes.iter().enumerate() {
if k != i && k != j && shape.contains_point(&point) {
adopters.push(k);
}
}
adopters.sort_unstable();
intersections.push(IntersectionPoint::new(point, (i, j), adopters));
}
}
}
intersections
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitter::Fitter;
use crate::geometry::shapes::Circle;
use crate::spec::{DiagramSpecBuilder, InputType};
#[test]
fn test_decompose_disjoint_circles_skips_pairwise() {
let circles = [
Circle::new(Point::new(0.0, 0.0), 1.0),
Circle::new(Point::new(10.0, 0.0), 1.0),
];
let set_names = vec!["A".to_string(), "B".to_string()];
let spec = DiagramSpecBuilder::new()
.set("A", std::f64::consts::PI)
.set("B", std::f64::consts::PI)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let regions = decompose_regions(&circles, &set_names, &spec, 64);
assert_eq!(regions.len(), 2, "expected only A-only and B-only regions");
assert!(regions.get(&Combination::new(&["A"])).is_some());
assert!(regions.get(&Combination::new(&["B"])).is_some());
assert!(
regions.get(&Combination::new(&["A", "B"])).is_none(),
"disjoint pair should not appear in region polygons"
);
}
#[test]
fn test_decompose_many_disjoint_circles_scales_sparsely() {
let n = 20;
let circles: Vec<Circle> = (0..n)
.map(|i| Circle::new(Point::new(10.0 * i as f64, 0.0), 1.0))
.collect();
let set_names: Vec<String> = (0..n).map(|i| format!("S{i}")).collect();
let mut builder = DiagramSpecBuilder::new();
for name in &set_names {
builder = builder.set(name.clone(), std::f64::consts::PI);
}
let spec = builder.input_type(InputType::Exclusive).build().unwrap();
let regions = decompose_regions(&circles, &set_names, &spec, 32);
assert_eq!(regions.len(), n, "expected one region per disjoint circle");
}
#[test]
fn test_decompose_two_squares() {
use crate::geometry::shapes::Square;
let spec = DiagramSpecBuilder::new()
.set("A", 4.0)
.set("B", 4.0)
.intersection(&["A", "B"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Square>::new(&spec).seed(42).fit().unwrap();
let shapes: Vec<Square> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 64);
assert!(!regions.is_empty(), "no regions decomposed");
for (combo, polys) in regions.iter() {
assert!(!polys.is_empty(), "Region {:?} should have polygons", combo);
}
}
#[test]
fn test_decompose_two_circles() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 3.0)
.intersection(&["A", "B"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
let shapes: Vec<Circle> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 64);
assert!(regions.len() >= 2);
for (combo, polys) in regions.iter() {
assert!(!polys.is_empty(), "Region {:?} should have polygons", combo);
}
}
#[test]
fn test_decompose_three_circles() {
let spec = DiagramSpecBuilder::new()
.set("A", 4.0)
.set("B", 4.0)
.set("C", 4.0)
.intersection(&["A", "B"], 1.0)
.intersection(&["B", "C"], 1.0)
.intersection(&["A", "C"], 1.0)
.intersection(&["A", "B", "C"], 0.5)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(123).fit().unwrap();
let shapes: Vec<Circle> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 64);
assert!(regions.len() >= 3);
}
#[test]
fn test_label_points_two_circles() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 3.0)
.intersection(&["A", "B"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
let shapes: Vec<Circle> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 64);
let labels = regions.label_points(0.01);
for combo in regions.iter().map(|(c, _)| c) {
assert!(
labels.contains_key(combo),
"Missing label point for region {:?}",
combo
);
}
for (combo, pieces) in regions.iter() {
let label = labels.get(combo).unwrap();
let largest = pieces
.iter()
.max_by(|a, b| a.area().partial_cmp(&b.area()).unwrap())
.unwrap();
let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for v in largest.outer.vertices() {
min_x = min_x.min(v.x());
min_y = min_y.min(v.y());
max_x = max_x.max(v.x());
max_y = max_y.max(v.y());
}
assert!(
label.x() >= min_x - 1e-9
&& label.x() <= max_x + 1e-9
&& label.y() >= min_y - 1e-9
&& label.y() <= max_y + 1e-9,
"Label for {:?} at ({:.3}, {:.3}) is outside its region's bounding box [{:.3}, {:.3}] x [{:.3}, {:.3}]",
combo,
label.x(),
label.y(),
min_x,
max_x,
min_y,
max_y
);
}
}
#[test]
fn test_set_label_points_two_circles() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 3.0)
.intersection(&["A", "B"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
let shapes: Vec<Circle> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 128);
let set_labels = regions.set_label_points(spec.set_names(), 0.01);
assert_eq!(set_labels.len(), 2);
for name in ["A", "B"] {
let label = set_labels.get(name).expect("missing set label");
let circle = layout.shape_for_set(name).unwrap();
let dx = label.x() - circle.center().x();
let dy = label.y() - circle.center().y();
let r = circle.radius();
assert!(
dx * dx + dy * dy <= r * r + 1e-6,
"set label for {} at ({:.3}, {:.3}) is outside circle (center=({:.3}, {:.3}), r={:.3})",
name,
label.x(),
label.y(),
circle.center().x(),
circle.center().y(),
r,
);
}
}
#[test]
fn test_set_label_points_skips_absent_sets() {
let regions = RegionPolygons::new();
let names = vec!["A".to_string()];
assert!(regions.set_label_points(&names, 0.01).is_empty());
}
#[test]
fn test_label_points_empty() {
let empty = RegionPolygons::new();
assert!(empty.label_points(0.01).is_empty());
}
#[test]
fn test_label_points_skips_zero_area_regions() {
let mut regions = RegionPolygons::new();
let degenerate = Polygon::new(vec![
Point::new(0.0, 0.0),
Point::new(1.0, 0.0),
Point::new(0.5, 0.0), ]);
let piece = RegionPiece {
outer: degenerate,
holes: Vec::new(),
};
regions.insert(Combination::new(&["X"]), vec![piece]);
assert!(regions.label_points(0.01).is_empty());
}
#[test]
fn test_region_areas() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 3.0)
.intersection(&["A", "B"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let layout = Fitter::<Circle>::new(&spec).seed(42).fit().unwrap();
let shapes: Vec<Circle> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 128);
let areas = regions.areas();
let total_area: f64 = areas.values().sum();
let expected_total: f64 = spec.exclusive_areas().values().sum();
assert!(
(total_area - expected_total).abs() < 0.5,
"Total area {:.3} should be close to expected {:.3}",
total_area,
expected_total
);
}
#[test]
fn test_decompose_with_zero_spec_area() {
let spec = DiagramSpecBuilder::new()
.set("A", 3.0)
.set("B", 5.0)
.intersection(&["A", "B", "C"], 1.0)
.input_type(InputType::Exclusive)
.build()
.unwrap();
let c_combo = crate::spec::Combination::new(&["C"]);
assert!(
spec.exclusive_areas().get(&c_combo).copied().unwrap_or(0.0) < 1e-10,
"Spec should have zero area for C-only"
);
use crate::geometry::shapes::Ellipse;
let layout = Fitter::<Ellipse>::new(&spec).seed(1).fit().unwrap();
let shapes: Vec<Ellipse> = spec
.set_names()
.iter()
.map(|name| *layout.shape_for_set(name).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, 64);
let abc_combo = crate::spec::Combination::new(&["A", "B", "C"]);
let abc_polygons = regions.get(&abc_combo);
assert!(abc_polygons.is_some(), "Should have polygons for A&B&C");
let total_area: f64 = regions.areas().values().sum();
assert!(
total_area > 5.0,
"Total area should be substantial, got {:.3}",
total_area
);
}
}