diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index b8848bf2..d5d2d473 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -511,7 +511,22 @@ fn number_pieces(names: &mut [String], pts: &[Vec], mark: char) { } }; let mut order = cs.clone(); - order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal)); + // Pieces lying almost level along the sort axis differ only in the last bits of their centre: + // the centre is the mean of a tessellated boundary, and that depends on where the loop's + // polyline starts, which can differ between processes. Comparing the keys with a tolerance + // makes those pieces equal, so the stable sort keeps the order they arrived in and a name + // keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic. + let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9; + let bucket = |c: Vec3| { + let k = key(c); + // Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a + // total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a` + // and `c` in different ones, or the sort has no order to work with. Since Rust 1.81 + // `sort_by` may panic on a comparator that is not one. Two pieces can still flip when + // they straddle a bucket edge; that is the price of a key that ignores their low bits. + ((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64) + }; + order.sort_by_key(|a| bucket(a.1)); for (k, (j, _)) in order.iter().enumerate() { if let Some(slot) = names.get_mut(*j) { *slot = format!("{n}{mark}{}", k + 1); @@ -760,6 +775,45 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec #[cfg(test)] mod tests { + /// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever + /// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those + /// bits depend on where the loop's polyline starts and can differ between processes — which is + /// how a name's number used to swap between runs (solidcraft#569). + #[test] + fn level_pieces_keep_their_input_order() { + let piece = |dx: f64| vec![Vec3::new(10.0 + dx, 0.0, 0.0), Vec3::new(10.0 + dx, 0.0, 5.0)]; + for (first, second) in [(0.0, 1e-12), (1e-12, 0.0)] { + let mut names = vec!["f".to_string(), "f".to_string()]; + let pts = vec![piece(first), piece(second)]; + number_pieces(&mut names, &pts, '#'); + assert_eq!(names, vec!["f#1".to_string(), "f#2".to_string()], "input order not kept for {first} then {second}"); + } + } + + /// Three pieces spaced just under the tolerance apart: `a` and `b` in one bucket and `b` and `c` + /// in the next, so a tolerance comparison would call `a≈b` and `b≈c` while `a>(); + number_pieces(&mut names, &pts, '#'); + assert_eq!(names, want, "wrong order for offsets {a} {b} {c}"); + } + } + use super::*; /// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest