diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index c186534c9..0c7ddd326 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -75,7 +75,108 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec], i: usize, j: usize } } +/// Upper bound on candidate-pair *visits*. Every visit counts, including repeats of a pair that shares several grid +/// cells, not only the distinct pairs actually tested. A long segment replicated across many +/// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the +/// repeated work unbounded (CWE-407). +const MAX_PAIR_VISITS: u64 = 60_000_000; +/// Upper bound on distinct segment pairs tested for a crossing. +const MAX_PAIRS_TESTED: usize = 20_000_000; + +/// Split parameters of every segment (always `0` and `1`, plus every crossing), found with a uniform grid. +fn pair_splits(segs: &[(Vec2, Vec2)], bb: &Bounds2) -> Option>> { + pair_splits_limited(segs, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED) +} + +/// `pair_splits` with explicit budgets: `None` once more than `max_visits` candidate pairs were visited +/// or more than `max_pairs` distinct pairs were tested. +fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option>> { + let n = segs.len(); + let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512); + let cw = (bb.width() / cells as f64).max(1e-12); + let ch = (bb.height() / cells as f64).max(1e-12); + let cell = |p: Vec2| { + ( + ((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize, + ((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize, + ) + }; + // Cell lists in a flat grid, indexed `gx * cells + gy`; each list is in ascending segment order. + let mut grid: Vec> = vec![Vec::new(); cells * cells]; + // The lowest cell of each segment's cell range; the lowest cell two segments share is the + // componentwise maximum of their two. + let mut low: Vec<(usize, usize)> = vec![(0, 0); n]; + let mut is_long = vec![false; n]; + let mut long: Vec = Vec::new(); + for (i, (a, b)) in segs.iter().enumerate() { + let (x0, y0) = cell(a.min(*b)); + let (x1, y1) = cell(a.max(*b)); + if let Some(l) = low.get_mut(i) { + *l = (x0, y0); + } + if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 { + // Very long segment: too many cells to register in; it is tested against every segment below. + long.push(i); + if let Some(f) = is_long.get_mut(i) { + *f = true; + } + continue; + } + for gx in x0..=x1 { + for gy in y0..=y1 { + if let Some(list) = grid.get_mut(gx * cells + gy) { + list.push(i); + } + } + } + } + let mut splits: Vec> = vec![vec![0.0, 1.0]; n]; + let mut tested: usize = 0; + let mut visits: u64 = 0; + for (c, list) in grid.iter().enumerate() { + let (gx, gy) = (c / cells, c % cells); + for (k, &i) in list.iter().enumerate() { + let Some(&(ix, iy)) = low.get(i) else { continue }; + for &j in list.iter().skip(k + 1) { + visits += 1; + if visits > max_visits { + return None; + } + let Some(&(jx, jy)) = low.get(j) else { continue }; + // A pair sharing several cells is tested once, in the lowest cell both occupy. + if i == j || gx != ix.max(jx) || gy != iy.max(jy) { + continue; + } + split_pair(segs, &mut splits, i, j); + tested += 1; + if tested > max_pairs { + return None; + } + } + } + } + // A long segment is in no cell list. Two long segments are tested once, from the lower index. + for &i in &long { + for j in 0..n { + if i == j || (j < i && is_long.get(j).copied().unwrap_or(false)) { + continue; + } + split_pair(segs, &mut splits, i, j); + tested += 1; + if tested > max_pairs { + return None; + } + } + } + Some(splits) +} + fn build(polys: &[Vec], tol: f64) -> Option { + build_with(polys, tol, pair_splits) +} + +/// `build` with the crossing search injected, so tests can run the old and the new search on the same input. +fn build_with(polys: &[Vec], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)], &Bounds2) -> Option>>) -> Option { let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); let mut owner: Vec = Vec::new(); for (k, pl) in polys.iter().enumerate() { @@ -96,70 +197,9 @@ fn build(polys: &[Vec], tol: f64) -> Option { if segs.is_empty() || segs.len() > MAX_SEGMENTS { return None; } - // Split points per segment (parameters), found with a uniform grid. let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b])); let n = segs.len(); - let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512); - let cw = (bb.width() / cells as f64).max(1e-12); - let ch = (bb.height() / cells as f64).max(1e-12); - let cell = |p: Vec2| { - ( - ((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize, - ((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize, - ) - }; - let mut grid: HashMap<(usize, usize), Vec> = HashMap::new(); - let mut long: Vec = Vec::new(); - for (i, (a, b)) in segs.iter().enumerate() { - let (x0, y0) = cell(a.min(*b)); - let (x1, y1) = cell(a.max(*b)); - if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 { - // Very long segment: too many cells to register in; it is tested against every segment below. - long.push(i); - continue; - } - for gx in x0..=x1 { - for gy in y0..=y1 { - grid.entry((gx, gy)).or_default().push(i); - } - } - } - let mut splits: Vec> = vec![vec![0.0, 1.0]; n]; - let mut tested = std::collections::HashSet::new(); - // Count every candidate-pair *visit*, including repeats of a pair that shares several grid - // cells, not only the distinct pairs actually tested. A long segment replicated across many - // cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the - // repeated hashing unbounded (CWE-407). - let mut visits: u64 = 0; - const MAX_PAIR_VISITS: u64 = 60_000_000; - for list in grid.values() { - for (k, &i) in list.iter().enumerate() { - for &j in list.iter().skip(k + 1) { - visits += 1; - if visits > MAX_PAIR_VISITS { - return None; - } - if i == j || !tested.insert((i.min(j), i.max(j))) { - continue; - } - split_pair(&segs, &mut splits, i, j); - if tested.len() > 20_000_000 { - return None; - } - } - } - } - for &i in &long { - for j in 0..n { - if i == j || !tested.insert((i.min(j), i.max(j))) { - continue; - } - split_pair(&segs, &mut splits, i, j); - if tested.len() > 20_000_000 { - return None; - } - } - } + let splits = find_splits(&segs, &bb)?; let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() }; let mut index: HashMap<(i64, i64), usize> = HashMap::new(); let mut vid = |p: Vec2, g: &mut Graph| -> usize { @@ -630,6 +670,187 @@ mod tests { vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)] } + /// The crossing search before the lowest-shared-cell rule: a `HashSet` of tested pairs over a `HashMap` grid. + fn pair_splits_oracle(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option>> { + let n = segs.len(); + let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512); + let cw = (bb.width() / cells as f64).max(1e-12); + let ch = (bb.height() / cells as f64).max(1e-12); + let cell = |p: Vec2| { + ( + ((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize, + ((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize, + ) + }; + let mut grid: HashMap<(usize, usize), Vec> = HashMap::new(); + let mut long: Vec = Vec::new(); + for (i, (a, b)) in segs.iter().enumerate() { + let (x0, y0) = cell(a.min(*b)); + let (x1, y1) = cell(a.max(*b)); + if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 { + long.push(i); + continue; + } + for gx in x0..=x1 { + for gy in y0..=y1 { + grid.entry((gx, gy)).or_default().push(i); + } + } + } + let mut splits: Vec> = vec![vec![0.0, 1.0]; n]; + let mut tested = std::collections::HashSet::new(); + let mut visits: u64 = 0; + for list in grid.values() { + for (k, &i) in list.iter().enumerate() { + for &j in list.iter().skip(k + 1) { + visits += 1; + if visits > max_visits { + return None; + } + if i == j || !tested.insert((i.min(j), i.max(j))) { + continue; + } + split_pair(segs, &mut splits, i, j); + if tested.len() > max_pairs { + return None; + } + } + } + } + for &i in &long { + for j in 0..n { + if i == j || !tested.insert((i.min(j), i.max(j))) { + continue; + } + split_pair(segs, &mut splits, i, j); + if tested.len() > max_pairs { + return None; + } + } + } + Some(splits) + } + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn unit(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// Random segments: short ones, some long ones, and some on a coarse lattice (shared ends, overlaps). + fn random_segments(r: &mut Rng, n: usize, size: f64, long_every: u64) -> Vec<(Vec2, Vec2)> { + let mut out = Vec::new(); + for _ in 0..n { + let a = Vec2::new(r.unit() * size, r.unit() * size); + let seg = match r.next() % 4 { + 0 if long_every > 0 && r.next().is_multiple_of(long_every) => ( + Vec2::new(r.unit() * size * 0.02, r.unit() * size * 0.02), + Vec2::new(size * (0.98 + r.unit() * 0.02), size * (0.98 + r.unit() * 0.02)), + ), + 1 => { + let q = |r: &mut Rng| (r.next() % 9) as f64 * size / 8.0; + (Vec2::new(q(r), q(r)), Vec2::new(q(r), q(r))) + } + _ => (a, a + Vec2::new((r.unit() - 0.5) * size * 0.1, (r.unit() - 0.5) * size * 0.1)), + }; + if !seg.0.near(seg.1, 1e-9) { + out.push(seg); + } + } + out + } + + fn sorted_bits(splits: &[Vec]) -> Vec> { + splits + .iter() + .map(|v| { + let mut v = v.clone(); + v.sort_by(f64::total_cmp); + v.iter().map(|x| x.to_bits()).collect() + }) + .collect() + } + + fn bounds_of(segs: &[(Vec2, Vec2)]) -> Bounds2 { + Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b])) + } + + fn assert_same_search(segs: &[(Vec2, Vec2)], max_visits: u64, max_pairs: usize) { + let bb = bounds_of(segs); + let old = pair_splits_oracle(segs, &bb, max_visits, max_pairs); + let new = pair_splits_limited(segs, &bb, max_visits, max_pairs); + assert_eq!(old.as_deref().map(sorted_bits), new.as_deref().map(sorted_bits), "n={} visits<={max_visits} pairs<={max_pairs}", segs.len()); + } + + #[test] + fn pair_search_matches_oracle_with_budgets() { + // Small inputs in few cells: many pairs share several cells. Sweeping each budget over + // 0..=400 crosses its exhaustion point for most seeds, including the exact boundary. + for seed in 1..=150u64 { + let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1); + let n = 2 + (r.next() % 28) as usize; + let segs = random_segments(&mut r, n, 100.0, 0); + if segs.is_empty() { + continue; + } + for v in 0..=400u64 { + assert_same_search(&segs, v, usize::MAX); + } + for p in 0..=400usize { + assert_same_search(&segs, u64::MAX, p); + } + assert_same_search(&segs, 60_000_000, 20_000_000); + } + } + + #[test] + fn pair_search_matches_oracle_with_long_segments() { + // Around 5000 segments give a 71x71 grid, so a diagonal covers more than 4096 cells and takes the long path. + for seed in 1..=3u64 { + let mut r = Rng(seed.wrapping_mul(0xD1B5_4A32_D192_ED03) | 1); + let n = 5_000 + (r.next() % 500) as usize; + let segs = random_segments(&mut r, n, 1000.0, 40); + let bb = bounds_of(&segs); + let cells = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512); + assert!(cells >= 64, "grid too coarse to reach the long path"); + assert_same_search(&segs, 60_000_000, 20_000_000); + // Budgets exhausted in the cell phase (visits, then pairs). + assert_same_search(&segs, 3_000, 20_000_000); + assert_same_search(&segs, 60_000_000, 200); + assert!(pair_splits_limited(&segs, &bb, 3_000, 20_000_000).is_none()); + assert!(pair_splits_limited(&segs, &bb, 60_000_000, 200).is_none()); + let (cw, ch) = ((bb.width() / cells as f64).max(1e-12), (bb.height() / cells as f64).max(1e-12)); + let span = |a: f64, b: f64, o: f64, w: f64| { + (((b.max(a) - o) / w).floor().min((cells - 1) as f64) - ((b.min(a) - o) / w).floor().max(0.0) + 1.0) as usize + }; + let long = segs.iter().filter(|(a, b)| span(a.x, b.x, bb.min.x, cw) * span(a.y, b.y, bb.min.y, ch) > 4096).count(); + assert!(long > 0, "seed {seed}: no segment takes the long path"); + } + } + + #[test] + fn build_matches_oracle_on_random_drawings() { + for seed in 1..=60u64 { + let mut r = Rng(seed.wrapping_mul(0xA076_1D64_78BD_642F) | 1); + let n = 5 + (r.next() % 400) as usize; + let polys: Vec> = random_segments(&mut r, n, 100.0, 0).into_iter().map(|(a, b)| vec![a, b]).collect(); + let old = build_with(&polys, 1e-6, |s, bb| pair_splits_oracle(s, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED)); + let new = build(&polys, 1e-6); + let (Some(old), Some(new)) = (old, new) else { panic!("seed {seed}: build failed") }; + assert_eq!(old.pts, new.pts, "seed {seed}"); + assert_eq!(old.adj, new.adj, "seed {seed}"); + assert_eq!(old.src, new.src, "seed {seed}"); + assert_eq!(old.edge_src, new.edge_src, "seed {seed}"); + } + } + #[test] fn long_segment_crossing_in_unsampled_cell_is_split() { // A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.