From 7bc76f94ca080275eb866567e45f3110a6525c6c Mon Sep 17 00:00:00 2001 From: Tyler Chiriboga Date: Mon, 2 Nov 2026 15:36:53 -0600 Subject: [PATCH] OVERKILL: objects within the tolerance are duplicates wherever their numbers fall (#2653) * OVERKILL: objects within the tolerance are duplicates wherever their numbers fall OVERKILL compared objects by rounding every number to a multiple of the tolerance. Two numbers closer than the tolerance on either side of a rounding step round apart, so the objects were kept: at 0.01, circles at x = 80.001 and 80.003 were merged but circles at 50.004 and 50.006 were not. Arcs on one circle were bucketed the same way. Duplicates are now found by shape (the JSON without its floating-point numbers; counts and flags must match exactly) and a weighted sum of the numbers on a grid of the tolerance, looking in the neighbouring cells too, then compared pairwise to within the tolerance. Arcs and circles look in the neighbouring cells of centre and radius and are grouped with a union-find. Lines were already grouped by sorting and are unchanged. Co-Authored-By: Claude Opus 5.5 * OVERKILL: within the tolerance doesn't chain Arcs and circles were grouped with a union-find over pairwise matches, so A ~ B and B ~ C put A and C in one group even when A and C are farther apart than the tolerance: a circle C was deleted for matching only the arc between it and the kept circle A, and arcs were combined across the chain. Each object now joins the first group whose every member is within the tolerance of it, or starts its own. In the duplicates pass, a followed object displaced an earlier keeper that later objects had already matched, leaving them deleted up to twice the tolerance from what stays. Followed objects are now looked at first, so every deletion is within the tolerance of an object that stays. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/overkill.rs | 142 +++++++++++++++++++++-------- crates/engine/tests/it/overkill.rs | 86 +++++++++++++++++ 2 files changed, 190 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/overkill.rs b/crates/engine/src/cmd/overkill.rs index abfdff2eb..632001461 100644 --- a/crates/engine/src/cmd/overkill.rs +++ b/crates/engine/src/cmd/overkill.rs @@ -146,20 +146,49 @@ impl Opts { } } -/// Round every number of a JSON value to a multiple of `q`. -fn quantize(v: &mut Value, q: f64) { +/// Take the measurements (floating-point numbers) out of a JSON value, in order, leaving a marker +/// in their place: two objects are equal when what is left matches, counts and flags included, +/// and each pair of measurements is within the tolerance. +fn split_numbers(v: &mut Value, nums: &mut Vec) { match v { Value::Number(n) => { - if let Some(x) = n.as_f64() { - *v = json!((x / q).round()); + if n.is_f64() + && let Some(x) = n.as_f64() + { + nums.push(x); + *v = Value::String(NUMBER.into()); } } - Value::Array(a) => a.iter_mut().for_each(|x| quantize(x, q)), - Value::Object(o) => o.values_mut().for_each(|x| quantize(x, q)), + Value::Array(a) => a.iter_mut().for_each(|x| split_numbers(x, nums)), + Value::Object(o) => o.values_mut().for_each(|x| split_numbers(x, nums)), _ => {} } } +/// What [`split_numbers`] leaves where a number was. +const NUMBER: &str = "\u{0}#"; + +/// Whether two lists of numbers match pairwise to within `tol`. +fn within(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The numbers summed with weights in [1, 2) that no simple pattern shares (golden-ratio steps), +/// so points on a diagonal of a grid don't all get the same sum; and the sum of the weights. +fn weighted_sum(nums: &[f64]) -> (f64, f64) { + nums.iter().enumerate().fold((0.0, 0.0), |(sum, total), (j, x)| { + let w = 1.0 + (j as f64 * 0.618_033_988_749_895).fract(); + (sum + w * x, total + w) + }) +} + +/// The cell of `x` on a grid of `step`. Values within one step of each other land in the same or +/// neighbouring cells; values too large for the grid share the outermost ones. +fn cell_of(x: f64, step: f64) -> i64 { + let k = (x / step).floor(); + if k.is_finite() { k.clamp(-9e15, 9e15) as i64 } else { i64::MIN } +} + #[derive(Default)] struct Report { duplicates: usize, @@ -234,33 +263,37 @@ fn overkill(s: &mut Session, hs: Option>, o: &Opts) -> Result = HashMap::new(); - for (i, c) in cands.iter().enumerate() { + // Keepers so far: (properties, shape) → cell of the sum → their numbers. + type Keepers = HashMap>>; + let mut seen: HashMap<(u32, String), Keepers> = HashMap::new(); + // Followed objects first, so a keeper is never displaced: everything deleted is within the + // tolerance of an object that stays. + let (followed, rest): (Vec, Vec) = (0..cands.len()).partition(|i| cands.get(*i).is_some_and(|c| c.protected)); + for i in followed.into_iter().chain(rest) { + let Some(c) = cands.get(i) else { continue }; let Ok(mut v) = serde_json::to_value(&c.e.kind) else { continue }; - if o.tol > 0.0 { - quantize(&mut v, o.q()); + let mut nums = Vec::new(); + split_numbers(&mut v, &mut nums); + let (sum, weights) = weighted_sum(&nums); + let cell = cell_of(sum, o.q() * weights.max(1.0)); + let cells = seen.entry((c.prop, v.to_string())).or_default(); + let found = [cell.saturating_sub(1), cell, cell.saturating_add(1)] + .into_iter() + .any(|k| cells.get(&k).is_some_and(|keepers| keepers.iter().any(|n| within(n, &nums, o.tol)))); + if !found || c.protected { + // A followed object stays even when it matches another. + cells.entry(cell).or_default().push(nums); + continue; } - let key = (c.prop, v.to_string()); - match seen.get(&key).copied() { - None => { - seen.insert(key, i); - } - Some(k) => { - let keeper_protected = cands.get(k).is_some_and(|x| x.protected); - let victim = match (keeper_protected, c.protected) { - (_, false) => i, - (false, true) => { - seen.insert(key, i); - k - } - (true, true) => continue, - }; - if let Some(g) = gone.get_mut(victim) { - *g = true; - rep.duplicates += 1; - } - } + if let Some(g) = gone.get_mut(i) { + *g = true; + rep.duplicates += 1; } } } @@ -421,8 +454,8 @@ fn collinear(members: &[&LineRec], cands: &[Cand], o: &Opts, gone: &mut [bool], fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap, rep: &mut Report) { let q = o.q(); let g = q.max(1e-9); - let cell = |x: f64| (x / g).round() as i64; - let mut buckets: HashMap<(u32, i64, i64, i64, i64), Vec> = HashMap::new(); + // (index, properties, centre and radius); cells of `g` hold them. + let mut recs: Vec<(usize, u32, [f64; 4])> = Vec::new(); for (i, c) in cands.iter().enumerate() { if gone.get(i).copied().unwrap_or(true) || c.protected { continue; @@ -442,12 +475,45 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap = buckets.keys().copied().collect(); - keys.sort_unstable(); - for key in keys { - let Some(group) = buckets.get(&key) else { continue }; + // Those within `g` of each other in centre and radius are on one circle. Each object joins the + // first group whose every member is within `g` of it, or starts one: matches don't chain, so + // nothing is deleted for matching only an object that itself matches the keeper. Members are + // looked for in the neighbouring cells too, so values on either side of a cell boundary meet. + let mut grid: HashMap<(u32, [i64; 4]), Vec> = HashMap::new(); + let mut group_of: Vec = Vec::with_capacity(recs.len()); + // Each group's records, in draw order; groups in the order of their first object. + let mut members: Vec> = Vec::new(); + for (ri, (_, prop, v)) in recs.iter().enumerate() { + let cells = v.map(|x| cell_of(x, g)); + let mut near_groups: Vec = Vec::new(); + for n in 0..81_i64 { + let near = [n % 3 - 1, n / 3 % 3 - 1, n / 9 % 3 - 1, n / 27 % 3 - 1]; + let mut key = cells; + for (k, d) in key.iter_mut().zip(near) { + *k = k.saturating_add(d); + } + near_groups.extend(grid.get(&(*prop, key)).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|rj| group_of.get(*rj))); + } + near_groups.sort_unstable(); + near_groups.dedup(); + let fits = |gi: &usize| members.get(*gi).is_some_and(|m| m.iter().all(|rk| recs.get(*rk).is_some_and(|(_, _, w)| within(v, w, g)))); + let gi = match near_groups.into_iter().find(fits) { + Some(gi) => gi, + None => { + members.push(Vec::new()); + members.len() - 1 + } + }; + if let Some(m) = members.get_mut(gi) { + m.push(ri); + } + group_of.push(gi); + grid.entry((*prop, cells)).or_default().push(ri); + } + let groups = members.iter().map(|m| m.iter().filter_map(|ri| recs.get(*ri).map(|r| r.0)).collect::>()); + for group in groups { if group.len() < 2 { continue; } @@ -465,7 +531,7 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap>(); + let mut s = Session::new(); + circle(&mut s, 50.0); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!((r["duplicates"].as_u64(), r["overlapping"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + let mut s = Session::new(); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + // Arcs alone: the third is 0.016 from the first, so it is not combined with it. + let mut s = Session::new(); + arc_at(&mut s, 0.0, 0.0, 90.0); + arc_at(&mut s, 0.008, 45.0, 180.0); + arc_at(&mut s, 0.016, 100.0, 200.0); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["overlapping"], 1, "{r}"); + assert_eq!(kinds(&s), ["Arc", "Arc"]); + // A followed line displaces the first one, which a third line matched: the third is 0.016 from + // the followed line, so it stays. + let mut s = Session::new(); + let first = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let third = line(&mut s, [0.0, 0.008], [10.0, 0.008]); + let followed = line(&mut s, [0.0, -0.008], [10.0, -0.008]); + s.execute("dimlinear", &json!({ "object": followed.hex(), "at": [5, 5] })).unwrap(); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + let d = s.doc().unwrap(); + assert!(d.entity(first).is_none() && d.entity(third).is_some() && d.entity(followed).is_some()); +}