Property-based and hostile-input tests for geometry numerics (gaps.md S6) (#733)
* Property-based and hostile-input tests for geometry numerics (gaps.md S6)
A deterministic xorshift64* generator (no new dependency) drives a few
thousand cases per property in crates/geom/src/property_tests.rs:
- line/line: hits lie on both lines, parameters reproduce the point,
swapping the operands gives the same point, finite results agree with
the infinite parameters
- line/circle and circle/circle: hits lie on both curves, results are
symmetric, properly crossing circles match the closed form; hostile
inputs (NaN, infinities, subnormals, raw bit patterns, 1e300) never
panic and the guarded functions return only finite hits or none
- segments: extending only adds hits
- closest points: fixed point of closest, perpendicular inside the
segment, endpoints outside
- arcs: at(0)/at(1) are the endpoints at radii from 1e-6 to 1e6; the
three-point arc keeps {start, end} = {p1, p3} and contains p2 (the
CCW-only storage swaps the pair when p2 is on the clockwise side, as
DXF stores arcs) and matches the circumradius closed form; collinear
inputs are rejected
- bounds and vector identities
- known answers at 1e6-1e7 coordinates hold to 1e-9 relative
gaps.md S6 removed and geometry-parity.md's Tests section updated in
the same change.
* docs: drop the sentence the new numerics tests make stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+452
-6
No files matched your search
@@ -74,5 +74,7 @@ pub fn arc_segments(radius: f64, sweep: f64, tol: f64) -> usize {
|
||||
(n as usize).clamp(1, 4096)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod property_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Property-based and hostile-input tests for geometry numerics (gaps.md S6).
|
||||
//!
|
||||
//! Deterministic: a fixed-seed xorshift64* generator per test, no external crate. Each property
|
||||
//! runs a few thousand cheap cases; the known-answer part of the suite works at 1e6–1e7
|
||||
//! coordinates, where double precision starts to show (geometry-parity.md §Tests).
|
||||
|
||||
use crate::*;
|
||||
|
||||
// ---------- generator ----------
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Rng(seed.max(1))
|
||||
}
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545F4914F6CDD1D)
|
||||
}
|
||||
/// Uniform in `[-scale, scale]`.
|
||||
fn f64(&mut self, scale: f64) -> f64 {
|
||||
((self.next() >> 11) as f64 / (1u64 << 53) as f64) * 2.0 * scale - scale
|
||||
}
|
||||
/// Uniform in `[lo, hi]`.
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * (self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn vec(&mut self, scale: f64) -> Vec2 {
|
||||
Vec2::new(self.f64(scale), self.f64(scale))
|
||||
}
|
||||
/// An f64 chosen to be nasty: non-finite, subnormal, extreme or raw bit patterns.
|
||||
fn hostile(&mut self) -> f64 {
|
||||
match self.next() % 10 {
|
||||
0 => 0.0,
|
||||
1 => -0.0,
|
||||
2 => f64::NAN,
|
||||
3 => f64::INFINITY,
|
||||
4 => f64::NEG_INFINITY,
|
||||
5 => 1.0e300,
|
||||
6 => -1.0e300,
|
||||
7 => 5.0e-324,
|
||||
8 => f64::MIN_POSITIVE,
|
||||
_ => f64::from_bits(self.next()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- comparison helpers ----------
|
||||
|
||||
/// Relative closeness with a floor, so zero-scale cases stay meaningful.
|
||||
fn near(a: f64, b: f64, rel: f64) -> bool {
|
||||
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1e-300)
|
||||
}
|
||||
|
||||
fn pt_eq(a: Vec2, b: Vec2, rel: f64) -> bool {
|
||||
near(a.x, b.x, rel) && near(a.y, b.y, rel)
|
||||
}
|
||||
|
||||
/// Every point of `a` matches some point of `b` and vice versa (multiset within tolerance).
|
||||
fn same_set(a: &[Vec2], b: &[Vec2], rel: f64) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut used = vec![false; b.len()];
|
||||
'outer: for p in a {
|
||||
for (i, q) in b.iter().enumerate() {
|
||||
if !used[i] && pt_eq(*p, *q, rel) {
|
||||
used[i] = true;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `p` lies on the circle: |p - c| = r.
|
||||
fn on_circle(p: Vec2, c: Vec2, r: f64, rel: f64) -> bool {
|
||||
near(p.dist(c), r.abs(), rel)
|
||||
}
|
||||
|
||||
/// `p` lies on the line through `a` with direction `d`: cross(d, p - a) = 0.
|
||||
fn on_line(p: Vec2, a: Vec2, d: Vec2, rel: f64) -> bool {
|
||||
(d.cross(p - a)).abs() <= rel * d.len() * (p - a).len().max(1.0)
|
||||
}
|
||||
|
||||
// ---------- line/line ----------
|
||||
|
||||
/// The infinite-line intersection lies on both lines, and swapping the lines gives the same point.
|
||||
#[test]
|
||||
fn line_line_infinite_lies_on_both_and_is_symmetric() {
|
||||
let mut r = Rng::new(0xC0FFEE);
|
||||
let mut checked = 0;
|
||||
for _ in 0..4000 {
|
||||
let (a1, a2, b1, b2) = (r.vec(1000.0), r.vec(1000.0), r.vec(1000.0), r.vec(1000.0));
|
||||
let Some((p, t, u)) = line_line_infinite(a1, a2, b1, b2) else { continue };
|
||||
checked += 1;
|
||||
// The parameters put the point back on each line.
|
||||
assert!(pt_eq(a1 + (a2 - a1) * t, p, 1e-9), "t does not reproduce p: {t}");
|
||||
assert!(pt_eq(b1 + (b2 - b1) * u, p, 1e-9), "u does not reproduce p: {u}");
|
||||
assert!(on_line(p, a1, a2 - a1, 1e-9) && on_line(p, b1, b2 - b1, 1e-9), "p {p:?} not on both lines");
|
||||
// Symmetry.
|
||||
let swapped = line_line_infinite(b1, b2, a1, a2);
|
||||
assert!(swapped.is_some_and(|(q, _, _)| pt_eq(p, q, 1e-9)), "a×b {p:?} != b×a {swapped:?}");
|
||||
}
|
||||
assert!(checked > 1000, "almost-parallel filter ate everything ({checked} cases)");
|
||||
}
|
||||
|
||||
/// The finite-line result agrees with the parameters of the infinite one.
|
||||
#[test]
|
||||
fn line_line_matches_infinite_parameters() {
|
||||
let mut r = Rng::new(0xBAD5EED);
|
||||
for _ in 0..4000 {
|
||||
let a = Line::new(r.vec(100.0), r.vec(100.0));
|
||||
let b = Line::new(r.vec(100.0), r.vec(100.0));
|
||||
let finite = line_line(&a, &b);
|
||||
match line_line_infinite(a.a, a.b, b.a, b.b) {
|
||||
Some((p, t, u)) => {
|
||||
let inside = (-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u);
|
||||
if let Some(q) = finite {
|
||||
assert!(inside && pt_eq(p, q, 1e-9), "finite hit {q:?} at t={t} u={u} outside the segments");
|
||||
} else {
|
||||
assert!(!inside, "no finite hit but t={t} u={u} are inside");
|
||||
}
|
||||
}
|
||||
None => assert!(finite.is_none(), "parallel lines report a finite intersection"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- line/circle ----------
|
||||
|
||||
/// Every reported hit lies on the line and on the circle; a line through the centre hits at ±r
|
||||
/// along its direction (known answer).
|
||||
#[test]
|
||||
fn line_circle_hits_lie_on_both() {
|
||||
let mut r = Rng::new(0x5EED1);
|
||||
for _ in 0..4000 {
|
||||
let l = Line::new(r.vec(1000.0), r.vec(1000.0));
|
||||
let c = Circle { center: r.vec(100.0), radius: r.range(0.01, 50.0) };
|
||||
for (p, t) in line_circle(&l, &c) {
|
||||
assert!(p.x.is_finite() && p.y.is_finite(), "non-finite hit {p:?}");
|
||||
assert!(on_circle(p, c.center, c.radius, 1e-9), "hit {p:?} not on the circle {c:?}");
|
||||
assert!(on_line(p, l.a, l.b - l.a, 1e-9), "hit {p:?} not on the line {l:?}");
|
||||
// The parameter reproduces the hit to within the coordinate scale's rounding.
|
||||
let scale = l.a.len().max(l.b.len()).max(c.center.len()).max(1.0);
|
||||
assert!(l.at(t).dist(p) <= 1e-9 * scale, "parameter {t} off the line");
|
||||
}
|
||||
}
|
||||
// Known answer: through the centre at a known angle.
|
||||
let dir = Vec2::new(3.0, 4.0).normalized();
|
||||
for &(cx, cy) in &[(0.0, 0.0), (1.0e6, -5.0e5), (8.0e6, 7.0e6)] {
|
||||
let c = Circle { center: Vec2::new(cx, cy), radius: 2.5 };
|
||||
let far = c.center + dir * 100.0;
|
||||
let hits = line_circle(&Line::new(c.center - dir, far), &c);
|
||||
assert_eq!(hits.len(), 2, "a line through the centre crosses twice at ({cx},{cy})");
|
||||
let want = [c.center - dir * c.radius, c.center + dir * c.radius];
|
||||
for h in hits.iter().map(|(p, _)| *p) {
|
||||
assert!(want.iter().any(|w| pt_eq(h, *w, 1e-9)), "hit {h:?} != expected {want:?} at ({cx},{cy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile lines and circles never panic; guarded functions return only finite hits or none.
|
||||
#[test]
|
||||
fn line_circle_hostile_inputs_stay_finite() {
|
||||
let mut r = Rng::new(0xF00D);
|
||||
for _ in 0..3000 {
|
||||
let l = Line::new(Vec2::new(r.hostile(), r.hostile()), Vec2::new(r.hostile(), r.hostile()));
|
||||
let c = Circle { center: Vec2::new(r.hostile(), r.hostile()), radius: r.hostile() };
|
||||
for (p, _) in line_circle(&l, &c) {
|
||||
assert!(p.x.is_finite() && p.y.is_finite(), "non-finite hit from hostile input: {p:?}");
|
||||
}
|
||||
// Degenerate line (a == b) on a sane circle: no hits, no panic.
|
||||
let c2 = Circle { center: Vec2::new(1.0, 2.0), radius: 3.0 };
|
||||
assert!(line_circle(&Line::new(c2.center, c2.center), &c2).is_empty(), "zero-length line crosses nothing");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- circle/circle ----------
|
||||
|
||||
/// Hits lie on both circles, the result is symmetric, and the closed-form answer matches.
|
||||
#[test]
|
||||
fn circle_circle_hits_lie_on_both_and_match_the_closed_form() {
|
||||
let mut r = Rng::new(0xC1C1E);
|
||||
for _ in 0..4000 {
|
||||
let c1 = Circle { center: r.vec(100.0), radius: r.range(1.0, 40.0) };
|
||||
let c2 = Circle { center: r.vec(100.0), radius: r.range(1.0, 40.0) };
|
||||
let hits = circle_circle(&c1, &c2);
|
||||
for p in &hits {
|
||||
assert!(on_circle(*p, c1.center, c1.radius, 1e-9), "hit {p:?} not on circle 1");
|
||||
assert!(on_circle(*p, c2.center, c2.radius, 1e-9), "hit {p:?} not on circle 2");
|
||||
}
|
||||
assert!(same_set(&hits, &circle_circle(&c2, &c1), 1e-9), "c1×c2 {hits:?} != c2×c1");
|
||||
// Closed form when they properly cross: x = (d² + r1² − r2²) / 2d, h = √(r1² − x²).
|
||||
let d = c1.center.dist(c2.center);
|
||||
if c1.radius.abs() < d && c2.radius.abs() < d && d < c1.radius + c2.radius && d > (c1.radius - c2.radius).abs() && d > 1e-9 {
|
||||
let dir = (c2.center - c1.center) / d;
|
||||
let x = (d * d + c1.radius * c1.radius - c2.radius * c2.radius) / (2.0 * d);
|
||||
let h2 = c1.radius * c1.radius - x * x;
|
||||
if h2 > 0.0 {
|
||||
let h = h2.sqrt();
|
||||
let want = [c1.center + dir * x + Vec2::new(-dir.y, dir.x) * h, c1.center + dir * x - Vec2::new(-dir.y, dir.x) * h];
|
||||
assert_eq!(hits.len(), 2, "properly crossing circles report {} hits: {c1:?} {c2:?}", hits.len());
|
||||
for p in &hits {
|
||||
assert!(want.iter().any(|w| pt_eq(*p, *w, 1e-9)), "hit {p:?} != closed form {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Degenerate and hostile circles: concentric, contained, negative/NaN radii, extreme centres.
|
||||
#[test]
|
||||
fn circle_circle_degenerate_and_hostile_never_panic() {
|
||||
let mut r = Rng::new(0xD15EA5E);
|
||||
for _ in 0..3000 {
|
||||
let c1 = Circle { center: Vec2::new(r.hostile(), r.hostile()), radius: r.hostile() };
|
||||
let c2 = Circle { center: Vec2::new(r.hostile(), r.hostile()), radius: r.hostile() };
|
||||
for p in circle_circle(&c1, &c2) {
|
||||
assert!(p.x.is_finite() && p.y.is_finite(), "non-finite hit from hostile circles: {p:?}");
|
||||
}
|
||||
}
|
||||
let o = Circle { center: Vec2::ZERO, radius: 5.0 };
|
||||
assert!(circle_circle(&o, &Circle { center: Vec2::ZERO, radius: 5.0 }).is_empty(), "concentric circles do not cross");
|
||||
assert!(circle_circle(&o, &Circle { center: Vec2::new(1.0, 0.0), radius: 1.0 }).is_empty(), "contained circle does not cross");
|
||||
assert!(circle_circle(&o, &Circle { center: Vec2::new(20.0, 0.0), radius: 1.0 }).is_empty(), "far circle does not cross");
|
||||
assert!(circle_circle(&o, &Circle { center: Vec2::ZERO, radius: -5.0 }).is_empty(), "negative radius is not a circle");
|
||||
}
|
||||
|
||||
// ---------- segments ----------
|
||||
|
||||
/// Extending segments only adds hits: the finite result is a subset of the extended one.
|
||||
#[test]
|
||||
fn segment_extension_only_adds_hits() {
|
||||
let mut r = Rng::new(0x5E6C0DE);
|
||||
for _ in 0..3000 {
|
||||
let s1 = Segment::Line(Line::new(r.vec(100.0), r.vec(100.0)));
|
||||
let s2 = Segment::Line(Line::new(r.vec(100.0), r.vec(100.0)));
|
||||
let finite = intersect_segments(&s1, &s2);
|
||||
let extended = intersect_ext(&s1, &s2, true);
|
||||
for p in finite {
|
||||
assert!(extended.iter().any(|q| pt_eq(p, *q, 1e-9)), "finite hit {p:?} missing from the extended result");
|
||||
}
|
||||
for p in &extended {
|
||||
assert!(p.x.is_finite() && p.y.is_finite(), "non-finite segment hit {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- closest points ----------
|
||||
|
||||
/// The closest point on a line is a fixed point of `closest`, and the offset is perpendicular.
|
||||
#[test]
|
||||
fn line_closest_is_a_fixed_point_and_perpendicular() {
|
||||
let mut r = Rng::new(0xC105E);
|
||||
for _ in 0..4000 {
|
||||
let l = Line::new(r.vec(1000.0), r.vec(1000.0));
|
||||
if (l.b - l.a).len() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let p = r.vec(1000.0);
|
||||
let c = l.closest(p);
|
||||
assert!(pt_eq(l.closest(c), c, 1e-12), "closest is not idempotent at {c:?}");
|
||||
assert!(l.dist(p) >= 0.0, "negative distance");
|
||||
// `Line` is a finite segment: the offset is perpendicular only for interior projections;
|
||||
// otherwise the closest point is an endpoint.
|
||||
let t = l.param_of(p);
|
||||
let d = (l.b - l.a).normalized();
|
||||
if (1e-9..=1.0 - 1e-9).contains(&t) {
|
||||
assert!((c - p).dot(d).abs() <= 1e-9 * (c - p).len().max(1.0), "offset not perpendicular to the line");
|
||||
} else {
|
||||
assert!(pt_eq(c, l.a, 1e-12) || pt_eq(c, l.b, 1e-12), "closest outside the segment is not an endpoint: {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The closest point on an arc is on its circle, or one of its endpoints.
|
||||
#[test]
|
||||
fn arc_closest_is_on_the_circle_or_an_endpoint() {
|
||||
let mut r = Rng::new(0xA2C10);
|
||||
for _ in 0..4000 {
|
||||
let a = Arc::new(r.vec(100.0), r.range(0.1, 50.0), r.f64(std::f64::consts::TAU), r.f64(std::f64::consts::TAU));
|
||||
let p = r.vec(200.0);
|
||||
let c = a.closest(p);
|
||||
assert!(
|
||||
on_circle(c, a.center, a.radius, 1e-9) || pt_eq(c, a.start_point(), 1e-9) || pt_eq(c, a.end_point(), 1e-9),
|
||||
"closest {c:?} neither on the circle nor an endpoint of {a:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- arcs ----------
|
||||
|
||||
/// Parameter and constructor consistency of arcs, from tiny to huge radii.
|
||||
#[test]
|
||||
fn arc_endpoints_and_sampling_are_consistent() {
|
||||
let mut r = Rng::new(0xA2C_5EED_7101 & 0x7FFF_FFFF_FFFF_FFFF);
|
||||
for _ in 0..3000 {
|
||||
let radius = r.range(1e-6, 1e6);
|
||||
let a = Arc::new(r.vec(1000.0), radius, r.f64(100.0), r.f64(100.0));
|
||||
assert!(pt_eq(a.at(0.0), a.start_point(), 1e-9), "at(0) != start_point");
|
||||
assert!(pt_eq(a.at(1.0), a.end_point(), 1e-9), "at(1) != end_point");
|
||||
for k in 0..=10 {
|
||||
let t = k as f64 / 10.0;
|
||||
let p = a.at(t);
|
||||
assert!(p.x.is_finite() && p.y.is_finite(), "arc sample not finite at t={t}: {p:?}");
|
||||
assert!(on_circle(p, a.center, a.radius, 1e-9), "arc sample at t={t} off the circle: {p:?}");
|
||||
}
|
||||
assert!(a.len() >= 0.0, "negative arc length");
|
||||
}
|
||||
}
|
||||
|
||||
/// Three points rebuild an arc through all three; collinear points cannot.
|
||||
#[test]
|
||||
fn arc_from_3_points_passes_through_the_inputs() {
|
||||
let mut r = Rng::new(0x3BC1);
|
||||
for _ in 0..3000 {
|
||||
let p1 = r.vec(100.0);
|
||||
let p3 = r.vec(100.0);
|
||||
if p1.dist(p3) < 1.0 {
|
||||
continue;
|
||||
}
|
||||
// A point clearly off the p1-p3 line.
|
||||
let mid = (p1 + p3) * 0.5;
|
||||
let perp = Vec2::new(-(p3 - p1).y, (p3 - p1).x).normalized() * r.range(1.0, 50.0);
|
||||
let p2 = mid + perp;
|
||||
let Some(a) = Arc::from_3_points(p1, p2, p3) else {
|
||||
assert!(perp.len() < 1e-9, "non-collinear points {p1:?} {p2:?} {p3:?} rejected");
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
on_circle(p1, a.center, a.radius, 1e-9) && on_circle(p2, a.center, a.radius, 1e-9) && on_circle(p3, a.center, a.radius, 1e-9),
|
||||
"rebuilt arc misses its inputs: {a:?}"
|
||||
);
|
||||
// The arc type is CCW-only: when p2 lies on the clockwise side of p1→p3, the same point
|
||||
// set is stored with start/end swapped (as DXF arcs are; the direction lives in Segment).
|
||||
assert!(
|
||||
(pt_eq(a.start_point(), p1, 1e-9) && pt_eq(a.end_point(), p3, 1e-9))
|
||||
|| (pt_eq(a.start_point(), p3, 1e-9) && pt_eq(a.end_point(), p1, 1e-9)),
|
||||
"arc endpoints are not {{p1, p3}}: {a:?}"
|
||||
);
|
||||
// Whichever way it is stored, p2 lies on the returned arc.
|
||||
assert!(a.contains_angle(a.center.angle_to(p2)), "p2 is not on the returned arc: {a:?}");
|
||||
// The radius matches the circumradius closed form.
|
||||
let ((ax, ay), (bx, by), (cx2, cy2)) = ((p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y));
|
||||
let d = 2.0 * (ax * (by - cy2) + bx * (cy2 - ay) + cx2 * (ay - by));
|
||||
let ux = ((ax * ax + ay * ay) * (by - cy2) + (bx * bx + by * by) * (cy2 - ay) + (cx2 * cx2 + cy2 * cy2) * (ay - by)) / d;
|
||||
let uy = ((ax * ax + ay * ay) * (cx2 - bx) + (bx * bx + by * by) * (ax - cx2) + (cx2 * cx2 + cy2 * cy2) * (bx - ax)) / d;
|
||||
let circum = (Vec2::new(ux, uy) - p1).len();
|
||||
assert!(near(a.radius, circum, 1e-9), "radius {} != circumradius {circum}", a.radius);
|
||||
}
|
||||
// Exactly collinear points (axis-aligned integers) cannot define a circle.
|
||||
assert!(Arc::from_3_points(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)).is_none(), "collinear points have no circumcircle");
|
||||
assert!(
|
||||
Arc::from_3_points(Vec2::new(5.0, 7.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 12.0)).is_none(),
|
||||
"vertical collinear points have no circumcircle"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- bounds ----------
|
||||
|
||||
#[test]
|
||||
fn bounds_union_contains_both_boxes() {
|
||||
let mut r = Rng::new(0xB0D4D);
|
||||
for _ in 0..3000 {
|
||||
let b1 = Bounds2::from_points([r.vec(100.0), r.vec(100.0)]);
|
||||
let b2 = Bounds2::from_points([r.vec(100.0), r.vec(100.0)]);
|
||||
let u = b1.union(&b2);
|
||||
for b in [&b1, &b2] {
|
||||
assert!(u.contains(b.min) && u.contains(b.max), "union does not contain {b:?}");
|
||||
}
|
||||
assert!(u.width() >= b1.width() - 1e-12 && u.width() >= b2.width() - 1e-12, "union narrower than its parts");
|
||||
assert!(u.height() >= b1.height() - 1e-12 && u.height() >= b2.height() - 1e-12, "union shorter than its parts");
|
||||
assert!(
|
||||
!u.intersects(&Bounds2::from_points([Vec2::new(1e6, 1e6), Vec2::new(2e6, 2e6)])) || u.max.x >= 1e6,
|
||||
"intersects disagrees with containment"
|
||||
);
|
||||
}
|
||||
// A box contains itself and contains_box agrees with contains on corners.
|
||||
let b = Bounds2::from_points([Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)]);
|
||||
assert!(b.contains_box(&b));
|
||||
}
|
||||
|
||||
// ---------- vectors ----------
|
||||
|
||||
#[test]
|
||||
fn vector_identities_hold_for_finite_inputs() {
|
||||
let mut r = Rng::new(0xEEC1 & 0x7FFF);
|
||||
for _ in 0..4000 {
|
||||
let a = r.vec(100.0);
|
||||
let b = r.vec(100.0);
|
||||
assert!(near(a.dot(b), b.dot(a), 1e-12), "dot is not commutative");
|
||||
assert!(pt_eq(a + b - b, a, 1e-12), "(a + b) - b != a");
|
||||
assert!(near(a.len2(), a.dot(a), 1e-12), "len2 != dot(a, a)");
|
||||
if a.len() > 1e-9 {
|
||||
let n = a.normalized();
|
||||
assert!(near(n.len(), 1.0, 1e-12), "normalized length is not 1: {n:?}");
|
||||
}
|
||||
let ang = r.f64(1e6);
|
||||
assert!(near(Vec2::from_angle(ang).len(), 1.0, 1e-12), "from_angle is not unit at {ang}");
|
||||
assert!(near(Vec2::polar(a, b.len(), ang).dist(a), b.len(), 1e-12), "polar is not at the given distance");
|
||||
}
|
||||
}
|
||||
|
||||
/// Known answers at 1e6–1e7 coordinates, where ulp grows to ~2e-9…2e-7 (geometry-parity.md).
|
||||
#[test]
|
||||
fn large_coordinate_known_answers_stay_accurate() {
|
||||
for &shift in &[1.0e6, 3.0e6, 1.0e7] {
|
||||
let base = Vec2::new(shift, -shift * 0.4);
|
||||
// Two lines crossing at a known point.
|
||||
let target = base + Vec2::new(123.456, -78.9);
|
||||
let d1 = Vec2::new(1.0, 0.25);
|
||||
let d2 = Vec2::new(-0.5, 1.75);
|
||||
let crossed = line_line_infinite(target - d1 * 250.0, target + d1 * 250.0, target - d2 * 250.0, target + d2 * 250.0);
|
||||
assert!(crossed.is_some(), "orthogonal-ish lines failed to cross at {shift}");
|
||||
let (p, _, _) = crossed.unwrap_or((target, 0.0, 0.0));
|
||||
assert!(p.dist(target) <= 1e-9 * shift, "crossing drifted {} at shift {shift}", p.dist(target));
|
||||
// A circle pair with a closed-form crossing.
|
||||
let c1 = Circle { center: base, radius: 10.0 };
|
||||
let c2 = Circle { center: base + Vec2::new(12.0, 0.0), radius: 10.0 };
|
||||
let hits = circle_circle(&c1, &c2);
|
||||
assert_eq!(hits.len(), 2, "symmetric circles cross twice at {shift}: {hits:?}");
|
||||
let x = (12.0f64 * 12.0 + 10.0 * 10.0 - 10.0 * 10.0) / (2.0 * 12.0);
|
||||
let h = (10.0 * 10.0 - x * x).sqrt();
|
||||
for p in &hits {
|
||||
let want1 = Vec2::new(base.x + x, base.y + h);
|
||||
let want2 = Vec2::new(base.x + x, base.y - h);
|
||||
assert!(pt_eq(*p, want1, 1e-9) || pt_eq(*p, want2, 1e-9), "hit {p:?} != closed form ({want1:?} / {want2:?}) at {shift}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Where CADCraft falls short of AutoCAD
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (STRETCH polygon and fence selection landed: F1 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (property-based and hostile-input tests for geometry landed; S6 removed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
||||
@@ -115,7 +115,6 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| S3 | 40 open PRs, many community bug fixes awaiting review | `gh pr list` | 10–20 |
|
||||
| S4 | Open correctness issues: extents union far-apart geometry (#166), lines not visible mid-command (#44), browser plot shows raw PDF bytes (#134) (closing a tab and browser saves fixed, #76, #133) | issues | 4–6 |
|
||||
| S5 | No smoke test of every command through the UI (PR #42 proposes one) | — | 6–10 |
|
||||
| S6 | No property-based or fuzz tests for geometry; hostile-input tests cover parsing only | [geometry-parity](geometry-parity.md#tests) | 4–6 |
|
||||
|
||||
## Platforms
|
||||
|
||||
@@ -149,6 +148,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode |
|
||||
| 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed |
|
||||
| 2026-10-13 | minor | STRETCH takes crossing and window polygons (CP, WP), BOX and a fence (F), typed and as JSON (`polygon`, `fence`): F1 narrowed |
|
||||
| 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (deterministic xorshift generator, no new dependency; known answers at 1e6–1e7 coordinates): S6 removed |
|
||||
| 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed |
|
||||
| 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes |
|
||||
| 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
# Geometry parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726); previously minor: constraint solver: DOF report, G2 Smooth, ellipse/spline/block geometry, CONSTRAINTINFER, #546) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (property-based and hostile-input test suite for geometry landed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right
|
||||
to the last displayed digit or the tool is unusable for production drawings. This document
|
||||
@@ -126,9 +126,15 @@ lines are scanline families clipped against the tessellated loops (`render/src/h
|
||||
|
||||
## Tests
|
||||
|
||||
`#[test]` counts on 2026-10-10: geom 28, constraints 22, render 50, engine 174. No property-based
|
||||
or fuzz tests of geometry, and no known-answer suite at large coordinates. Hostile-input tests
|
||||
exist for parsing, not for numerics.
|
||||
`#[test]` counts on 2026-10-10: geom 28, constraints 22, render 50, engine 174. A property-based
|
||||
and hostile-input suite for geometry numerics landed 2026-10-12 (`crates/geom/src/property_tests.rs`):
|
||||
a deterministic xorshift generator (no new dependency) drives a few thousand cases per property —
|
||||
intersections lie on both curves and are symmetric, circle pairs match the closed form, closest
|
||||
points are fixed points (perpendicular inside the segment, endpoints outside), three-point arcs
|
||||
keep `{start, end} = {p1, p3}` and contain p2 (the CCW-only storage swaps the pair when p2 is on
|
||||
the clockwise side, as DXF does), and hostile inputs (NaN, infinities, subnormals, raw bit
|
||||
patterns, 1e300) never panic and return only finite hits or none. Known answers hold at 1e6–1e7
|
||||
coordinates to 1e-9 relative.
|
||||
|
||||
## What to do first (ranked)
|
||||
|
||||
@@ -150,6 +156,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) |
|
||||
| 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (`crates/geom/src/property_tests.rs`): intersections, closest points, three-point arcs, bounds, vector identities; hostile f64s never panic; known answers at 1e6–1e7 |
|
||||
| 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver |
|
||||
| 2026-10-11 | minor | EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; closed splines trim to one piece; trim/extend section 45% → 55% (#466) |
|
||||
| 2026-10-11 | minor | FILLET with elliptical arcs and splines; trim/extend section 55% → 65% (#466) |
|
||||
|
||||
Reference in new issue
Block a user