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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tyler Chiriboga
2026-11-02 14:36:53 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 Tyler Chiriboga
parent 6660d88785
commit 7bc76f94ca
2 changed files with 190 additions and 38 deletions
+104 -38
View File
@@ -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<f64>) {
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<Vec<Handle>>, o: &Opts) -> Result<Report
/// Equal objects (numbers compared to the tolerance): the first in draw order stays, unless a
/// later one is followed by an associative object.
///
/// Objects are hashed by their properties and shape (their JSON without the numbers), then by a
/// weighted sum of their numbers on a grid of the tolerance times the weights' sum: two equal
/// objects' sums are that close, so they land in the same or neighbouring cells, wherever the
/// numbers fall.
fn duplicates(cands: &[Cand], o: &Opts, gone: &mut [bool], rep: &mut Report) {
let mut seen: HashMap<(u32, String), usize> = HashMap::new();
for (i, c) in cands.iter().enumerate() {
// Keepers so far: (properties, shape) → cell of the sum → their numbers.
type Keepers = HashMap<i64, Vec<Vec<f64>>>;
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<usize>, Vec<usize>) = (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<usize, EntityKind>, 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<usize>> = 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<usize
{
continue;
}
buckets.entry((c.prop, cell(center.x), cell(center.y), cell(r), cell(center.z))).or_default().push(i);
recs.push((i, c.prop, [center.x, center.y, r, center.z]));
}
let mut keys: Vec<_> = 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<usize>> = HashMap::new();
let mut group_of: Vec<usize> = 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<usize>> = Vec::new();
for (ri, (_, prop, v)) in recs.iter().enumerate() {
let cells = v.map(|x| cell_of(x, g));
let mut near_groups: Vec<usize> = 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::<Vec<usize>>());
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<usize
}
continue;
}
same_circle(group, cands, o, gone, changed, rep);
same_circle(&group, cands, o, gone, changed, rep);
}
}
+86
View File
@@ -66,6 +66,48 @@ fn duplicates_tolerance_and_ignored_properties() {
assert!(r["message"].as_str().unwrap().contains("duplicate(s) deleted"));
}
#[test]
fn objects_within_the_tolerance_are_duplicates_on_either_side_of_a_rounding_step() {
// Each pair is closer than the tolerance, but its numbers round to different multiples of it
// (50.004 → 5000, 50.006 → 5001 at 0.01).
let pairs = [
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50.004, 50], "radius": 3 }), json!({ "center": [50.006, 50], "radius": 3 })),
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50, 50], "radius": 3.004 }), json!({ "center": [50, 50], "radius": 3.006 })),
(json!({}), "circle", json!({ "center": [0.0000014, 0], "radius": 3 }), json!({ "center": [0.0000016, 0], "radius": 3 })),
(json!({ "tolerance": 0.01 }), "point", json!({ "at": [10.004, 0] }), json!({ "at": [10.006, 0] })),
(
json!({ "tolerance": 0.01 }),
"text",
json!({ "at": [0.004, 0], "text": "A", "height": 2.5 }),
json!({ "at": [0.006, 0], "text": "A", "height": 2.5 }),
),
];
for (opts, cmd, a, b) in pairs {
let mut s = Session::new();
s.execute(cmd, &a).unwrap();
s.execute(cmd, &b).unwrap();
let r = ok(&mut s, opts);
assert_eq!(r["deleted"], 1, "{cmd} {a} {b}: {r}");
assert_eq!(count(&s), 1, "{cmd} {a} {b}");
}
// Arcs on (about) one circle are combined the same way.
let mut s = Session::new();
s.execute("arc", &json!({ "center": [0.004, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
s.execute("arc", &json!({ "center": [0.006, 0], "radius": 5, "start": 45, "end": 180 })).unwrap();
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["overlapping"], 1, "{r}");
assert_eq!(count(&s), 1);
// Farther apart than the tolerance, they stay.
let mut s = Session::new();
s.execute("circle", &json!({ "center": [50.004, 50], "radius": 3 })).unwrap();
s.execute("circle", &json!({ "center": [50.016, 50], "radius": 3 })).unwrap();
s.execute("point", &json!({ "at": [10.004, 0] })).unwrap();
s.execute("point", &json!({ "at": [10.016, 0] })).unwrap();
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["deleted"], 0, "{r}");
assert_eq!(count(&s), 4);
}
#[test]
fn collinear_lines_overlap_and_end_to_end() {
let mut s = Session::new();
@@ -230,3 +272,47 @@ fn many_objects_stay_fast() {
assert_eq!(count(&s), 4001);
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
}
#[test]
fn within_the_tolerance_does_not_chain() {
// At 0.01, the arc is within the tolerance of both circles, but the circles are 0.016 apart:
// the arc goes, both circles stay, whichever comes first in draw order.
let circle = |s: &mut Session, x: f64| s.execute("circle", &json!({ "center": [x, 0], "radius": 3 })).unwrap();
let arc_at = |s: &mut Session, x: f64, start: f64, end: f64| {
s.execute("arc", &json!({ "center": [x, 0], "radius": 3, "start": start, "end": end })).unwrap()
};
let kinds = |s: &Session| s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
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());
}