use std::collections::HashMap;
use crate::geometry::primitives::Point;
use crate::geometry::shapes::{Polygon, Rectangle};
use crate::plotting::clip::polygon_union_many;
use crate::plotting::inscribed::{fit_label_in_region, principal_axis};
use crate::plotting::regions::{
classify_into_pieces, signed_clearance, RegionPiece, RegionPolygons,
};
use crate::spec::Combination;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LabelPlacement {
pub anchor: Point,
pub kind: PlacementKind,
pub tether: Option<Point>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlacementKind {
Interior,
ExteriorRaycast,
ExteriorForceDirected,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExteriorPolicy {
Raycast { margin: Option<f64> },
ForceDirected {
margin: Option<f64>,
iterations: Option<usize>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TetherSource {
#[default]
Poi,
Boundary,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacementStrategy {
pub exterior: ExteriorPolicy,
pub precision: f64,
pub tether: TetherSource,
}
impl Default for PlacementStrategy {
fn default() -> Self {
Self {
exterior: ExteriorPolicy::Raycast { margin: None },
precision: 0.01,
tether: TetherSource::Poi,
}
}
}
pub fn place_labels(
regions: &RegionPolygons,
sizes: &HashMap<String, (f64, f64)>,
container: Option<&Rectangle>,
strategy: &PlacementStrategy,
) -> HashMap<String, LabelPlacement> {
let exterior_kind = match strategy.exterior {
ExteriorPolicy::Raycast { margin } => ExteriorPlan::Raycast { margin },
ExteriorPolicy::ForceDirected { margin, iterations } => ExteriorPlan::ForceDirected {
margin,
iterations: iterations.unwrap_or(DEFAULT_FORCE_DIRECTED_ITERATIONS),
},
};
let union_pieces: Vec<RegionPiece> = if container.is_none() {
build_diagram_union(regions)
} else {
Vec::new()
};
let diagram_bbox = match container {
Some(rect) => Some(*rect),
None => union_bbox(regions),
};
let centroid = diagram_bbox.map(|r| *r.center());
let pois = regions.label_points(strategy.precision);
let mut out: HashMap<String, LabelPlacement> = HashMap::with_capacity(sizes.len());
let mut exteriors: Vec<ExteriorEntry> = Vec::new();
let raycast_margin_opt = match exterior_kind {
ExteriorPlan::Raycast { margin } => margin,
ExteriorPlan::ForceDirected { margin, .. } => margin,
};
for (key, &(w, h)) in sizes {
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
continue;
}
let combo: Combination = match key.parse() {
Ok(c) => c,
Err(_) => continue,
};
let Some(pieces) = regions.get(&combo) else {
continue;
};
if let Some(anchor) = fit_label_in_region(pieces, w, h, strategy.precision) {
out.insert(
key.clone(),
LabelPlacement {
anchor,
kind: PlacementKind::Interior,
tether: None,
},
);
continue;
}
let Some(bbox) = diagram_bbox else { continue };
let Some(centroid) = centroid else { continue };
let Some(poi) = pois.get(&combo).copied() else {
continue;
};
let direction = direction_from(&poi, ¢roid, pieces);
let margin = raycast_margin_opt.unwrap_or_else(|| 0.5 * w.max(h));
let anchor = raycast_anchor_union(&poi, w, h, &union_pieces, margin, direction)
.unwrap_or_else(|| raycast_anchor(&poi, w, h, &bbox, margin, direction));
exteriors.push(ExteriorEntry {
key: key.clone(),
combo,
anchor,
home: anchor,
poi,
direction,
margin,
w,
h,
});
}
let interior_aabbs: Vec<InteriorAabb> = out
.iter()
.filter(|(_, p)| p.kind == PlacementKind::Interior)
.filter_map(|(k, p)| {
let (w, h) = *sizes.get(k)?;
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
Some(InteriorAabb {
xmin: p.anchor.x() - 0.5 * w,
ymin: p.anchor.y() - 0.5 * h,
xmax: p.anchor.x() + 0.5 * w,
ymax: p.anchor.y() + 0.5 * h,
})
})
.collect();
let exterior_kind_label = match exterior_kind {
ExteriorPlan::Raycast { .. } => {
resolve_exterior_collisions(&mut exteriors, &interior_aabbs, 50);
PlacementKind::ExteriorRaycast
}
ExteriorPlan::ForceDirected { iterations, .. } => {
if let Some(bbox) = diagram_bbox {
resolve_force_directed(
&mut exteriors,
regions,
&union_pieces,
&bbox,
&interior_aabbs,
iterations,
);
}
PlacementKind::ExteriorForceDirected
}
};
for entry in exteriors {
let tether_pt = match strategy.tether {
TetherSource::Poi => entry.poi,
TetherSource::Boundary => {
let dx = entry.anchor.x() - entry.poi.x();
let dy = entry.anchor.y() - entry.poi.y();
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
entry.poi
} else {
let dir = (dx / len, dy / len);
let pieces = regions
.get(&entry.combo)
.map(|v| v.as_slice())
.unwrap_or(&[]);
ray_first_edge_exit(&entry.poi, dir, pieces).unwrap_or(entry.poi)
}
}
};
out.insert(
entry.key,
LabelPlacement {
anchor: entry.anchor,
kind: exterior_kind_label,
tether: Some(tether_pt),
},
);
}
out
}
pub fn placements_bbox(
placements: &HashMap<String, LabelPlacement>,
sizes: &HashMap<String, (f64, f64)>,
) -> Option<Rectangle> {
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;
let mut found = false;
for (key, placement) in placements {
let Some(&(w, h)) = sizes.get(key) else {
continue;
};
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
continue;
}
let half_w = 0.5 * w;
let half_h = 0.5 * h;
let cx = placement.anchor.x();
let cy = placement.anchor.y();
if !(cx.is_finite() && cy.is_finite()) {
continue;
}
min_x = min_x.min(cx - half_w);
max_x = max_x.max(cx + half_w);
min_y = min_y.min(cy - half_h);
max_y = max_y.max(cy + half_h);
found = true;
}
if !found {
return None;
}
let cx = 0.5 * (min_x + max_x);
let cy = 0.5 * (min_y + max_y);
Some(Rectangle::new(
Point::new(cx, cy),
max_x - min_x,
max_y - min_y,
))
}
pub fn place_labels_to_fixed_point<F>(
regions: &RegionPolygons,
container: Option<&Rectangle>,
initial_sizes: HashMap<String, (f64, f64)>,
strategy: &PlacementStrategy,
mut measure: F,
bbox_tolerance: f64,
max_iters: usize,
) -> HashMap<String, LabelPlacement>
where
F: FnMut(&Rectangle) -> HashMap<String, (f64, f64)>,
{
let mut sizes = initial_sizes;
let mut placements = place_labels(regions, &sizes, container, strategy);
let mut prev_short =
canvas_bbox(regions, container, &placements, &sizes).map(|r| r.width().min(r.height()));
for _ in 0..max_iters {
let Some(prev_bbox) = canvas_bbox(regions, container, &placements, &sizes) else {
return placements;
};
let new_sizes = measure(&prev_bbox);
let new_placements = place_labels(regions, &new_sizes, container, strategy);
let new_short = canvas_bbox(regions, container, &new_placements, &new_sizes)
.map(|r| r.width().min(r.height()));
let converged = match (prev_short, new_short) {
(Some(a), Some(b)) if a > 0.0 => (a - b).abs() / a <= bbox_tolerance,
_ => true,
};
sizes = new_sizes;
placements = new_placements;
prev_short = new_short;
if converged {
return placements;
}
}
placements
}
fn canvas_bbox(
regions: &RegionPolygons,
container: Option<&Rectangle>,
placements: &HashMap<String, LabelPlacement>,
sizes: &HashMap<String, (f64, f64)>,
) -> Option<Rectangle> {
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;
let mut found = false;
let mut consume = |xmin: f64, xmax: f64, ymin: f64, ymax: f64| {
if xmin.is_finite() && xmax.is_finite() && ymin.is_finite() && ymax.is_finite() {
min_x = min_x.min(xmin);
max_x = max_x.max(xmax);
min_y = min_y.min(ymin);
max_y = max_y.max(ymax);
found = true;
}
};
if let Some(r) = union_bbox(regions) {
let (xmin, xmax, ymin, ymax) = r.bounds();
consume(xmin, xmax, ymin, ymax);
}
if let Some(c) = container {
let (xmin, xmax, ymin, ymax) = c.bounds();
consume(xmin, xmax, ymin, ymax);
}
if let Some(b) = placements_bbox(placements, sizes) {
let (xmin, xmax, ymin, ymax) = b.bounds();
consume(xmin, xmax, ymin, ymax);
}
if !found {
return None;
}
let cx = 0.5 * (min_x + max_x);
let cy = 0.5 * (min_y + max_y);
Some(Rectangle::new(
Point::new(cx, cy),
max_x - min_x,
max_y - min_y,
))
}
const DEFAULT_FORCE_DIRECTED_ITERATIONS: usize = 200;
#[derive(Clone, Copy)]
enum ExteriorPlan {
Raycast {
margin: Option<f64>,
},
ForceDirected {
margin: Option<f64>,
iterations: usize,
},
}
struct InteriorAabb {
xmin: f64,
ymin: f64,
xmax: f64,
ymax: f64,
}
struct ExteriorEntry {
key: String,
combo: Combination,
anchor: Point,
home: Point,
poi: Point,
direction: (f64, f64),
margin: f64,
w: f64,
h: f64,
}
fn resolve_exterior_collisions(
entries: &mut [ExteriorEntry],
interior_aabbs: &[InteriorAabb],
max_iters: usize,
) {
if entries.is_empty() {
return;
}
let eps = 1e-9;
for _ in 0..max_iters {
let mut moved = false;
for entry in entries.iter_mut() {
for aabb in interior_aabbs {
let Some((t_enter, t_exit)) = segment_vs_aabb(
&entry.poi,
&entry.anchor,
aabb.xmin,
aabb.ymin,
aabb.xmax,
aabb.ymax,
) else {
continue;
};
let push = leader_avoidance_push(entry, aabb, t_enter, t_exit);
if push.is_none() {
continue;
}
let (dx, dy) = push.unwrap();
entry.anchor = Point::new(entry.anchor.x() + dx, entry.anchor.y() + dy);
moved = true;
}
}
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let (left, right) = entries.split_at_mut(j);
let a = &mut left[i];
let b = &mut right[0];
let dx = a.anchor.x() - b.anchor.x();
let dy = a.anchor.y() - b.anchor.y();
let half_sum_w = 0.5 * (a.w + b.w);
let half_sum_h = 0.5 * (a.h + b.h);
let ox = half_sum_w - dx.abs();
let oy = half_sum_h - dy.abs();
if ox <= 0.0 || oy <= 0.0 {
continue;
}
let tax = -a.direction.1;
let tay = a.direction.0;
let tbx = -b.direction.1;
let tby = b.direction.0;
let mut tx = 0.5 * (tax + tbx);
let mut ty = 0.5 * (tay + tby);
let tlen = (tx * tx + ty * ty).sqrt();
if tlen < eps {
tx = tax;
ty = tay;
} else {
tx /= tlen;
ty /= tlen;
}
let mid_x = 0.5 * (a.anchor.x() + b.anchor.x());
let mid_y = 0.5 * (a.anchor.y() + b.anchor.y());
let sa_t = (a.anchor.x() - mid_x) * tx + (a.anchor.y() - mid_y) * ty;
let sign = if sa_t >= 0.0 { 1.0 } else { -1.0 };
let push = 0.5 * (ox.min(oy) + 1e-6);
a.anchor = Point::new(
a.anchor.x() + sign * push * tx,
a.anchor.y() + sign * push * ty,
);
b.anchor = Point::new(
b.anchor.x() - sign * push * tx,
b.anchor.y() - sign * push * ty,
);
moved = true;
}
}
if !moved {
break;
}
}
}
fn resolve_force_directed(
entries: &mut [ExteriorEntry],
regions: &RegionPolygons,
union_pieces: &[RegionPiece],
bbox: &Rectangle,
interior_aabbs: &[InteriorAabb],
max_iters: usize,
) {
if entries.is_empty() || max_iters == 0 {
return;
}
let bbox_short = bbox.width().min(bbox.height()).max(1e-6);
let tolerance = 1e-4 * bbox_short;
let spring = 0.05_f64;
let foreign_pieces: Vec<Vec<&RegionPiece>> = entries
.iter()
.map(|entry| {
let mut foreign: Vec<&RegionPiece> = Vec::new();
for (combo, pieces) in regions.iter() {
if combo == &entry.combo {
continue;
}
for piece in pieces {
foreign.push(piece);
}
}
foreign
})
.collect();
for _ in 0..max_iters {
let mut max_move: f64 = 0.0;
for i in 0..entries.len() {
let prev = entries[i].anchor;
let dx = entries[i].home.x() - entries[i].anchor.x();
let dy = entries[i].home.y() - entries[i].anchor.y();
entries[i].anchor = Point::new(prev.x() + spring * dx, prev.y() + spring * dy);
let (bx, by) = if union_pieces.is_empty() {
bbox_push_along(
&entries[i].anchor,
0.5 * entries[i].w,
0.5 * entries[i].h,
bbox,
entries[i].margin,
entries[i].direction,
)
} else {
union_push_along(
&entries[i].anchor,
0.5 * entries[i].w,
0.5 * entries[i].h,
union_pieces,
entries[i].margin,
entries[i].direction,
)
};
entries[i].anchor = Point::new(entries[i].anchor.x() + bx, entries[i].anchor.y() + by);
let buffer = 0.5 * entries[i].w.max(entries[i].h);
for piece in &foreign_pieces[i] {
let (px, py) = polygon_push(&entries[i].anchor, buffer, piece);
entries[i].anchor =
Point::new(entries[i].anchor.x() + px, entries[i].anchor.y() + py);
}
for aabb in interior_aabbs {
let Some((t_enter, t_exit)) = segment_vs_aabb(
&entries[i].poi,
&entries[i].anchor,
aabb.xmin,
aabb.ymin,
aabb.xmax,
aabb.ymax,
) else {
continue;
};
if let Some((dx, dy)) = leader_avoidance_push(&entries[i], aabb, t_enter, t_exit) {
entries[i].anchor =
Point::new(entries[i].anchor.x() + dx, entries[i].anchor.y() + dy);
}
}
let moved = ((entries[i].anchor.x() - prev.x()).powi(2)
+ (entries[i].anchor.y() - prev.y()).powi(2))
.sqrt();
max_move = max_move.max(moved);
}
const MAX_LABEL_COLLISION_SWEEPS: usize = 50;
for _ in 0..MAX_LABEL_COLLISION_SWEEPS {
let mut moved_any = false;
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let (left, right) = entries.split_at_mut(j);
let a = &mut left[i];
let b = &mut right[0];
let dx = a.anchor.x() - b.anchor.x();
let dy = a.anchor.y() - b.anchor.y();
let half_sum_w = 0.5 * (a.w + b.w);
let half_sum_h = 0.5 * (a.h + b.h);
let ox = half_sum_w - dx.abs();
let oy = half_sum_h - dy.abs();
if ox <= 0.0 || oy <= 0.0 {
continue;
}
let len = (dx * dx + dy * dy).sqrt();
let (nx, ny) = if len > 1e-9 {
(dx / len, dy / len)
} else {
(1.0, 0.0)
};
let push = 0.5 * (ox.min(oy) + 1e-6);
a.anchor = Point::new(a.anchor.x() + push * nx, a.anchor.y() + push * ny);
b.anchor = Point::new(b.anchor.x() - push * nx, b.anchor.y() - push * ny);
max_move = max_move.max(push);
moved_any = true;
}
}
if !moved_any {
break;
}
}
if max_move < tolerance {
break;
}
}
}
fn bbox_push_along(
center: &Point,
half_w: f64,
half_h: f64,
bbox: &Rectangle,
margin: f64,
dir: (f64, f64),
) -> (f64, f64) {
let (xmin, xmax, ymin, ymax) = bbox.bounds();
let xmin = xmin - margin - half_w;
let xmax = xmax + margin + half_w;
let ymin = ymin - margin - half_h;
let ymax = ymax + margin + half_h;
let inside = center.x() > xmin && center.x() < xmax && center.y() > ymin && center.y() < ymax;
if !inside {
return (0.0, 0.0);
}
let eps = 1e-9;
let mut t_min = f64::INFINITY;
if dir.0 > eps {
let t = (xmax - center.x()) / dir.0;
if t > 0.0 && t < t_min {
t_min = t;
}
} else if dir.0 < -eps {
let t = (xmin - center.x()) / dir.0;
if t > 0.0 && t < t_min {
t_min = t;
}
}
if dir.1 > eps {
let t = (ymax - center.y()) / dir.1;
if t > 0.0 && t < t_min {
t_min = t;
}
} else if dir.1 < -eps {
let t = (ymin - center.y()) / dir.1;
if t > 0.0 && t < t_min {
t_min = t;
}
}
if t_min.is_finite() {
let push = t_min + eps;
(push * dir.0, push * dir.1)
} else {
(0.0, 0.0)
}
}
fn polygon_push(center: &Point, buffer: f64, piece: &RegionPiece) -> (f64, f64) {
let signed = signed_clearance(center.x(), center.y(), piece);
let penetration = buffer + signed;
if penetration <= 0.0 {
return (0.0, 0.0);
}
let (qx, qy) = closest_point_on_piece(center.x(), center.y(), piece);
let dx = center.x() - qx;
let dy = center.y() - qy;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return (penetration, 0.0);
}
let sign = if signed > 0.0 { -1.0 } else { 1.0 };
let nx = sign * dx / len;
let ny = sign * dy / len;
let push = penetration + 1e-9;
(push * nx, push * ny)
}
fn closest_point_on_piece(px: f64, py: f64, piece: &RegionPiece) -> (f64, f64) {
let mut best_d2 = f64::INFINITY;
let mut best = (px, py);
let mut update_with = |ring: &crate::geometry::shapes::Polygon| {
let v = ring.vertices();
let n = v.len();
if n < 2 {
return;
}
for i in 0..n {
let a = v[i];
let b = v[(i + 1) % n];
let ex = b.x() - a.x();
let ey = b.y() - a.y();
let len2 = ex * ex + ey * ey;
let t = if len2 > 0.0 {
(((px - a.x()) * ex + (py - a.y()) * ey) / len2).clamp(0.0, 1.0)
} else {
0.0
};
let qx = a.x() + t * ex;
let qy = a.y() + t * ey;
let dx = px - qx;
let dy = py - qy;
let d2 = dx * dx + dy * dy;
if d2 < best_d2 {
best_d2 = d2;
best = (qx, qy);
}
}
};
update_with(&piece.outer);
for h in &piece.holes {
update_with(h);
}
best
}
fn union_bbox(regions: &RegionPolygons) -> Option<Rectangle> {
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;
let mut found = false;
for (_, pieces) in regions.iter() {
for piece in pieces {
for v in piece.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());
found = true;
}
}
}
if !found
|| !min_x.is_finite()
|| !max_x.is_finite()
|| !min_y.is_finite()
|| !max_y.is_finite()
|| max_x <= min_x
|| max_y <= min_y
{
return None;
}
let cx = 0.5 * (min_x + max_x);
let cy = 0.5 * (min_y + max_y);
Some(Rectangle::new(
Point::new(cx, cy),
max_x - min_x,
max_y - min_y,
))
}
fn direction_from(poi: &Point, centroid: &Point, pieces: &[RegionPiece]) -> (f64, f64) {
let dx = poi.x() - centroid.x();
let dy = poi.y() - centroid.y();
let mag = (dx * dx + dy * dy).sqrt();
let eps = 1e-9;
if mag > eps {
return (dx / mag, dy / mag);
}
let largest = pieces.iter().max_by(|a, b| {
a.area()
.partial_cmp(&b.area())
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(piece) = largest {
let (angle, elongation) = principal_axis(piece);
if elongation >= 1.05 {
return (angle.cos(), angle.sin());
}
}
(0.0, 1.0)
}
fn build_diagram_union(regions: &RegionPolygons) -> Vec<RegionPiece> {
let mut outers: Vec<Polygon> = Vec::new();
for (_, pieces) in regions.iter() {
for piece in pieces {
if !piece.outer.vertices().is_empty() {
outers.push(piece.outer.clone());
}
}
}
if outers.is_empty() {
return Vec::new();
}
let rings = polygon_union_many(&outers);
classify_into_pieces(rings)
}
fn last_vertex_clearance_t(
origin: &Point,
dir: (f64, f64),
half_w_m: f64,
half_h_m: f64,
union_pieces: &[RegionPiece],
) -> Option<f64> {
let (ox, oy) = (origin.x(), origin.y());
let (dx, dy) = dir;
let eps = 1e-12;
let mut t_max = f64::NEG_INFINITY;
let mut found = false;
for piece in union_pieces {
for v in piece.outer.vertices() {
let px = v.x();
let py = v.y();
let (tx_lo, tx_hi) = if dx.abs() < eps {
if (px - ox).abs() <= half_w_m {
(f64::NEG_INFINITY, f64::INFINITY)
} else {
continue;
}
} else {
let a = (px - ox - half_w_m) / dx;
let b = (px - ox + half_w_m) / dx;
if a < b {
(a, b)
} else {
(b, a)
}
};
let (ty_lo, ty_hi) = if dy.abs() < eps {
if (py - oy).abs() <= half_h_m {
(f64::NEG_INFINITY, f64::INFINITY)
} else {
continue;
}
} else {
let a = (py - oy - half_h_m) / dy;
let b = (py - oy + half_h_m) / dy;
if a < b {
(a, b)
} else {
(b, a)
}
};
let t_in_lo = tx_lo.max(ty_lo);
let t_in_hi = tx_hi.min(ty_hi);
if t_in_lo < t_in_hi && t_in_hi > 0.0 {
if t_in_hi > t_max {
t_max = t_in_hi;
}
found = true;
}
}
}
if found {
Some(t_max)
} else {
None
}
}
fn raycast_anchor_union(
poi: &Point,
w: f64,
h: f64,
union_pieces: &[RegionPiece],
margin: f64,
direction: (f64, f64),
) -> Option<Point> {
if union_pieces.is_empty() {
return None;
}
let half_w_m = 0.5 * w + margin;
let half_h_m = 0.5 * h + margin;
let t = last_vertex_clearance_t(poi, direction, half_w_m, half_h_m, union_pieces)?;
let t = t.max(0.0);
Some(Point::new(
poi.x() + t * direction.0,
poi.y() + t * direction.1,
))
}
fn union_push_along(
center: &Point,
half_w: f64,
half_h: f64,
union_pieces: &[RegionPiece],
margin: f64,
dir: (f64, f64),
) -> (f64, f64) {
let half_w_m = half_w + margin;
let half_h_m = half_h + margin;
let Some(t) = last_vertex_clearance_t(center, dir, half_w_m, half_h_m, union_pieces) else {
return (0.0, 0.0);
};
if t <= 0.0 {
return (0.0, 0.0);
}
(t * dir.0, t * dir.1)
}
fn raycast_anchor(
poi: &Point,
w: f64,
h: f64,
bbox: &Rectangle,
margin: f64,
direction: (f64, f64),
) -> Point {
let (dx, dy) = direction;
let half_w = 0.5 * w;
let half_h = 0.5 * h;
let bx = *bbox.center();
let bw = 0.5 * bbox.width();
let bh = 0.5 * bbox.height();
let bbox_min_x = bx.x() - bw;
let bbox_max_x = bx.x() + bw;
let bbox_min_y = bx.y() - bh;
let bbox_max_y = bx.y() + bh;
let eps = 1e-9;
let mut best_t = f64::INFINITY;
if dx > eps {
let need = (bbox_max_x + margin + half_w - poi.x()) / dx;
if need < best_t {
best_t = need;
}
} else if dx < -eps {
let need = (bbox_min_x - margin - half_w - poi.x()) / dx;
if need < best_t {
best_t = need;
}
}
if dy > eps {
let need = (bbox_max_y + margin + half_h - poi.y()) / dy;
if need < best_t {
best_t = need;
}
} else if dy < -eps {
let need = (bbox_min_y - margin - half_h - poi.y()) / dy;
if need < best_t {
best_t = need;
}
}
let t = if best_t.is_finite() {
best_t.max(0.0)
} else {
0.0
};
Point::new(poi.x() + t * dx, poi.y() + t * dy)
}
fn ray_first_edge_exit(origin: &Point, dir: (f64, f64), pieces: &[RegionPiece]) -> Option<Point> {
let (ox, oy) = (origin.x(), origin.y());
let (rx, ry) = dir;
if rx.abs() < f64::EPSILON && ry.abs() < f64::EPSILON {
return None;
}
let eps = 1e-9;
let mut best_t = f64::INFINITY;
for piece in pieces {
let verts = piece.outer.vertices();
let n = verts.len();
if n < 2 {
continue;
}
for i in 0..n {
let a = &verts[i];
let b = &verts[(i + 1) % n];
let sx = b.x() - a.x();
let sy = b.y() - a.y();
let denom = rx * sy - ry * sx;
if denom.abs() < eps {
continue; }
let wx = a.x() - ox;
let wy = a.y() - oy;
let t = (wx * sy - wy * sx) / denom;
let u = (wx * ry - wy * rx) / denom;
if t > eps && (-eps..=1.0 + eps).contains(&u) && t < best_t {
best_t = t;
}
}
}
if best_t.is_finite() {
Some(Point::new(ox + best_t * rx, oy + best_t * ry))
} else {
None
}
}
fn leader_avoidance_push(
entry: &ExteriorEntry,
aabb: &InteriorAabb,
t_enter: f64,
t_exit: f64,
) -> Option<(f64, f64)> {
let t_mid = 0.5 * (t_enter + t_exit);
if !t_mid.is_finite() || t_mid <= 1e-6 {
return None;
}
let tx = -entry.direction.1;
let ty = entry.direction.0;
let cx = 0.5 * (aabb.xmin + aabb.xmax);
let cy = 0.5 * (aabb.ymin + aabb.ymax);
let half_tan =
0.5 * (aabb.xmax - aabb.xmin) * tx.abs() + 0.5 * (aabb.ymax - aabb.ymin) * ty.abs();
let proj = (entry.anchor.x() - cx) * tx + (entry.anchor.y() - cy) * ty;
let sign = if proj >= 0.0 { 1.0 } else { -1.0 };
let mag = (half_tan + 1e-6) / t_mid;
Some((sign * mag * tx, sign * mag * ty))
}
fn segment_vs_aabb(
p0: &Point,
p1: &Point,
xmin: f64,
ymin: f64,
xmax: f64,
ymax: f64,
) -> Option<(f64, f64)> {
let dx = p1.x() - p0.x();
let dy = p1.y() - p0.y();
let mut t_min = 0.0_f64;
let mut t_max = 1.0_f64;
for &(o, d, lo, hi) in &[(p0.x(), dx, xmin, xmax), (p0.y(), dy, ymin, ymax)] {
if d.abs() < f64::EPSILON {
if o < lo || o > hi {
return None;
}
} else {
let t1 = (lo - o) / d;
let t2 = (hi - o) / d;
let (t_lo, t_hi) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
if t_lo > t_min {
t_min = t_lo;
}
if t_hi < t_max {
t_max = t_hi;
}
if t_min > t_max {
return None;
}
}
}
Some((t_min, t_max))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitter::Fitter;
use crate::geometry::primitives::Point;
use crate::geometry::shapes::{Circle, Polygon};
use crate::plotting::regions::RegionPiece;
use crate::plotting::{decompose_regions, RegionPolygons};
use crate::spec::{Combination, DiagramSpecBuilder, InputType};
fn axis_aligned_square_piece(side: f64) -> RegionPiece {
let s = side;
RegionPiece {
outer: Polygon::new(vec![
Point::new(0.0, 0.0),
Point::new(s, 0.0),
Point::new(s, s),
Point::new(0.0, s),
]),
holes: vec![],
}
}
fn single_region(combo: &[&str], pieces: Vec<RegionPiece>) -> RegionPolygons {
let mut map = HashMap::new();
map.insert(Combination::new(combo), pieces);
RegionPolygons::from_map(map)
}
#[test]
fn test_default_strategy_is_raycast() {
let s = PlacementStrategy::default();
assert!(matches!(
s.exterior,
ExteriorPolicy::Raycast { margin: None }
));
assert!((s.precision - 0.01).abs() < 1e-12);
assert_eq!(s.tether, TetherSource::Poi);
}
#[test]
fn test_strict_raycast_interior_fit() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (1.0, 0.5));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::Interior);
assert!(p.tether.is_none());
assert!((0.0..=10.0).contains(&p.anchor.x()));
assert!((0.0..=10.0).contains(&p.anchor.y()));
}
#[test]
fn test_strict_raycast_exterior_fallback() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
let tether = p.tether.expect("exterior placement should have a tether");
assert!((tether.x() - 5.0).abs() < 0.5);
assert!((tether.y() - 5.0).abs() < 0.5);
let margin = 0.5 * 20.0;
let half_h = 10.0;
assert!(
p.anchor.y() >= 10.0 + margin + half_h - 1e-6,
"anchor y = {}",
p.anchor.y()
);
}
#[test]
fn test_strict_raycast_uses_centroid_to_poi_direction() {
let mut regions = RegionPolygons::new();
regions.insert(
Combination::new(&["A"]),
vec![RegionPiece {
outer: Polygon::new(vec![
Point::new(0.0, 0.0),
Point::new(2.0, 0.0),
Point::new(2.0, 2.0),
Point::new(0.0, 2.0),
]),
holes: vec![],
}],
);
regions.insert(
Combination::new(&["B"]),
vec![RegionPiece {
outer: Polygon::new(vec![
Point::new(40.0, 40.0),
Point::new(60.0, 40.0),
Point::new(60.0, 60.0),
Point::new(40.0, 60.0),
]),
holes: vec![],
}],
);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (5.0, 5.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
assert!(p.anchor.x() < 0.0, "anchor.x = {}", p.anchor.x());
assert!(p.anchor.y() < 0.0, "anchor.y = {}", p.anchor.y());
}
#[test]
fn test_strict_raycast_with_container() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let container = Rectangle::new(Point::new(5.0, 5.0), 100.0, 100.0);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (50.0, 50.0));
let placements = place_labels(
®ions,
&sizes,
Some(&container),
&PlacementStrategy::default(),
);
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
let margin = 25.0;
let half = 25.0;
let outside_top = p.anchor.y() >= 55.0 + margin + half - 1e-6;
let outside_bottom = p.anchor.y() <= -45.0 - margin - half + 1e-6;
let outside_right = p.anchor.x() >= 55.0 + margin + half - 1e-6;
let outside_left = p.anchor.x() <= -45.0 - margin - half + 1e-6;
assert!(
outside_top || outside_bottom || outside_right || outside_left,
"anchor {:?} not outside container + margin",
p.anchor
);
}
#[test]
fn test_strict_raycast_isotropic_centroid_tiebreak() {
let outer = Polygon::new(vec![
Point::new(-5.0, -5.0),
Point::new(5.0, -5.0),
Point::new(5.0, 5.0),
Point::new(-5.0, 5.0),
]);
let regions = single_region(
&["A"],
vec![RegionPiece {
outer,
holes: vec![],
}],
);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
assert!(p.anchor.x().abs() < 1e-6, "anchor x = {}", p.anchor.x());
assert!(p.anchor.y() > 5.0, "anchor y = {}", p.anchor.y());
}
#[test]
fn test_unknown_keys_skipped() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("Z".to_string(), (1.0, 1.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
assert!(placements.is_empty());
}
#[test]
fn test_invalid_dimensions_skipped() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (0.0, 1.0));
sizes.insert("B".to_string(), (f64::NAN, 1.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
assert!(placements.is_empty());
}
#[test]
fn test_strict_raycast_resolves_collisions_between_exterior_labels() {
let outer = || {
Polygon::new(vec![
Point::new(-5.0, -5.0),
Point::new(5.0, -5.0),
Point::new(5.0, 5.0),
Point::new(-5.0, 5.0),
])
};
let mut regions_map = HashMap::new();
for combo in &[&["A"][..], &["B"][..], &["C"][..]] {
regions_map.insert(
Combination::new(combo),
vec![RegionPiece {
outer: outer(),
holes: vec![],
}],
);
}
let regions = RegionPolygons::from_map(regions_map);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
sizes.insert("B".to_string(), (20.0, 20.0));
sizes.insert("C".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
assert_eq!(placements.len(), 3);
let entries: Vec<&LabelPlacement> = ["A", "B", "C"]
.iter()
.map(|k| placements.get(*k).expect("placed"))
.collect();
for p in &entries {
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
}
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let dx = (entries[i].anchor.x() - entries[j].anchor.x()).abs();
let dy = (entries[i].anchor.y() - entries[j].anchor.y()).abs();
assert!(
dx >= 20.0 - 1e-6 || dy >= 20.0 - 1e-6,
"pair ({}, {}) overlaps: dx = {}, dy = {}",
i,
j,
dx,
dy
);
}
}
}
#[test]
fn test_strict_raycast_no_collisions_leaves_anchors_unchanged() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").unwrap();
assert!((p.anchor.x() - 5.0).abs() < 1e-6);
assert!(p.anchor.y() >= 30.0 - 1e-6);
}
#[test]
fn test_two_circle_decomposition_all_regions_placed() {
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(|n| *layout.shape_for_set(n).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, None, 64);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (0.2, 0.1));
sizes.insert("A&B".to_string(), (100.0, 100.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
assert!(placements.contains_key("A"));
let ab = placements.get("A&B").expect("A&B should be placed");
assert_eq!(ab.kind, PlacementKind::ExteriorRaycast);
assert!(ab.tether.is_some());
}
fn diamond_piece(cx: f64, cy: f64, r: f64) -> RegionPiece {
RegionPiece {
outer: Polygon::new(vec![
Point::new(cx + r, cy),
Point::new(cx, cy + r),
Point::new(cx - r, cy),
Point::new(cx, cy - r),
]),
holes: vec![],
}
}
#[test]
fn test_raycast_uses_union_polygon_on_diagonal_direction() {
let mut map = HashMap::new();
let a_piece = RegionPiece {
outer: Polygon::new(vec![
Point::new(-10.0, -10.0),
Point::new(0.0, -10.0),
Point::new(0.0, 0.0),
Point::new(-10.0, 0.0),
]),
holes: vec![],
};
map.insert(Combination::new(&["A"]), vec![a_piece]);
map.insert(
Combination::new(&["B"]),
vec![diamond_piece(10.0, 10.0, 5.0)],
);
let regions = RegionPolygons::from_map(map);
let mut sizes = HashMap::new();
sizes.insert("B".to_string(), (10.0, 10.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("B").expect("B should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
let dx = p.anchor.x() - 10.0;
let dy = p.anchor.y() - 10.0;
let dist_from_poi = (dx * dx + dy * dy).sqrt();
let aabb_only = 15.0 * std::f64::consts::SQRT_2;
assert!(
dist_from_poi < aabb_only - 1.0,
"expected union-polygon raycast to be tighter than AABB ({aabb_only}); got {dist_from_poi}",
);
let half = 5.0;
let margin = 5.0;
let cx = p.anchor.x();
let cy = p.anchor.y();
for (vx, vy) in [(15.0_f64, 10.0_f64), (10.0, 15.0), (5.0, 10.0), (10.0, 5.0)] {
let qx = vx.clamp(cx - half, cx + half);
let qy = vy.clamp(cy - half, cy + half);
let d = ((vx - qx).powi(2) + (vy - qy).powi(2)).sqrt();
assert!(
d >= margin - 1e-6,
"diamond vertex ({vx}, {vy}) too close to label box at ({cx}, {cy}): \
distance {d} < margin {margin}",
);
}
}
fn force_directed_strategy() -> PlacementStrategy {
PlacementStrategy {
exterior: ExteriorPolicy::ForceDirected {
margin: None,
iterations: None,
},
..PlacementStrategy::default()
}
}
#[test]
fn test_force_directed_interior_fit_unchanged() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (1.0, 0.5));
let placements = place_labels(®ions, &sizes, None, &force_directed_strategy());
let p = placements.get("A").unwrap();
assert_eq!(p.kind, PlacementKind::Interior);
assert!(p.tether.is_none());
}
#[test]
fn test_force_directed_exterior_kind_and_tether() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &force_directed_strategy());
let p = placements.get("A").unwrap();
assert_eq!(p.kind, PlacementKind::ExteriorForceDirected);
let tether = p.tether.expect("force-directed exterior must have tether");
assert!((tether.x() - 5.0).abs() < 0.5);
assert!((tether.y() - 5.0).abs() < 0.5);
}
#[test]
fn test_force_directed_resolves_label_label_overlap() {
let outer = || {
Polygon::new(vec![
Point::new(-5.0, -5.0),
Point::new(5.0, -5.0),
Point::new(5.0, 5.0),
Point::new(-5.0, 5.0),
])
};
let mut regions_map = HashMap::new();
for combo in &[&["A"][..], &["B"][..], &["C"][..]] {
regions_map.insert(
Combination::new(combo),
vec![RegionPiece {
outer: outer(),
holes: vec![],
}],
);
}
let regions = RegionPolygons::from_map(regions_map);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
sizes.insert("B".to_string(), (20.0, 20.0));
sizes.insert("C".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &force_directed_strategy());
let entries: Vec<&LabelPlacement> = ["A", "B", "C"]
.iter()
.map(|k| placements.get(*k).expect("placed"))
.collect();
for p in &entries {
assert_eq!(p.kind, PlacementKind::ExteriorForceDirected);
}
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let dx = (entries[i].anchor.x() - entries[j].anchor.x()).abs();
let dy = (entries[i].anchor.y() - entries[j].anchor.y()).abs();
assert!(
dx >= 20.0 - 1e-3 || dy >= 20.0 - 1e-3,
"pair ({}, {}) overlaps after ForceDirected: dx = {}, dy = {}",
i,
j,
dx,
dy
);
}
}
}
#[test]
fn test_force_directed_avoids_foreign_polygon() {
let mut regions_map = HashMap::new();
regions_map.insert(
Combination::new(&["A"]),
vec![RegionPiece {
outer: Polygon::new(vec![
Point::new(0.0, 0.0),
Point::new(4.0, 0.0),
Point::new(4.0, 4.0),
Point::new(0.0, 4.0),
]),
holes: vec![],
}],
);
regions_map.insert(
Combination::new(&["B"]),
vec![RegionPiece {
outer: Polygon::new(vec![
Point::new(10.0, 0.0),
Point::new(14.0, 0.0),
Point::new(14.0, 4.0),
Point::new(10.0, 4.0),
]),
holes: vec![],
}],
);
let regions = RegionPolygons::from_map(regions_map);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (5.0, 5.0));
let strategy = PlacementStrategy {
exterior: ExteriorPolicy::ForceDirected {
margin: Some(0.5),
iterations: Some(300),
},
precision: 0.05,
tether: TetherSource::Poi,
};
let placements = place_labels(®ions, &sizes, None, &strategy);
let p = placements.get("A").unwrap();
assert_eq!(p.kind, PlacementKind::ExteriorForceDirected);
let half = 2.5;
let label_xmin = p.anchor.x() - half;
let label_xmax = p.anchor.x() + half;
let label_ymin = p.anchor.y() - half;
let label_ymax = p.anchor.y() + half;
let overlap_x = label_xmax > 10.0 && label_xmin < 14.0;
let overlap_y = label_ymax > 0.0 && label_ymin < 4.0;
assert!(
!(overlap_x && overlap_y),
"A's label box at ({}, {}) overlaps B's region",
p.anchor.x(),
p.anchor.y()
);
}
#[test]
fn test_force_directed_two_circle_decomposition() {
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(|n| *layout.shape_for_set(n).unwrap())
.collect();
let regions = decompose_regions(&shapes, spec.set_names(), &spec, None, 64);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (0.2, 0.1));
sizes.insert("A&B".to_string(), (100.0, 100.0));
let placements = place_labels(®ions, &sizes, None, &force_directed_strategy());
assert!(placements.contains_key("A"));
let ab = placements.get("A&B").expect("A&B should be placed");
assert_eq!(ab.kind, PlacementKind::ExteriorForceDirected);
assert!(ab.tether.is_some());
}
#[test]
fn test_force_directed_zero_iterations_keeps_raycast_anchor() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let strategy = PlacementStrategy {
exterior: ExteriorPolicy::ForceDirected {
margin: None,
iterations: Some(0),
},
precision: 0.01,
tether: TetherSource::Poi,
};
let placements = place_labels(®ions, &sizes, None, &strategy);
let p = placements.get("A").unwrap();
assert_eq!(p.kind, PlacementKind::ExteriorForceDirected);
assert!((p.anchor.x() - 5.0).abs() < 1e-6);
assert!(p.anchor.y() >= 30.0 - 1e-6);
}
fn placement(x: f64, y: f64, kind: PlacementKind) -> LabelPlacement {
LabelPlacement {
anchor: Point::new(x, y),
kind,
tether: None,
}
}
#[test]
fn test_placements_bbox_unions_label_boxes() {
let mut placements = HashMap::new();
placements.insert(
"A".to_string(),
placement(0.0, 0.0, PlacementKind::Interior),
);
placements.insert(
"B".to_string(),
placement(10.0, 5.0, PlacementKind::ExteriorRaycast),
);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (4.0, 2.0));
sizes.insert("B".to_string(), (4.0, 2.0));
let bbox = placements_bbox(&placements, &sizes).expect("bbox");
assert!((bbox.center().x() - 5.0).abs() < 1e-9);
assert!((bbox.center().y() - 2.5).abs() < 1e-9);
assert!((bbox.width() - 14.0).abs() < 1e-9);
assert!((bbox.height() - 7.0).abs() < 1e-9);
}
#[test]
fn test_placements_bbox_single_placement() {
let mut placements = HashMap::new();
placements.insert(
"A".to_string(),
placement(3.0, 4.0, PlacementKind::Interior),
);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (2.0, 2.0));
let bbox = placements_bbox(&placements, &sizes).expect("bbox");
assert!((bbox.center().x() - 3.0).abs() < 1e-9);
assert!((bbox.center().y() - 4.0).abs() < 1e-9);
assert!((bbox.width() - 2.0).abs() < 1e-9);
assert!((bbox.height() - 2.0).abs() < 1e-9);
}
#[test]
fn test_placements_bbox_empty_returns_none() {
let placements: HashMap<String, LabelPlacement> = HashMap::new();
let sizes: HashMap<String, (f64, f64)> = HashMap::new();
assert!(placements_bbox(&placements, &sizes).is_none());
}
#[test]
fn test_placements_bbox_skips_missing_sizes() {
let mut placements = HashMap::new();
placements.insert(
"A".to_string(),
placement(0.0, 0.0, PlacementKind::Interior),
);
placements.insert(
"B".to_string(),
placement(10.0, 0.0, PlacementKind::Interior),
);
let mut sizes = HashMap::new();
sizes.insert("B".to_string(), (4.0, 2.0));
let bbox = placements_bbox(&placements, &sizes).expect("bbox");
assert!((bbox.center().x() - 10.0).abs() < 1e-9);
assert!((bbox.width() - 4.0).abs() < 1e-9);
}
#[test]
fn test_placements_bbox_skips_invalid_dimensions() {
let mut placements = HashMap::new();
placements.insert(
"A".to_string(),
placement(0.0, 0.0, PlacementKind::Interior),
);
placements.insert(
"B".to_string(),
placement(5.0, 5.0, PlacementKind::Interior),
);
placements.insert(
"C".to_string(),
placement(8.0, 8.0, PlacementKind::Interior),
);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (0.0, 1.0)); sizes.insert("B".to_string(), (f64::NAN, 1.0)); sizes.insert("C".to_string(), (2.0, 2.0)); let bbox = placements_bbox(&placements, &sizes).expect("bbox");
assert!((bbox.center().x() - 8.0).abs() < 1e-9);
assert!((bbox.center().y() - 8.0).abs() < 1e-9);
}
#[test]
fn test_fixed_point_converges_with_inverse_scaling() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut initial = HashMap::new();
initial.insert("A".to_string(), (20.0, 20.0));
let mut iter_count = 0usize;
let mut last_size: f64 = 0.0;
let placements = place_labels_to_fixed_point(
®ions,
None,
initial,
&PlacementStrategy::default(),
|bbox| {
iter_count += 1;
let s = bbox.width().min(bbox.height()) * 0.6;
last_size = s;
let mut out = HashMap::new();
out.insert("A".to_string(), (s, s));
out
},
1e-3,
10,
);
let p = placements.get("A").expect("A placed");
assert!(matches!(
p.kind,
PlacementKind::Interior | PlacementKind::ExteriorRaycast
));
assert!(iter_count >= 1, "measure closure was never called");
assert!(iter_count < 10, "loop didn't converge within 10 iters");
assert!(last_size > 0.0, "measure produced a zero size");
}
#[test]
fn test_fixed_point_returns_after_max_iters() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut initial = HashMap::new();
initial.insert("A".to_string(), (20.0, 20.0));
let mut iter_count = 0usize;
let placements = place_labels_to_fixed_point(
®ions,
None,
initial,
&PlacementStrategy::default(),
|bbox| {
iter_count += 1;
let grow = bbox.width().max(bbox.height()) * 1.5;
let mut out = HashMap::new();
out.insert("A".to_string(), (grow, grow));
out
},
1e-3,
5,
);
assert_eq!(iter_count, 5, "expected exactly max_iters measure calls");
assert!(placements.contains_key("A"));
}
fn rect_piece(x: f64, y: f64, w: f64, h: f64) -> RegionPiece {
RegionPiece {
outer: Polygon::new(vec![
Point::new(x, y),
Point::new(x + w, y),
Point::new(x + w, y + h),
Point::new(x, y + h),
]),
holes: vec![],
}
}
#[test]
fn test_segment_vs_aabb_hit_and_miss() {
let p0 = Point::new(0.0, 1.0);
let p1 = Point::new(10.0, 1.0);
let hit = segment_vs_aabb(&p0, &p1, 3.0, 0.5, 5.0, 1.5);
let (te, tx) = hit.expect("segment should hit");
assert!((te - 0.3).abs() < 1e-9);
assert!((tx - 0.5).abs() < 1e-9);
let p2 = Point::new(0.0, 10.0);
let p3 = Point::new(10.0, 10.0);
assert!(segment_vs_aabb(&p2, &p3, 3.0, 0.5, 5.0, 1.5).is_none());
}
#[test]
fn test_ray_first_edge_exit_centred() {
let pieces = vec![axis_aligned_square_piece(10.0)];
let exit = ray_first_edge_exit(&Point::new(5.0, 5.0), (1.0, 0.0), &pieces)
.expect("ray should exit through the right edge");
assert!((exit.x() - 10.0).abs() < 1e-9);
assert!((exit.y() - 5.0).abs() < 1e-9);
}
#[test]
fn test_ray_first_edge_exit_no_hit() {
let pieces = vec![axis_aligned_square_piece(10.0)];
assert!(ray_first_edge_exit(&Point::new(-1.0, 5.0), (-1.0, 0.0), &pieces).is_none());
}
#[test]
fn test_boundary_tether_lands_on_polygon_edge() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let strategy = PlacementStrategy {
tether: TetherSource::Boundary,
..PlacementStrategy::default()
};
let placements = place_labels(®ions, &sizes, None, &strategy);
let p = placements.get("A").expect("A should be placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
let tether = p.tether.expect("exterior placement should have a tether");
let on_left = (tether.x()).abs() < 1e-6;
let on_right = (tether.x() - 10.0).abs() < 1e-6;
let on_bottom = (tether.y()).abs() < 1e-6;
let on_top = (tether.y() - 10.0).abs() < 1e-6;
assert!(
(on_left || on_right || on_bottom || on_top)
&& (0.0..=10.0).contains(&tether.x())
&& (0.0..=10.0).contains(&tether.y()),
"tether ({}, {}) should lie on the 10×10 boundary",
tether.x(),
tether.y(),
);
}
#[test]
fn test_poi_tether_is_default() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("A").expect("A should be placed");
let tether = p.tether.expect("exterior placement should have a tether");
assert!((tether.x() - 5.0).abs() < 0.5);
assert!((tether.y() - 5.0).abs() < 0.5);
}
#[test]
fn test_boundary_tether_falls_back_to_poi_on_degenerate_ray() {
let entry = ExteriorEntry {
key: "A".to_string(),
combo: Combination::new(&["A"]),
anchor: Point::new(5.0, 5.0),
home: Point::new(5.0, 5.0),
poi: Point::new(5.0, 5.0),
direction: (1.0, 0.0),
margin: 0.0,
w: 1.0,
h: 1.0,
};
let pieces = vec![axis_aligned_square_piece(10.0)];
assert!(ray_first_edge_exit(&entry.poi, (0.0, 0.0), &pieces).is_none());
}
#[test]
fn test_leader_avoidance_raycast_pushes_anchor_clear_of_interior_label() {
let mut map = HashMap::new();
map.insert(
Combination::new(&["A"]),
vec![rect_piece(4.0, 0.0, 2.0, 2.0)],
);
map.insert(
Combination::new(&["B"]),
vec![rect_piece(0.0, 0.0, 3.0, 2.0)],
);
map.insert(
Combination::new(&["C"]),
vec![rect_piece(7.0, 0.0, 2.0, 2.0)],
);
let regions = RegionPolygons::from_map(map);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 10.0));
sizes.insert("B".to_string(), (1.0, 0.5));
sizes.insert("C".to_string(), (1.0, 0.5));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let a = placements.get("A").expect("A should be placed");
let c = placements.get("C").expect("C should be placed");
assert_eq!(a.kind, PlacementKind::ExteriorRaycast);
assert_eq!(c.kind, PlacementKind::Interior);
let tether = a.tether.expect("A should have a tether");
let c_xmin = c.anchor.x() - 0.5 * 1.0;
let c_xmax = c.anchor.x() + 0.5 * 1.0;
let c_ymin = c.anchor.y() - 0.5 * 0.5;
let c_ymax = c.anchor.y() + 0.5 * 0.5;
assert!(
segment_vs_aabb(&tether, &a.anchor, c_xmin, c_ymin, c_xmax, c_ymax).is_none(),
"leader segment ({}, {}) → ({}, {}) still crosses C's AABB ({}, {}, {}, {})",
tether.x(),
tether.y(),
a.anchor.x(),
a.anchor.y(),
c_xmin,
c_ymin,
c_xmax,
c_ymax,
);
}
#[test]
fn test_leader_avoidance_force_directed_pushes_anchor_clear_of_interior_label() {
let mut map = HashMap::new();
map.insert(
Combination::new(&["A"]),
vec![rect_piece(4.0, 0.0, 2.0, 2.0)],
);
map.insert(
Combination::new(&["B"]),
vec![rect_piece(0.0, 0.0, 3.0, 2.0)],
);
map.insert(
Combination::new(&["C"]),
vec![rect_piece(7.0, 0.0, 2.0, 2.0)],
);
let regions = RegionPolygons::from_map(map);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 10.0));
sizes.insert("B".to_string(), (1.0, 0.5));
sizes.insert("C".to_string(), (1.0, 0.5));
let strategy = PlacementStrategy {
exterior: ExteriorPolicy::ForceDirected {
margin: None,
iterations: Some(300),
},
precision: 0.01,
tether: TetherSource::Poi,
};
let placements = place_labels(®ions, &sizes, None, &strategy);
let a = placements.get("A").expect("A should be placed");
let c = placements.get("C").expect("C should be placed");
assert_eq!(a.kind, PlacementKind::ExteriorForceDirected);
assert_eq!(c.kind, PlacementKind::Interior);
let tether = a.tether.expect("A should have a tether");
let c_xmin = c.anchor.x() - 0.5 * 1.0;
let c_xmax = c.anchor.x() + 0.5 * 1.0;
let c_ymin = c.anchor.y() - 0.5 * 0.5;
let c_ymax = c.anchor.y() + 0.5 * 0.5;
assert!(
segment_vs_aabb(&tether, &a.anchor, c_xmin, c_ymin, c_xmax, c_ymax).is_none(),
"leader segment ({}, {}) → ({}, {}) still crosses C's AABB ({}, {}, {}, {})",
tether.x(),
tether.y(),
a.anchor.x(),
a.anchor.y(),
c_xmin,
c_ymin,
c_xmax,
c_ymax,
);
}
}