#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinePlacement {
Line,
LineCenter,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Anchor {
pub s: f32,
pub x: f32,
pub y: f32,
pub reversed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlyphOnLine {
pub x: f32,
pub y: f32,
pub angle: f32,
}
fn cumulative(poly: &[(f32, f32)]) -> Vec<f32> {
if poly.len() < 2 {
return Vec::new();
}
let mut cum = Vec::with_capacity(poly.len());
let mut acc = 0.0f32;
cum.push(0.0);
for w in poly.windows(2) {
let (dx, dy) = (w[1].0 - w[0].0, w[1].1 - w[0].1);
acc += (dx * dx + dy * dy).sqrt();
cum.push(acc);
}
cum
}
fn point_at(poly: &[(f32, f32)], cum: &[f32], s: f32) -> (f32, f32) {
let total = *cum.last().unwrap_or(&0.0);
let s = s.clamp(0.0, total);
for i in 0..poly.len() - 1 {
if s <= cum[i + 1] || i + 2 == poly.len() {
let seg = cum[i + 1] - cum[i];
let f = if seg > 1e-6 { (s - cum[i]) / seg } else { 0.0 };
return (
poly[i].0 + (poly[i + 1].0 - poly[i].0) * f,
poly[i].1 + (poly[i + 1].1 - poly[i].1) * f,
);
}
}
*poly.last().expect("poly has >= 2 vertices")
}
fn tangent_at(poly: &[(f32, f32)], cum: &[f32], s: f32) -> f32 {
for i in 0..poly.len() - 1 {
if s <= cum[i + 1] || i + 2 == poly.len() {
let (dx, dy) = (poly[i + 1].0 - poly[i].0, poly[i + 1].1 - poly[i].1);
return dy.atan2(dx);
}
}
0.0
}
fn max_angle_ok(
poly: &[(f32, f32)],
cum: &[f32],
lo: f32,
hi: f32,
window: f32,
max_angle_deg: f32,
) -> bool {
let mut recent: std::collections::VecDeque<(f32, f32)> = std::collections::VecDeque::new();
let mut sum = 0.0f32;
for i in 1..poly.len() - 1 {
let v = cum[i];
if v <= lo {
continue;
}
if v >= hi {
break;
}
let a0 = {
let (dx, dy) = (poly[i].0 - poly[i - 1].0, poly[i].1 - poly[i - 1].1);
dy.atan2(dx)
};
let a1 = {
let (dx, dy) = (poly[i + 1].0 - poly[i].0, poly[i + 1].1 - poly[i].1);
dy.atan2(dx)
};
let mut d = (a1 - a0).abs();
if d > std::f32::consts::PI {
d = 2.0 * std::f32::consts::PI - d;
}
let d = d.to_degrees();
recent.push_back((v, d));
sum += d;
while let Some(&(s0, d0)) = recent.front() {
if v - s0 > window.max(0.0) {
sum -= d0;
recent.pop_front();
} else {
break;
}
}
if sum > max_angle_deg {
return false;
}
}
true
}
fn window_reversed(poly: &[(f32, f32)], cum: &[f32], s: f32, label_len: f32) -> bool {
let a = point_at(poly, cum, s - label_len * 0.5);
let b = point_at(poly, cum, s + label_len * 0.5);
b.0 < a.0
}
pub fn clip_line(
poly: &[(f32, f32)],
min_x: f32,
min_y: f32,
max_x: f32,
max_y: f32,
) -> Vec<Vec<(f32, f32)>> {
let cross_x = |p: (f32, f32), q: (f32, f32), lim: f32| {
let t = (lim - p.0) / (q.0 - p.0);
(lim, p.1 + (q.1 - p.1) * t)
};
let cross_y = |p: (f32, f32), q: (f32, f32), lim: f32| {
let t = (lim - p.1) / (q.1 - p.1);
(p.0 + (q.0 - p.0) * t, lim)
};
let mut out: Vec<Vec<(f32, f32)>> = Vec::new();
'segments: for w in poly.windows(2) {
let (mut p0, mut p1) = (w[0], w[1]);
for (lim, beyond) in [(min_x, true), (max_x, false)] {
let out_of = |v: f32| if beyond { v < lim } else { v >= lim };
match (out_of(p0.0), out_of(p1.0)) {
(true, true) => continue 'segments,
(true, false) => p0 = cross_x(p0, p1, lim),
(false, true) => p1 = cross_x(p1, p0, lim),
(false, false) => {}
}
}
for (lim, beyond) in [(min_y, true), (max_y, false)] {
let out_of = |v: f32| if beyond { v < lim } else { v >= lim };
match (out_of(p0.1), out_of(p1.1)) {
(true, true) => continue 'segments,
(true, false) => p0 = cross_y(p0, p1, lim),
(false, true) => p1 = cross_y(p1, p0, lim),
(false, false) => {}
}
}
match out.last_mut() {
Some(run) if run.last() == Some(&p0) => run.push(p1),
_ => out.push(vec![p0, p1]),
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnchorParams {
pub placement: LinePlacement,
pub label_len: f32,
pub spacing: f32,
pub max_angle_deg: f32,
pub angle_window: f32,
pub glyph_size: f32,
pub continued: bool,
}
pub fn generate_anchors(poly: &[(f32, f32)], p: &AnchorParams) -> Vec<Anchor> {
let cum = cumulative(poly);
let Some(&total) = cum.last() else {
return Vec::new();
};
let half = p.label_len * 0.5;
if p.label_len > total || total <= 0.0 {
return Vec::new();
}
let mut anchors = Vec::new();
let push_if_straight = |s: f32, anchors: &mut Vec<Anchor>| {
if !max_angle_ok(
poly,
&cum,
s - half,
s + half,
p.angle_window,
p.max_angle_deg,
) {
return;
}
let (x, y) = point_at(poly, &cum, s);
anchors.push(Anchor {
s,
x,
y,
reversed: window_reversed(poly, &cum, s, p.label_len),
});
};
match p.placement {
LinePlacement::LineCenter => push_if_straight(total * 0.5, &mut anchors),
LinePlacement::Line => {
let mut spacing = p.spacing;
if spacing - p.label_len < spacing * 0.25 {
spacing = p.label_len + spacing * 0.25;
}
let step = spacing.max(1.0);
let offset = if p.continued {
(0.5 * step) % step
} else {
(half + 2.0 * p.glyph_size.max(0.0)) % step
};
let resample = |from: f32, anchors: &mut Vec<Anchor>| {
let mut s = from;
while s < total {
if s - half >= 0.0 && s + half <= total {
push_if_straight(s, anchors);
}
s += step;
}
};
resample(offset, &mut anchors);
if anchors.is_empty() && !p.continued {
resample(total * 0.5, &mut anchors);
}
}
}
anchors
}
pub fn place_glyphs(
poly: &[(f32, f32)],
anchor: &Anchor,
centre_offsets: &[f32],
) -> Option<Vec<GlyphOnLine>> {
let cum = cumulative(poly);
let &total = cum.last()?;
let mut out = Vec::with_capacity(centre_offsets.len());
for &off in centre_offsets {
let s = if anchor.reversed {
anchor.s - off
} else {
anchor.s + off
};
if s < -1e-3 || s > total + 1e-3 {
return None;
}
let (x, y) = point_at(poly, &cum, s);
let mut angle = tangent_at(poly, &cum, s);
if anchor.reversed {
angle += std::f32::consts::PI;
}
let tau = 2.0 * std::f32::consts::PI;
angle = (angle + std::f32::consts::PI).rem_euclid(tau) - std::f32::consts::PI;
out.push(GlyphOnLine { x, y, angle });
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn straight(len: f32) -> Vec<(f32, f32)> {
vec![(0.0, 0.0), (len, 0.0)]
}
fn params(
placement: LinePlacement,
label_len: f32,
spacing: f32,
max_angle_deg: f32,
) -> AnchorParams {
AnchorParams {
placement,
label_len,
spacing,
max_angle_deg,
angle_window: f32::MAX,
glyph_size: 0.0,
continued: false,
}
}
#[test]
fn line_anchors_are_spaced_from_half_a_label() {
let a = generate_anchors(
&straight(100.0),
¶ms(LinePlacement::Line, 20.0, 30.0, 45.0),
);
let ss: Vec<f32> = a.iter().map(|a| a.s).collect();
assert_eq!(ss, vec![10.0, 40.0, 70.0]);
assert!(a.iter().all(|a| (a.y).abs() < 1e-3));
assert!((a[1].x - 40.0).abs() < 1e-3);
}
#[test]
fn label_longer_than_line_is_dropped() {
let a = generate_anchors(
&straight(30.0),
¶ms(LinePlacement::Line, 40.0, 20.0, 45.0),
);
assert!(a.is_empty(), "a label that can't fit yields no anchors");
let c = generate_anchors(
&straight(30.0),
¶ms(LinePlacement::LineCenter, 40.0, 20.0, 45.0),
);
assert!(c.is_empty());
}
#[test]
fn line_center_places_exactly_one_at_the_midpoint() {
let a = generate_anchors(
&straight(80.0),
¶ms(LinePlacement::LineCenter, 20.0, 30.0, 45.0),
);
assert_eq!(a.len(), 1);
assert!((a[0].s - 40.0).abs() < 1e-3);
}
#[test]
fn max_angle_rejects_a_sharp_corner_but_passes_a_gentle_curve() {
let sharp = vec![(0.0, 0.0), (50.0, 0.0), (50.0, 50.0)];
let a = generate_anchors(&sharp, ¶ms(LinePlacement::LineCenter, 60.0, 30.0, 45.0));
assert!(a.is_empty(), "a 90° corner exceeds the 45° max angle");
let gentle = vec![(0.0, 0.0), (50.0, 0.0), (100.0, 9.0)];
let b = generate_anchors(
&gentle,
¶ms(LinePlacement::LineCenter, 60.0, 30.0, 45.0),
);
assert_eq!(b.len(), 1, "a gentle bend is within the max angle");
}
#[test]
fn max_angle_is_measured_over_a_sliding_window() {
let arc: Vec<(f32, f32)> = (0..=9)
.map(|i| {
let t = (i as f32) * 10.0f32.to_radians();
(100.0 * t.sin(), 100.0 * (1.0 - t.cos()))
})
.collect();
let mut p = params(LinePlacement::LineCenter, 100.0, 250.0, 45.0);
p.angle_window = 20.0;
assert_eq!(
generate_anchors(&arc, &p).len(),
1,
"a long gentle curve passes: no window exceeds the limit"
);
let kink = vec![(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (120.0, 60.0)];
assert!(generate_anchors(&kink, &p).is_empty());
}
#[test]
fn spacing_widens_to_keep_a_gap_between_long_labels() {
let a = generate_anchors(
&straight(1000.0),
¶ms(LinePlacement::Line, 220.0, 250.0, 45.0),
);
let ss: Vec<f32> = a.iter().map(|a| a.s).collect();
assert_eq!(ss, vec![110.0, 392.5, 675.0]);
}
#[test]
fn a_continued_line_is_phased_from_half_a_spacing() {
let mut p = params(LinePlacement::Line, 20.0, 30.0, 45.0);
p.continued = true;
let ss: Vec<f32> = generate_anchors(&straight(100.0), &p)
.iter()
.map(|a| a.s)
.collect();
assert_eq!(ss, vec![15.0, 45.0, 75.0]);
}
#[test]
fn a_self_contained_line_clears_its_start() {
let mut p = params(LinePlacement::Line, 20.0, 60.0, 45.0);
p.glyph_size = 10.0;
let ss: Vec<f32> = generate_anchors(&straight(200.0), &p)
.iter()
.map(|a| a.s)
.collect();
assert_eq!(ss, vec![30.0, 90.0, 150.0]);
}
#[test]
fn a_short_self_contained_line_falls_back_to_its_middle() {
let mut p = params(LinePlacement::Line, 40.0, 250.0, 45.0);
p.glyph_size = 12.0;
let a = generate_anchors(&straight(60.0), &p);
assert_eq!(a.len(), 1);
assert!((a[0].s - 30.0).abs() < 1e-3);
p.continued = true;
assert!(generate_anchors(&straight(60.0), &p).is_empty());
}
#[test]
fn clip_line_keeps_only_the_part_inside_the_box() {
let runs = clip_line(&[(-10.0, 5.0), (30.0, 5.0)], 0.0, 0.0, 20.0, 20.0);
assert_eq!(runs, vec![vec![(0.0, 5.0), (20.0, 5.0)]]);
let runs = clip_line(
&[(5.0, 5.0), (5.0, -10.0), (15.0, -10.0), (15.0, 5.0)],
0.0,
0.0,
20.0,
20.0,
);
assert_eq!(
runs,
vec![vec![(5.0, 5.0), (5.0, 0.0)], vec![(15.0, 0.0), (15.0, 5.0)],]
);
assert!(clip_line(&[(-5.0, -5.0), (-1.0, -5.0)], 0.0, 0.0, 20.0, 20.0).is_empty());
}
#[test]
fn keep_upright_flags_a_right_to_left_line() {
let rtl = vec![(100.0, 0.0), (0.0, 0.0)];
let a = generate_anchors(&rtl, ¶ms(LinePlacement::LineCenter, 20.0, 30.0, 45.0));
assert!(a[0].reversed, "a right-to-left line is flipped upright");
let ltr = straight(100.0);
let b = generate_anchors(<r, ¶ms(LinePlacement::LineCenter, 20.0, 30.0, 45.0));
assert!(!b[0].reversed);
}
#[test]
fn glyph_walk_follows_the_tangent_of_a_straight_line() {
let a = &generate_anchors(
&straight(100.0),
¶ms(LinePlacement::LineCenter, 20.0, 30.0, 45.0),
)[0];
let g = place_glyphs(&straight(100.0), a, &[-10.0, 0.0, 10.0]).unwrap();
assert!((g[0].x - 40.0).abs() < 1e-3 && g[0].angle.abs() < 1e-3);
assert!((g[1].x - 50.0).abs() < 1e-3);
assert!((g[2].x - 60.0).abs() < 1e-3);
assert!(g.iter().all(|g| g.y.abs() < 1e-3));
}
#[test]
fn glyph_walk_rotates_to_a_diagonal_tangent() {
let diag = vec![(0.0, 0.0), (100.0, 100.0)];
let a = &generate_anchors(&diag, ¶ms(LinePlacement::LineCenter, 20.0, 30.0, 90.0))[0];
let g = place_glyphs(&diag, a, &[-10.0, 0.0, 10.0]).unwrap();
assert!(g
.iter()
.all(|g| (g.angle - std::f32::consts::FRAC_PI_4).abs() < 1e-3));
}
#[test]
fn reversed_walk_reads_leftward_and_flips_angle() {
let rtl = vec![(100.0, 0.0), (0.0, 0.0)];
let a = &generate_anchors(&rtl, ¶ms(LinePlacement::LineCenter, 20.0, 30.0, 45.0))[0];
let g = place_glyphs(&rtl, a, &[-10.0, 10.0]).unwrap();
assert!((g[0].x - 40.0).abs() < 1e-3);
assert!((g[1].x - 60.0).abs() < 1e-3);
assert!(g[0].angle.abs() < 1e-3);
}
}