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::{
RegionPiece, RegionPolygons, classify_into_pieces, signed_clearance,
};
use crate::spec::Combination;
#[derive(Debug, Clone, PartialEq)]
pub struct LabelPlacement {
pub anchor: Point,
pub kind: PlacementKind,
pub tether: Option<Point>,
pub leader_end: Option<Point>,
pub leader_waypoints: Vec<Point>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlacementKind {
Interior,
ExteriorRaycast,
ExteriorForceDirected,
ExteriorElbow,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExteriorPolicy {
Raycast { margin: Option<f64> },
ForceDirected {
margin: Option<f64>,
iterations: Option<usize>,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElbowOptions {
pub margin: Option<f64>,
pub min_gap: Option<f64>,
}
impl Default for ElbowOptions {
fn default() -> Self {
Self {
margin: None,
min_gap: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TetherSource {
#[default]
Poi,
Boundary,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LeaderStrategy {
Straight(ExteriorPolicy),
Elbow(ElbowOptions),
}
impl Default for LeaderStrategy {
fn default() -> Self {
LeaderStrategy::Straight(ExteriorPolicy::Raycast { margin: None })
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacementStrategy {
pub leader: LeaderStrategy,
pub precision: f64,
pub tether: TetherSource,
pub leader_gap: f64,
}
impl Default for PlacementStrategy {
fn default() -> Self {
Self {
leader: LeaderStrategy::default(),
precision: 0.01,
tether: TetherSource::Poi,
leader_gap: 0.0,
}
}
}
pub fn place_labels(
regions: &RegionPolygons,
sizes: &HashMap<String, (f64, f64)>,
container: Option<&Rectangle>,
strategy: &PlacementStrategy,
) -> HashMap<String, LabelPlacement> {
let exterior = match strategy.leader {
LeaderStrategy::Straight(exterior) => exterior,
LeaderStrategy::Elbow(opts) => {
return place_elbow_labels(regions, sizes, container, strategy, opts);
}
};
let exterior_kind = match 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 degenerate_dir_threshold = diagram_bbox
.map(|r| 0.05 * r.width().hypot(r.height()))
.unwrap_or(0.0);
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,
leader_end: None,
leader_waypoints: Vec::new(),
},
);
continue;
}
let Some(bbox) = diagram_bbox else { continue };
let Some(centroid) = centroid else { continue };
let Some(poi) = pois.get(&combo).copied() else {
continue;
};
let mut direction = direction_from(&poi, ¢roid, pieces, degenerate_dir_threshold);
let margin = raycast_margin_opt.unwrap_or_else(|| 0.5 * w.max(h));
let mut anchor = raycast_anchor_union(&poi, w, h, &union_pieces, margin, direction)
.unwrap_or_else(|| raycast_anchor(&poi, w, h, &bbox, margin, direction));
if box_overlaps_foreign(&anchor, w, h, regions, &combo) {
let pa = principal_axis_direction(pieces);
for cand in [pa, (-pa.0, -pa.1)] {
let alt = raycast_anchor_union(&poi, w, h, &union_pieces, margin, cand)
.unwrap_or_else(|| raycast_anchor(&poi, w, h, &bbox, margin, cand));
if !box_overlaps_foreign(&alt, w, h, regions, &combo) {
direction = cand;
anchor = alt;
break;
}
}
}
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 pieces = regions
.get(&entry.combo)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let tether_pt = tether_point(
strategy.tether,
&entry.poi,
(
entry.anchor.x() - entry.poi.x(),
entry.anchor.y() - entry.poi.y(),
),
pieces,
);
let leader_end = leader_end_on_label_box(
&tether_pt,
&entry.anchor,
entry.w,
entry.h,
strategy.leader_gap,
);
out.insert(
entry.key,
LabelPlacement {
anchor: entry.anchor,
kind: exterior_kind_label,
tether: Some(tether_pt),
leader_end: Some(leader_end),
leader_waypoints: Vec::new(),
},
);
}
out
}
fn tether_point(
source: TetherSource,
poi: &Point,
dir: (f64, f64),
pieces: &[RegionPiece],
) -> Point {
match source {
TetherSource::Poi => *poi,
TetherSource::Boundary => {
let len = (dir.0 * dir.0 + dir.1 * dir.1).sqrt();
if len < 1e-12 {
return *poi;
}
let unit = (dir.0 / len, dir.1 / len);
ray_first_edge_exit(poi, unit, pieces).unwrap_or(*poi)
}
}
}
fn place_elbow_labels(
regions: &RegionPolygons,
sizes: &HashMap<String, (f64, f64)>,
container: Option<&Rectangle>,
strategy: &PlacementStrategy,
opts: ElbowOptions,
) -> HashMap<String, LabelPlacement> {
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());
struct ElbowCand {
key: String,
combo: Combination,
poi: Point,
w: f64,
h: f64,
}
let mut left: Vec<ElbowCand> = Vec::new();
let mut right: Vec<ElbowCand> = Vec::new();
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,
leader_end: None,
leader_waypoints: Vec::new(),
},
);
continue;
}
let Some(centroid) = centroid else { continue };
let Some(poi) = pois.get(&combo).copied() else {
continue;
};
let cand = ElbowCand {
key: key.clone(),
combo,
poi,
w,
h,
};
if poi.x() >= centroid.x() {
right.push(cand);
} else {
left.push(cand);
}
}
let Some(bbox) = diagram_bbox else {
return out;
};
let (xmin, xmax, _, _) = bbox.bounds();
let interior_band: Option<(f64, f64)> = {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (k, p) in out.iter() {
if p.kind != PlacementKind::Interior {
continue;
}
let Some(&(_, ih)) = sizes.get(k) else {
continue;
};
if !(ih.is_finite() && ih > 0.0) {
continue;
}
lo = lo.min(p.anchor.y() - 0.5 * ih);
hi = hi.max(p.anchor.y() + 0.5 * ih);
}
(lo.is_finite() && hi.is_finite() && hi >= lo).then_some((lo, hi))
};
for (side_right, mut col) in [(true, right), (false, left)] {
if col.is_empty() {
continue;
}
col.sort_by(|a, b| {
a.poi
.y()
.partial_cmp(&b.poi.y())
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.key.cmp(&b.key))
});
let col_margin = opts
.margin
.unwrap_or_else(|| 0.5 * col.iter().map(|c| c.w.max(c.h)).fold(0.0_f64, f64::max));
let bend_x = if side_right {
xmax + col_margin
} else {
xmin - col_margin
};
let sign = if side_right { 1.0 } else { -1.0 };
let n = col.len();
let rows: Vec<f64> = if let Some((band_lo, band_hi)) = interior_band {
let band_centre = 0.5 * (band_lo + band_hi);
let split = if n == 1 {
usize::from(col[0].poi.y() < band_centre)
} else {
n / 2
};
let mut rows = vec![0.0_f64; n];
let mut cursor = 0.0;
for k in 0..split {
let i = split - 1 - k;
cursor = if k == 0 {
band_lo - 0.5 * col[i].h
} else {
cursor - opts.min_gap.unwrap_or(1.5 * col[i].h.max(col[i + 1].h))
};
rows[i] = cursor;
}
for k in 0..(n - split) {
let i = split + k;
cursor = if k == 0 {
band_hi + 0.5 * col[i].h
} else {
cursor + opts.min_gap.unwrap_or(1.5 * col[i].h.max(col[i - 1].h))
};
rows[i] = cursor;
}
rows
} else {
let mut y: Vec<f64> = col.iter().map(|c| c.poi.y()).collect();
for i in 1..n {
let g = opts.min_gap.unwrap_or(1.5 * col[i - 1].h.max(col[i].h));
if y[i] < y[i - 1] + g {
y[i] = y[i - 1] + g;
}
}
for i in (0..n - 1).rev() {
let g = opts.min_gap.unwrap_or(1.5 * col[i].h.max(col[i + 1].h));
if y[i] > y[i + 1] - g {
y[i] = y[i + 1] - g;
}
}
let shift = 0.5 * (col[0].poi.y() + col[n - 1].poi.y()) - 0.5 * (y[0] + y[n - 1]);
for v in &mut y {
*v += shift;
}
y
};
let gap = strategy.leader_gap.max(0.0);
for (i, c) in col.into_iter().enumerate() {
let row_y = rows[i];
let anchor = Point::new(bend_x + sign * (gap + 0.5 * c.w), row_y);
let pieces = regions.get(&c.combo).map(|v| v.as_slice()).unwrap_or(&[]);
let dy = row_y - c.poi.y();
let tether = tether_point(strategy.tether, &c.poi, (0.0, dy), pieces);
let knee = Point::new(tether.x(), row_y);
let leader_end = leader_end_on_label_box(&knee, &anchor, c.w, c.h, gap);
out.insert(
c.key,
LabelPlacement {
anchor,
kind: PlacementKind::ExteriorElbow,
tether: Some(tether),
leader_end: Some(leader_end),
leader_waypoints: vec![knee],
},
);
}
}
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],
degenerate_threshold: f64,
) -> (f64, f64) {
let dx = poi.x() - centroid.x();
let dy = poi.y() - centroid.y();
let mag = (dx * dx + dy * dy).sqrt();
if mag > degenerate_threshold.max(1e-9) {
return (dx / mag, dy / mag);
}
principal_axis_direction(pieces)
}
fn principal_axis_direction(pieces: &[RegionPiece]) -> (f64, f64) {
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 box_overlaps_foreign(
center: &Point,
w: f64,
h: f64,
regions: &RegionPolygons,
own: &Combination,
) -> bool {
let (cx, cy) = (center.x(), center.y());
let (hw, hh) = (0.5 * w, 0.5 * h);
let probes = [
(cx, cy),
(cx - hw, cy - hh),
(cx + hw, cy - hh),
(cx - hw, cy + hh),
(cx + hw, cy + hh),
];
for (combo, pieces) in regions.iter() {
if combo == own {
continue;
}
for piece in pieces {
if probes
.iter()
.any(|&(px, py)| signed_clearance(px, py, piece) > 0.0)
{
return true;
}
if piece.outer.vertices().iter().any(|v| {
v.x() >= cx - hw && v.x() <= cx + hw && v.y() >= cy - hh && v.y() <= cy + hh
}) {
return true;
}
}
}
false
}
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 leader_end_on_label_box(tether: &Point, anchor: &Point, w: f64, h: f64, gap: f64) -> Point {
let gap = gap.max(0.0);
let half_w = 0.5 * w + gap;
let half_h = 0.5 * h + gap;
let xmin = anchor.x() - half_w;
let xmax = anchor.x() + half_w;
let ymin = anchor.y() - half_h;
let ymax = anchor.y() + half_h;
let Some((t_enter, _)) = segment_vs_aabb(tether, anchor, xmin, ymin, xmax, ymax) else {
return *anchor;
};
if t_enter <= 0.0 {
return *anchor;
}
let dx = anchor.x() - tether.x();
let dy = anchor.y() - tether.y();
Point::new(tether.x() + t_enter * dx, tether.y() + t_enter * dy)
}
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::{RegionPolygons, decompose_regions};
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.leader,
LeaderStrategy::Straight(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_interior_placement_has_no_leader_end() {
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.leader_end.is_none());
}
#[test]
fn test_exterior_raycast_leader_end_on_box_edge() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let (w, h) = (20.0, 20.0);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (w, h));
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");
let le = p
.leader_end
.expect("exterior placement should have leader_end");
let half_w = 0.5 * w;
let half_h = 0.5 * h;
let xmin = p.anchor.x() - half_w;
let xmax = p.anchor.x() + half_w;
let ymin = p.anchor.y() - half_h;
let ymax = p.anchor.y() + half_h;
let on_left = (le.x() - xmin).abs() < 1e-6;
let on_right = (le.x() - xmax).abs() < 1e-6;
let on_bottom = (le.y() - ymin).abs() < 1e-6;
let on_top = (le.y() - ymax).abs() < 1e-6;
let in_x = le.x() >= xmin - 1e-6 && le.x() <= xmax + 1e-6;
let in_y = le.y() >= ymin - 1e-6 && le.y() <= ymax + 1e-6;
assert!(
(on_left || on_right || on_bottom || on_top) && in_x && in_y,
"leader_end {:?} should lie on the label AABB edge ({:?} × {:?})",
(le.x(), le.y()),
(xmin, xmax),
(ymin, ymax),
);
let dx = p.anchor.x() - tether.x();
let dy = p.anchor.y() - tether.y();
let denom = dx * dx + dy * dy;
assert!(denom > 0.0, "anchor and tether should not coincide");
let t = ((le.x() - tether.x()) * dx + (le.y() - tether.y()) * dy) / denom;
assert!(
(0.0..=1.0).contains(&t),
"leader_end should sit on the tether→anchor segment (t = {})",
t
);
let reconstructed_x = tether.x() + t * dx;
let reconstructed_y = tether.y() + t * dy;
assert!((reconstructed_x - le.x()).abs() < 1e-6);
assert!((reconstructed_y - le.y()).abs() < 1e-6);
}
#[test]
fn test_exterior_force_directed_emits_leader_end() {
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 {
leader: LeaderStrategy::Straight(ExteriorPolicy::ForceDirected {
margin: None,
iterations: Some(10),
}),
..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::ExteriorForceDirected);
assert!(p.leader_end.is_some());
}
#[test]
fn test_leader_end_on_label_box_basic() {
let tether = Point::new(5.0, -5.0);
let anchor = Point::new(5.0, 10.0);
let le = leader_end_on_label_box(&tether, &anchor, 4.0, 2.0, 0.0);
assert!((le.x() - 5.0).abs() < 1e-9);
assert!((le.y() - 9.0).abs() < 1e-9);
}
#[test]
fn test_leader_end_with_gap_sits_on_inflated_box_edge() {
let tether = Point::new(5.0, -5.0);
let anchor = Point::new(5.0, 10.0);
let le = leader_end_on_label_box(&tether, &anchor, 4.0, 2.0, 0.5);
assert!((le.x() - 5.0).abs() < 1e-9);
assert!((le.y() - 8.5).abs() < 1e-9);
}
#[test]
fn test_leader_end_negative_gap_clamped_to_zero() {
let tether = Point::new(5.0, -5.0);
let anchor = Point::new(5.0, 10.0);
let le_neg = leader_end_on_label_box(&tether, &anchor, 4.0, 2.0, -1.0);
let le_zero = leader_end_on_label_box(&tether, &anchor, 4.0, 2.0, 0.0);
assert!((le_neg.x() - le_zero.x()).abs() < 1e-9);
assert!((le_neg.y() - le_zero.y()).abs() < 1e-9);
}
#[test]
fn test_leader_end_falls_back_when_tether_inside_box() {
let anchor = Point::new(5.0, 5.0);
let tether = Point::new(5.0, 5.0);
let le = leader_end_on_label_box(&tether, &anchor, 4.0, 2.0, 0.0);
assert!((le.x() - anchor.x()).abs() < 1e-9);
assert!((le.y() - anchor.y()).abs() < 1e-9);
}
#[test]
fn test_strategy_leader_gap_inflates_leader_end() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let (w, h) = (20.0, 20.0);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (w, h));
let base_strategy = PlacementStrategy::default();
let gap = 1.5;
let gap_strategy = PlacementStrategy {
leader_gap: gap,
..base_strategy
};
let base = place_labels(®ions, &sizes, None, &base_strategy)
.remove("A")
.expect("A placed");
let gapped = place_labels(®ions, &sizes, None, &gap_strategy)
.remove("A")
.expect("A placed");
let le0 = base.leader_end.expect("leader_end");
let le1 = gapped.leader_end.expect("leader_end");
assert!((le0.x() - le1.x()).abs() < 1e-6);
assert!(
(le0.y() - le1.y() - gap).abs() < 1e-6,
"gapped leader_end should sit `gap` units before the raw edge: \
base.y = {}, gapped.y = {}, gap = {}",
le0.y(),
le1.y(),
gap,
);
}
#[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_gap_straddling_sliver_deflects_to_principal_axis() {
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| RegionPiece {
outer: Polygon::new(vec![
Point::new(x0, y0),
Point::new(x1, y0),
Point::new(x1, y1),
Point::new(x0, y1),
]),
holes: vec![],
};
let mut map = HashMap::new();
map.insert(Combination::new(&["A"]), vec![rect(0.0, 0.0, 48.0, 48.0)]);
map.insert(Combination::new(&["B"]), vec![rect(48.0, 0.0, 49.0, 48.0)]);
map.insert(Combination::new(&["C"]), vec![rect(52.0, 0.0, 100.0, 48.0)]);
let regions = RegionPolygons::from_map(map);
let mut sizes = HashMap::new();
sizes.insert("B".to_string(), (20.0, 10.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("B").expect("B placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
assert!(
p.anchor.x() > 40.0,
"B should not be cast left into A: anchor.x = {}",
p.anchor.x()
);
assert!(
p.anchor.y() < 0.0 || p.anchor.y() > 48.0,
"B should be pushed vertically clear of the diagram body [0,48]: anchor.y = {}",
p.anchor.y()
);
}
#[test]
fn test_offcenter_edge_sliver_backstop_clears_neighbour() {
let rect = |verts: &[(f64, f64)]| RegionPiece {
outer: Polygon::new(verts.iter().map(|&(x, y)| Point::new(x, y)).collect()),
holes: vec![],
};
let mut map = HashMap::new();
map.insert(
Combination::new(&["L"]),
vec![rect(&[(0.0, 5.0), (40.0, 5.0), (40.0, 45.0), (0.0, 45.0)])],
);
map.insert(
Combination::new(&["C"]),
vec![rect(&[
(60.0, 5.0),
(100.0, 5.0),
(100.0, 45.0),
(60.0, 45.0),
(60.0, 25.0),
])],
);
map.insert(
Combination::new(&["D"]),
vec![rect(&[
(59.0, 20.0),
(60.0, 20.0),
(60.0, 30.0),
(59.0, 30.0),
])],
);
let regions = RegionPolygons::from_map(map);
let mut sizes = HashMap::new();
sizes.insert("D".to_string(), (8.0, 6.0));
let placements = place_labels(®ions, &sizes, None, &PlacementStrategy::default());
let p = placements.get("D").expect("D placed");
assert_eq!(p.kind, PlacementKind::ExteriorRaycast);
assert!(
p.anchor.y() < 5.0 || p.anchor.y() > 45.0,
"D should be deflected vertically clear of C's body: anchor.y = {}",
p.anchor.y()
);
assert!(
p.anchor.x() < 65.0,
"D should not be driven right into C: anchor.x = {}",
p.anchor.x()
);
assert!(
!box_overlaps_foreign(&p.anchor, 8.0, 6.0, ®ions, &Combination::new(&["D"])),
"D's box still overlaps a foreign region at ({}, {})",
p.anchor.x(),
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 {
leader: LeaderStrategy::Straight(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 {
leader: LeaderStrategy::Straight(ExteriorPolicy::ForceDirected {
margin: Some(0.5),
iterations: Some(300),
}),
precision: 0.05,
tether: TetherSource::Poi,
leader_gap: 0.0,
};
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 {
leader: LeaderStrategy::Straight(ExteriorPolicy::ForceDirected {
margin: None,
iterations: Some(0),
}),
precision: 0.01,
tether: TetherSource::Poi,
leader_gap: 0.0,
};
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,
leader_end: None,
leader_waypoints: Vec::new(),
}
}
#[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 {
leader: LeaderStrategy::Straight(ExteriorPolicy::ForceDirected {
margin: None,
iterations: Some(300),
}),
precision: 0.01,
tether: TetherSource::Poi,
leader_gap: 0.0,
};
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,
);
}
#[test]
fn test_straight_leader_has_no_waypoints() {
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);
assert!(p.tether.is_some());
assert!(p.leader_end.is_some());
assert!(
p.leader_waypoints.is_empty(),
"straight leaders carry no intermediate waypoints"
);
}
fn elbow_strategy() -> PlacementStrategy {
PlacementStrategy {
leader: LeaderStrategy::Elbow(ElbowOptions::default()),
..PlacementStrategy::default()
}
}
fn square_piece_at(x0: f64, y0: f64, side: f64) -> RegionPiece {
RegionPiece {
outer: Polygon::new(vec![
Point::new(x0, y0),
Point::new(x0 + side, y0),
Point::new(x0 + side, y0 + side),
Point::new(x0, y0 + side),
]),
holes: vec![],
}
}
fn regions_from(entries: Vec<(&[&str], Vec<RegionPiece>)>) -> RegionPolygons {
let mut map = HashMap::new();
for (combo, pieces) in entries {
map.insert(Combination::new(combo), pieces);
}
RegionPolygons::from_map(map)
}
#[test]
fn test_elbow_default_options() {
let opts = ElbowOptions::default();
assert!(opts.margin.is_none());
assert!(opts.min_gap.is_none());
assert!(matches!(elbow_strategy().leader, LeaderStrategy::Elbow(_)));
}
#[test]
fn test_elbow_column_assignment_left_right() {
let regions = regions_from(vec![
(&["A"][..], vec![square_piece_at(0.0, 0.0, 10.0)]),
(&["B"][..], vec![square_piece_at(20.0, 0.0, 10.0)]),
]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
sizes.insert("B".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let a = placements.get("A").expect("A placed");
let b = placements.get("B").expect("B placed");
assert_eq!(a.kind, PlacementKind::ExteriorElbow);
assert_eq!(b.kind, PlacementKind::ExteriorElbow);
assert!(
a.anchor.x() < 0.0,
"A should sit left of bbox: {}",
a.anchor.x()
);
assert!(
b.anchor.x() > 30.0,
"B should sit right of bbox: {}",
b.anchor.x()
);
}
#[test]
fn test_elbow_centre_tiebreak_goes_right() {
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, &elbow_strategy());
let a = placements.get("A").expect("A placed");
assert_eq!(a.kind, PlacementKind::ExteriorElbow);
assert!(
a.anchor.x() > 10.0,
"centre tiebreak should go right: {}",
a.anchor.x()
);
}
#[test]
fn test_elbow_vertical_distribution_no_overlap() {
let regions = regions_from(vec![
(&["A"][..], vec![axis_aligned_square_piece(10.0)]),
(&["B"][..], vec![axis_aligned_square_piece(10.0)]),
(&["C"][..], vec![axis_aligned_square_piece(10.0)]),
]);
let mut sizes = HashMap::new();
for k in ["A", "B", "C"] {
sizes.insert(k.to_string(), (20.0, 20.0));
}
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let mut ys: Vec<f64> = ["A", "B", "C"]
.iter()
.map(|k| {
let p = placements.get(*k).expect("placed");
assert_eq!(p.kind, PlacementKind::ExteriorElbow);
p.anchor.y()
})
.collect();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(ys[1] - ys[0] >= 30.0 - 1e-6, "gap 0-1 = {}", ys[1] - ys[0]);
assert!(ys[2] - ys[1] >= 30.0 - 1e-6, "gap 1-2 = {}", ys[2] - ys[1]);
}
#[test]
fn test_elbow_distribution_centred_on_cluster() {
let regions = regions_from(vec![
(&["A"][..], vec![axis_aligned_square_piece(10.0)]),
(&["B"][..], vec![axis_aligned_square_piece(10.0)]),
(&["C"][..], vec![axis_aligned_square_piece(10.0)]),
]);
let mut sizes = HashMap::new();
for k in ["A", "B", "C"] {
sizes.insert(k.to_string(), (20.0, 20.0));
}
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let mean = ["A", "B", "C"]
.iter()
.map(|k| placements.get(*k).unwrap().anchor.y())
.sum::<f64>()
/ 3.0;
assert!(
(mean - 5.0).abs() < 1e-6,
"stack not centred: mean y = {mean}"
);
}
#[test]
fn test_elbow_keep_out_band_clears_interior_labels() {
let regions = regions_from(vec![
(&["A"][..], vec![square_piece_at(0.0, 0.0, 30.0)]),
(&["B"][..], vec![square_piece_at(13.0, 13.0, 4.0)]),
]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (2.0, 1.0)); sizes.insert("B".to_string(), (20.0, 20.0)); let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let a = placements.get("A").expect("A placed");
let b = placements.get("B").expect("B placed");
assert_eq!(a.kind, PlacementKind::Interior);
assert_eq!(b.kind, PlacementKind::ExteriorElbow);
let (band_lo, band_hi) = (a.anchor.y() - 0.5, a.anchor.y() + 0.5);
assert!(
b.anchor.y() <= band_lo + 1e-9 || b.anchor.y() >= band_hi - 1e-9,
"B row {} not clear of interior band [{}, {}]",
b.anchor.y(),
band_lo,
band_hi
);
}
#[test]
fn test_elbow_waypoints_form_orthogonal_polyline() {
let regions = regions_from(vec![
(&["A"][..], vec![axis_aligned_square_piece(10.0)]),
(&["B"][..], vec![axis_aligned_square_piece(10.0)]),
(&["C"][..], vec![axis_aligned_square_piece(10.0)]),
]);
let mut sizes = HashMap::new();
for k in ["A", "B", "C"] {
sizes.insert(k.to_string(), (20.0, 20.0));
}
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let p = placements.get("A").expect("A placed");
assert_eq!(p.leader_waypoints.len(), 1);
let tether = p.tether.expect("exterior has tether");
let knee = p.leader_waypoints[0];
let end = p.leader_end.expect("exterior has leader_end");
assert!(
(knee.x() - tether.x()).abs() < 1e-9,
"first leg not vertical"
);
assert!(
(knee.y() - p.anchor.y()).abs() < 1e-9,
"knee not at row height"
);
assert!(
(knee.y() - end.y()).abs() < 1e-9,
"second leg not horizontal"
);
assert!(
(knee.y() - tether.y()).abs() > 1e-6,
"expected a real vertical leg"
);
}
#[test]
fn test_elbow_leader_end_on_box_edge() {
let regions = single_region(&["A"], vec![axis_aligned_square_piece(10.0)]);
let (w, h) = (20.0, 20.0);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (w, h));
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
let p = placements.get("A").expect("A placed");
let le = p.leader_end.expect("exterior has leader_end");
assert!(
(le.x() - (p.anchor.x() - 0.5 * w)).abs() < 1e-6,
"leader_end x = {} not on near edge",
le.x()
);
assert!(
(le.y() - p.anchor.y()).abs() < 1e-6,
"leader_end y = {}",
le.y()
);
}
#[test]
fn test_elbow_boundary_tether_vertical_exit() {
let regions = regions_from(vec![
(&["A"][..], vec![axis_aligned_square_piece(10.0)]),
(&["B"][..], vec![axis_aligned_square_piece(10.0)]),
(&["C"][..], vec![axis_aligned_square_piece(10.0)]),
]);
let mut sizes = HashMap::new();
for k in ["A", "B", "C"] {
sizes.insert(k.to_string(), (20.0, 20.0));
}
let strategy = PlacementStrategy {
leader: LeaderStrategy::Elbow(ElbowOptions::default()),
tether: TetherSource::Boundary,
..PlacementStrategy::default()
};
let placements = place_labels(®ions, &sizes, None, &strategy);
let a = placements.get("A").expect("A placed");
let c = placements.get("C").expect("C placed");
let ta = a.tether.expect("A has tether");
let tc = c.tether.expect("C has tether");
assert!((ta.x() - 5.0).abs() < 0.5, "A tether x = {}", ta.x());
assert!((tc.x() - 5.0).abs() < 0.5, "C tether x = {}", tc.x());
assert!(a.anchor.y() < c.anchor.y(), "A row should be below C row");
assert!(
(ta.y() - 0.0).abs() < 1e-6,
"A tether y = {} not bottom edge",
ta.y()
);
assert!(
(tc.y() - 10.0).abs() < 1e-6,
"C tether y = {} not top edge",
tc.y()
);
}
#[test]
fn test_elbow_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, &elbow_strategy());
let p = placements.get("A").expect("A placed");
assert_eq!(p.kind, PlacementKind::Interior);
assert!(p.tether.is_none());
assert!(p.leader_end.is_none());
assert!(p.leader_waypoints.is_empty());
}
#[test]
fn test_elbow_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, &elbow_strategy());
assert!(placements.contains_key("A"));
let ab = placements.get("A&B").expect("A&B placed");
assert_eq!(ab.kind, PlacementKind::ExteriorElbow);
assert!(ab.tether.is_some());
assert_eq!(ab.leader_waypoints.len(), 1);
}
#[test]
fn test_elbow_degenerate_region_skipped() {
let regions = regions_from(vec![(&["A"][..], vec![])]);
let mut sizes = HashMap::new();
sizes.insert("A".to_string(), (20.0, 20.0));
let placements = place_labels(®ions, &sizes, None, &elbow_strategy());
assert!(!placements.contains_key("A"));
}
}