use std::collections::HashMap;
use geo::{BoundingRect, Geometry, LineString};
use super::assign::{AssignConfig, AssignFeature, FeatureKind, Priority};
use super::level::Crs;
pub const COALESCED_COUNT_COLUMN: &str = "coalesced_count";
pub const DEFAULT_SNAP_GSD_FACTOR: f64 = 1.0;
pub const DEFAULT_JUNCTION_ANGLE_DEG: f64 = 0.0;
pub const DEFAULT_COALESCE_MAX_LEVEL_ROWS: usize = 2_000_000;
#[derive(Debug, Clone)]
pub struct CoalesceInput<'a> {
pub index: usize,
pub geom: &'a Geometry<f64>,
pub sort_key: Option<f64>,
pub group: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CoalescedLine {
pub rep: usize,
pub count: i32,
pub geom: Geometry<f64>,
}
type End = u8;
type NodeKey = (u32, i64, i64);
#[derive(Debug, Clone, Copy)]
pub struct CoalesceParams {
pub snap_gsd_factor: f64,
pub junction_angle_deg: f64,
pub budget: Option<(usize, f64)>,
}
impl Default for CoalesceParams {
fn default() -> Self {
Self {
snap_gsd_factor: DEFAULT_SNAP_GSD_FACTOR,
junction_angle_deg: DEFAULT_JUNCTION_ANGLE_DEG,
budget: None,
}
}
}
pub fn coalesce_level_lines(
lines: &[CoalesceInput<'_>],
gsd_m: f64,
crs: Crs,
config: &AssignConfig,
params: &CoalesceParams,
) -> Vec<CoalescedLine> {
if lines.is_empty() {
return Vec::new();
}
let budget = params.budget;
let gsd_units = crs.meters_to_units(gsd_m);
let snap_tol = if gsd_units > 0.0 {
params.snap_gsd_factor * gsd_units
} else {
0.0
};
let chains = build_chains(lines, snap_tol, params.junction_angle_deg);
let mut prio: HashMap<usize, Priority> = HashMap::with_capacity(lines.len());
let mut sort_keys: HashMap<usize, Option<f64>> = HashMap::with_capacity(lines.len());
for l in lines {
let feat = AssignFeature {
index: l.index,
bbox: geom_bbox(l.geom),
kind: FeatureKind::Line,
sort_key: l.sort_key,
entry_level: None,
};
prio.insert(l.index, Priority::new(&feat, config.sort_direction));
sort_keys.insert(l.index, l.sort_key);
}
let mut merged: Vec<CoalescedLine> = Vec::with_capacity(chains.len());
for chain in chains {
let rep = chain
.members
.iter()
.copied()
.reduce(|best, m| {
if prio[&m].beats(&prio[&best]) {
m
} else {
best
}
})
.expect("chain has at least one member");
merged.push(CoalescedLine {
rep,
count: chain.members.len() as i32,
geom: chain.geom,
});
}
let gate = config.line_visibility * gsd_units;
let gate_sq = gate * gate;
let mut gated: Vec<(CoalescedLine, AssignFeature)> = Vec::with_capacity(merged.len());
for line in merged {
let bbox = geom_bbox(&line.geom);
let (dx, dy) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
if gate > 0.0 && dx * dx + dy * dy < gate_sq {
continue; }
let feat = AssignFeature {
index: line.rep,
bbox,
kind: FeatureKind::Line,
sort_key: sort_keys[&line.rep],
entry_level: None,
};
gated.push((line, feat));
}
let cell_size = gsd_units * config.line_thinning;
let mut survivors: Vec<(CoalescedLine, AssignFeature)> =
if cell_size > 0.0 && !cell_size.is_nan() {
let chain_prio: Vec<Priority> = gated
.iter()
.map(|(_, f)| Priority::new(f, config.sort_direction))
.collect();
let mut grid: HashMap<(i64, i64), usize> = HashMap::new();
for (pos, (_, feat)) in gated.iter().enumerate() {
let (cx, cy) = feat.center();
let key = (
(cx / cell_size).floor() as i64,
(cy / cell_size).floor() as i64,
);
grid.entry(key)
.and_modify(|best| {
if chain_prio[pos].beats(&chain_prio[*best]) {
*best = pos;
}
})
.or_insert(pos);
}
let mut keep = vec![false; gated.len()];
for pos in grid.into_values() {
keep[pos] = true;
}
gated
.into_iter()
.zip(&keep)
.filter(|(_, &k)| k)
.map(|(pair, _)| pair)
.collect()
} else {
gated
};
if let Some((max_chains, gamma)) = budget {
if survivors.len() > max_chains {
let feats: Vec<AssignFeature> = survivors
.iter()
.enumerate()
.map(|(pos, (_, f))| AssignFeature { index: pos, ..*f })
.collect();
let prio: Vec<Priority> = feats
.iter()
.map(|f| Priority::new(f, config.sort_direction))
.collect();
let cands: Vec<usize> = (0..survivors.len()).collect();
let mut chosen = super::assign::select_budget_survivors(
&cands, max_chains, &feats, &prio, gsd_m, crs, gamma,
);
chosen.sort_unstable();
let mut keep = vec![false; survivors.len()];
for pos in chosen {
keep[pos] = true;
}
survivors = survivors
.into_iter()
.zip(&keep)
.filter(|(_, &k)| k)
.map(|(pair, _)| pair)
.collect();
}
}
let mut survivors: Vec<CoalescedLine> = survivors.into_iter().map(|(l, _)| l).collect();
survivors.sort_by_key(|c| c.rep);
survivors
}
fn piece_direction(p: &Piece, end: End) -> Option<(f64, f64)> {
let c = &p.coords;
let (anchor, inward) = if end == 0 {
(c[0], c.iter().skip(1).find(|&&q| q != c[0]).copied())
} else {
let last = c[c.len() - 1];
(last, c.iter().rev().skip(1).find(|&&q| q != last).copied())
};
let q = inward?;
let (dx, dy) = (q.x - anchor.x, q.y - anchor.y);
let len = (dx * dx + dy * dy).sqrt();
if len > 0.0 {
Some((dx / len, dy / len))
} else {
None
}
}
fn continuation_deviation_deg(a: (f64, f64), b: (f64, f64)) -> f64 {
let dot = (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0);
180.0 - dot.acos().to_degrees()
}
fn geom_bbox(g: &Geometry<f64>) -> [f64; 4] {
match g.bounding_rect() {
Some(r) => [r.min().x, r.min().y, r.max().x, r.max().y],
None => [0.0, 0.0, 0.0, 0.0],
}
}
struct RawChain {
members: Vec<usize>,
geom: Geometry<f64>,
}
struct Piece {
members: Vec<usize>,
coords: Vec<geo::Coord<f64>>,
group: u32,
}
#[inline]
fn exact_key(group: u32, c: geo::Coord<f64>) -> NodeKey {
(group, c.x.to_bits() as i64, c.y.to_bits() as i64)
}
#[inline]
fn snap_key(group: u32, c: geo::Coord<f64>, tol: f64) -> NodeKey {
(
group,
(c.x / tol).floor() as i64,
(c.y / tol).floor() as i64,
)
}
fn build_chains(
lines: &[CoalesceInput<'_>],
snap_tol: f64,
junction_angle_deg: f64,
) -> Vec<RawChain> {
let mut pieces: Vec<Piece> = Vec::new();
let mut singles: Vec<usize> = Vec::new(); for (pos, l) in lines.iter().enumerate() {
match l.geom {
Geometry::LineString(ls) if ls.0.len() >= 2 => pieces.push(Piece {
members: vec![l.index],
coords: ls.0.clone(),
group: l.group,
}),
_ => singles.push(pos),
}
}
let pieces = join_pieces(pieces, exact_key, junction_angle_deg);
let pieces = if snap_tol > 0.0 {
join_pieces(pieces, |g, c| snap_key(g, c, snap_tol), junction_angle_deg)
} else {
pieces
};
let mut chains: Vec<RawChain> = pieces
.into_iter()
.map(|p| RawChain {
geom: Geometry::LineString(LineString::new(p.coords)),
members: p.members,
})
.collect();
for pos in singles {
chains.push(RawChain {
members: vec![lines[pos].index],
geom: lines[pos].geom.clone(),
});
}
chains
}
fn join_pieces(
pieces: Vec<Piece>,
key: impl Fn(u32, geo::Coord<f64>) -> NodeKey,
junction_angle_deg: f64,
) -> Vec<Piece> {
let mut nodes: HashMap<NodeKey, Vec<(usize, End)>> = HashMap::new();
for (pi, p) in pieces.iter().enumerate() {
let first = p.coords[0];
let last = p.coords[p.coords.len() - 1];
nodes.entry(key(p.group, first)).or_default().push((pi, 0));
nodes.entry(key(p.group, last)).or_default().push((pi, 1));
}
let mut joins: Vec<[Option<(usize, End)>; 2]> = vec![[None, None]; pieces.len()];
for incidents in nodes.values() {
let d = incidents.len();
if d == 2 && incidents[0].0 != incidents[1].0 {
let (a, a_end) = incidents[0];
let (b, b_end) = incidents[1];
joins[a][a_end as usize] = Some((b, b_end));
joins[b][b_end as usize] = Some((a, a_end));
} else if d >= 3 && junction_angle_deg > 0.0 {
let dirs: Vec<Option<(f64, f64)>> = incidents
.iter()
.map(|&(pi, e)| piece_direction(&pieces[pi], e))
.collect();
let mut cands: Vec<(f64, usize, usize)> = Vec::new();
for i in 0..d {
for j in (i + 1)..d {
if incidents[i].0 == incidents[j].0 {
continue; }
if let (Some(a), Some(b)) = (dirs[i], dirs[j]) {
let dev = continuation_deviation_deg(a, b);
if dev <= junction_angle_deg {
cands.push((dev, i, j));
}
}
}
}
cands.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
let mut used = vec![false; d];
for (_, i, j) in cands {
if used[i] || used[j] {
continue;
}
used[i] = true;
used[j] = true;
let (a, a_end) = incidents[i];
let (b, b_end) = incidents[j];
joins[a][a_end as usize] = Some((b, b_end));
joins[b][b_end as usize] = Some((a, a_end));
}
}
}
let mut visited = vec![false; pieces.len()];
let mut order: Vec<Vec<(usize, End)>> = Vec::new();
for start in 0..pieces.len() {
if visited[start] {
continue;
}
let (mut cur, mut cur_end) = (start, 0u8);
loop {
match joins[cur][cur_end as usize] {
None => break, Some((prev, prev_end)) => {
if prev == start {
cur = start;
cur_end = 0;
break;
}
cur = prev;
cur_end = 1 - prev_end;
}
}
}
let (head, head_entry) = (cur, cur_end);
let mut walk: Vec<(usize, End)> = vec![(head, head_entry)];
visited[head] = true;
let (mut cur, mut entry) = (head, head_entry);
loop {
let exit = 1 - entry;
match joins[cur][exit as usize] {
Some((next, next_end)) if !visited[next] => {
walk.push((next, next_end));
visited[next] = true;
cur = next;
entry = next_end;
}
_ => break, }
}
order.push(walk);
}
let mut slots: Vec<Option<Piece>> = pieces.into_iter().map(Some).collect();
let mut out: Vec<Piece> = Vec::with_capacity(order.len());
for walk in order {
if walk.len() == 1 {
let (pi, _) = walk[0];
out.push(slots[pi].take().expect("piece consumed once"));
continue;
}
let mut members: Vec<usize> = Vec::new();
let mut coords: Vec<geo::Coord<f64>> = Vec::new();
let mut group = 0u32;
for &(pi, seg_entry) in &walk {
let mut p = slots[pi].take().expect("piece consumed once");
members.append(&mut p.members);
group = p.group;
if seg_entry == 1 {
p.coords.reverse();
}
for c in p.coords {
if coords.last() == Some(&c) {
continue; }
coords.push(c);
}
}
out.push(Piece {
members,
coords,
group,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use geo::{Coord, MultiLineString};
fn ls(coords: &[(f64, f64)]) -> Geometry<f64> {
Geometry::LineString(LineString::from(coords.to_vec()))
}
fn input<'a>(index: usize, geom: &'a Geometry<f64>) -> CoalesceInput<'a> {
CoalesceInput {
index,
geom,
sort_key: None,
group: 0,
}
}
const TINY_GSD: f64 = 1e-6;
fn cfg() -> AssignConfig {
AssignConfig::default()
}
fn params(snap: f64, junction: f64, budget: Option<(usize, f64)>) -> CoalesceParams {
CoalesceParams {
snap_gsd_factor: snap,
junction_angle_deg: junction,
budget,
}
}
fn run<'a>(lines: &[CoalesceInput<'a>], gsd_m: f64) -> Vec<CoalescedLine> {
coalesce_level_lines(
lines,
gsd_m,
Crs::Epsg3857,
&cfg(),
&CoalesceParams {
junction_angle_deg: 0.0, ..CoalesceParams::default()
},
)
}
fn coords_of(g: &Geometry<f64>) -> Vec<(f64, f64)> {
match g {
Geometry::LineString(ls) => ls.0.iter().map(|c| (c.x, c.y)).collect(),
other => panic!("expected LineString, got {other:?}"),
}
}
#[test]
fn two_touching_segments_merge() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let lines = [input(0, &a), input(1, &b)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 1, "touching segments must merge: {out:?}");
assert_eq!(out[0].count, 2);
assert_eq!(
coords_of(&out[0].geom),
vec![(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
"shared node vertex deduplicated"
);
}
#[test]
fn three_collinear_segments_merge_into_one() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let c = ls(&[(200.0, 0.0), (300.0, 0.0)]);
let lines = [input(0, &a), input(1, &b), input(2, &c)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 1);
assert_eq!(out[0].count, 3);
assert_eq!(coords_of(&out[0].geom).len(), 4);
}
#[test]
fn reversed_orientation_still_merges() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(200.0, 0.0), (100.0, 0.0)]);
let lines = [input(0, &a), input(1, &b)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 1);
let c = coords_of(&out[0].geom);
assert_eq!(c.len(), 3, "no duplicate shared vertex: {c:?}");
assert!(
c == vec![(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)]
|| c == vec![(200.0, 0.0), (100.0, 0.0), (0.0, 0.0)]
);
}
#[test]
fn t_junction_degree_three_does_not_merge_through() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let c = ls(&[(100.0, 0.0), (100.0, 100.0)]);
let lines = [input(0, &a), input(1, &b), input(2, &c)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 3, "junction must terminate chains: {out:?}");
assert!(out.iter().all(|l| l.count == 1));
}
#[test]
fn junction_continuation_merges_straight_pair_at_t() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let c = ls(&[(100.0, 0.0), (100.0, 100.0)]);
let lines = [input(0, &a), input(1, &b), input(2, &c)];
let out = coalesce_level_lines(
&lines,
TINY_GSD,
Crs::Epsg3857,
&cfg(),
¶ms(1.0, 30.0, None),
);
assert_eq!(
out.len(),
2,
"straight pair merges, branch survives: {out:?}"
);
let merged = out.iter().find(|l| l.count == 2).expect("merged pair");
assert_eq!(coords_of(&merged.geom).len(), 3);
assert_eq!(out.iter().find(|l| l.count == 1).map(|l| l.rep), Some(2));
}
#[test]
fn junction_continuation_pairs_both_streets_at_crossing() {
let e = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let w = ls(&[(100.0, 0.0), (300.0, 0.0)]);
let n = ls(&[(100.0, 0.0), (100.0, 100.0)]);
let s = ls(&[(100.0, -300.0), (100.0, 0.0)]);
let lines = [input(0, &e), input(1, &w), input(2, &n), input(3, &s)];
let out = coalesce_level_lines(
&lines,
TINY_GSD,
Crs::Epsg3857,
&cfg(),
¶ms(1.0, 30.0, None),
);
assert_eq!(out.len(), 2, "both through-streets continue: {out:?}");
assert!(out.iter().all(|l| l.count == 2));
}
#[test]
fn junction_continuation_respects_angle_threshold() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let bent = ls(&[(100.0, 0.0), (170.0, 70.0)]); let branch = ls(&[(100.0, 0.0), (100.0, 100.0)]); let lines = [input(0, &a), input(1, &bent), input(2, &branch)];
let strict = coalesce_level_lines(
&lines,
TINY_GSD,
Crs::Epsg3857,
&cfg(),
¶ms(1.0, 30.0, None),
);
assert_eq!(strict.len(), 3, "45° bend exceeds 30°: {strict:?}");
let loose = coalesce_level_lines(
&lines,
TINY_GSD,
Crs::Epsg3857,
&cfg(),
¶ms(1.0, 60.0, None),
);
assert_eq!(loose.len(), 2, "45° bend within 60°: {loose:?}");
assert_eq!(loose.iter().map(|l| l.count).max(), Some(2));
}
#[test]
fn junction_continuation_respects_groups() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]); let c = ls(&[(100.0, 0.0), (100.0, 100.0)]); let mut ia = input(0, &a);
let mut ib = input(1, &b);
let mut ic = input(2, &c);
ia.group = 1;
ib.group = 2;
ic.group = 1;
let out = coalesce_level_lines(
&[ia, ib, ic],
TINY_GSD,
Crs::Epsg3857,
&cfg(),
¶ms(1.0, 30.0, None),
);
assert_eq!(out.len(), 2, "no cross-group merge: {out:?}");
let merged = out.iter().find(|l| l.count == 2).expect("a+c chain");
assert!(merged.rep == 0 || merged.rep == 2);
}
#[test]
fn snap_tolerance_joins_near_endpoints() {
let a = ls(&[(0.0, 0.0), (100.2, 0.2)]);
let b = ls(&[(100.4, 0.4), (200.0, 0.0)]); let c = ls(&[(202.0, 0.0), (300.0, 0.0)]); let lines = [input(0, &a), input(1, &b), input(2, &c)];
let out = run(&lines, 1.0);
assert_eq!(out.len(), 2, "near endpoints join, far do not: {out:?}");
let merged = out.iter().find(|l| l.count == 2).expect("merged chain");
assert_eq!(coords_of(&merged.geom).len(), 4);
}
#[test]
fn exact_matching_when_snap_zero() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]); let c = ls(&[(200.0000001, 0.0), (300.0, 0.0)]); let lines = [input(0, &a), input(1, &b), input(2, &c)];
let out = coalesce_level_lines(&lines, 1.0, Crs::Epsg3857, &cfg(), ¶ms(0.0, 0.0, None));
assert_eq!(out.len(), 2);
assert_eq!(out.iter().map(|l| l.count).max(), Some(2));
}
#[test]
fn class_mismatch_does_not_merge() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let mut ia = input(0, &a);
let mut ib = input(1, &b);
ia.group = 1;
ib.group = 2;
let out = run(&[ia, ib], TINY_GSD);
assert_eq!(out.len(), 2, "different classes never chain");
}
#[test]
fn junction_degree_counted_within_group_only() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let minor = ls(&[(100.0, 0.0), (100.0, 50.0)]);
let mut ia = input(0, &a);
let mut ib = input(1, &b);
let mut im = input(2, &minor);
ia.group = 1;
ib.group = 1;
im.group = 9;
let out = run(&[ia, ib, im], TINY_GSD);
assert_eq!(out.len(), 2);
assert_eq!(out.iter().map(|l| l.count).max(), Some(2));
}
#[test]
fn priority_attribute_inheritance() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (150.0, 0.0)]); let ia = input(7, &a);
let mut ib = input(3, &b);
ib.sort_key = Some(5.0);
let out = run(&[ia, ib], TINY_GSD);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rep, 3, "sort-key holder donates attributes");
assert_eq!(out[0].count, 2);
}
#[test]
fn cycle_merges_into_closed_chain() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (100.0, 100.0)]);
let c = ls(&[(100.0, 100.0), (0.0, 100.0)]);
let d = ls(&[(0.0, 100.0), (0.0, 0.0)]);
let lines = [input(0, &a), input(1, &b), input(2, &c), input(3, &d)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 1, "ring merges into one chain: {out:?}");
assert_eq!(out[0].count, 4);
let coords = coords_of(&out[0].geom);
assert_eq!(coords.first(), coords.last(), "cycle closes");
}
#[test]
fn self_loop_is_not_merged_with_itself() {
let ring = ls(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 0.0)]);
let lines = [input(0, &ring)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 1);
assert_eq!(out[0].count, 1);
assert_eq!(out[0].geom, ring);
}
#[test]
fn multilinestring_passes_through_as_singleton() {
let mls = Geometry::MultiLineString(MultiLineString::new(vec![
LineString::from(vec![(0.0, 0.0), (100.0, 0.0)]),
LineString::from(vec![(300.0, 0.0), (400.0, 0.0)]),
]));
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]); let lines = [input(0, &mls), input(1, &b)];
let out = run(&lines, TINY_GSD);
assert_eq!(out.len(), 2, "multilines never chain");
assert!(out.iter().all(|l| l.count == 1));
}
#[test]
fn sub_visibility_fragments_survive_as_one_chain() {
let segs: Vec<Geometry<f64>> = (0..5)
.map(|i| ls(&[(i as f64 * 8.0, 0.0), ((i + 1) as f64 * 8.0, 0.0)]))
.collect();
let lone = ls(&[(10_000.0, 0.0), (10_008.0, 0.0)]);
let mut lines: Vec<CoalesceInput> =
segs.iter().enumerate().map(|(i, g)| input(i, g)).collect();
lines.push(input(5, &lone));
let out = run(&lines, 10.0);
assert_eq!(out.len(), 1, "chain survives, lone fragment drops: {out:?}");
assert_eq!(out[0].count, 5);
let coords = coords_of(&out[0].geom);
assert_eq!(coords.first(), Some(&(0.0, 0.0)));
assert_eq!(coords.last(), Some(&(40.0, 0.0)));
}
#[test]
fn thinning_keeps_one_chain_per_cell() {
let a = ls(&[(0.0, 0.0), (900.0, 0.0)]);
let b = ls(&[(0.0, 10.0), (900.0, 10.0)]);
let lines = [input(0, &a), input(1, &b)];
let cfg = AssignConfig {
line_visibility: 0.5, ..AssignConfig::default()
};
let out =
coalesce_level_lines(&lines, 1000.0, Crs::Epsg3857, &cfg, ¶ms(1.0, 0.0, None));
assert_eq!(out.len(), 1, "one chain per thinning cell: {out:?}");
}
#[test]
fn longer_chain_wins_thinning_cell() {
let long = ls(&[(0.0, 0.0), (900.0, 0.0)]);
let short = ls(&[(0.0, 10.0), (400.0, 10.0)]);
let lines = [input(0, &long), input(1, &short)];
let cfg = AssignConfig {
line_visibility: 0.1,
..AssignConfig::default()
};
let out =
coalesce_level_lines(&lines, 1000.0, Crs::Epsg3857, &cfg, ¶ms(1.0, 0.0, None));
assert_eq!(out.len(), 1);
assert_eq!(out[0].rep, 0, "longer chain out-ranks in its cell");
}
#[test]
fn output_is_input_order_independent() {
let a = ls(&[(0.0, 0.0), (100.0, 0.0)]);
let b = ls(&[(100.0, 0.0), (200.0, 0.0)]);
let c = ls(&[(500.0, 0.0), (600.0, 0.0)]);
let l1 = [input(0, &a), input(1, &b), input(2, &c)];
let l2 = [input(2, &c), input(1, &b), input(0, &a)];
let mut o1 = run(&l1, TINY_GSD);
let mut o2 = run(&l2, TINY_GSD);
o1.sort_by_key(|l| l.rep);
o2.sort_by_key(|l| l.rep);
assert_eq!(o1, o2, "results independent of input order");
}
#[test]
fn chain_budget_caps_survivors_by_priority() {
let long = ls(&[(0.0, 0.0), (5000.0, 0.0)]);
let mid = ls(&[(0.0, 20_000.0), (3000.0, 20_000.0)]);
let short = ls(&[(0.0, 40_000.0), (1000.0, 40_000.0)]);
let lines = [input(0, &long), input(1, &mid), input(2, &short)];
let cfg = AssignConfig {
line_visibility: 0.1,
..AssignConfig::default()
};
let all =
coalesce_level_lines(&lines, 1000.0, Crs::Epsg3857, &cfg, ¶ms(1.0, 0.0, None));
assert_eq!(all.len(), 3, "no budget keeps all: {all:?}");
let cut = coalesce_level_lines(
&lines,
1000.0,
Crs::Epsg3857,
&cfg,
¶ms(1.0, 0.0, Some((2, 1.0))),
);
assert_eq!(cut.len(), 2, "budget of 2 binds: {cut:?}");
let reps: Vec<usize> = cut.iter().map(|c| c.rep).collect();
assert_eq!(reps, vec![0, 1], "lowest-priority (shortest) chain drops");
}
#[test]
fn empty_input_is_noop() {
assert!(run(&[], 10.0).is_empty());
}
#[test]
fn degenerate_single_point_line_is_gated_out() {
let dot = Geometry::LineString(LineString::new(vec![Coord { x: 1.0, y: 1.0 }]));
let lines = [input(0, &dot)];
let out = run(&lines, 10.0);
assert!(out.is_empty(), "zero-diagonal chain fails the gate");
}
}