diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 9d45453a1..89984cea0 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -514,8 +514,31 @@ impl Polyline { } /// Signed area (CCW positive) of a closed polyline including bulge areas. pub fn area(&self) -> f64 { - let pts = self.tessellate(1e-4); - shoelace(&pts) + // Integrate the chords exactly and add the signed circular-segment area + // for every bulge. A tessellated shoelace loses precision on large arcs. + // Working relative to the first vertex avoids cancellation when a small + // shape is located far from the drawing origin. + let Some(first) = self.vertices.first() else { + return 0.0; + }; + let origin = first.p; + let n = self.vertices.len(); + let mut twice_area = 0.0; + for i in 0..n { + let (Some(a), Some(b)) = (self.vertices.get(i), self.vertices.get((i + 1) % n)) else { + continue; + }; + twice_area += (a.p - origin).cross(b.p - origin); + // Open polylines keep their historical implicit straight closing edge. + // The final vertex's bulge is only used for an explicit closing segment. + if (i + 1 < n || self.closed) + && let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) + { + let sweep = if ccw { arc.sweep() } else { -arc.sweep() }; + twice_area += arc.radius * arc.radius * (sweep - sweep.sin()); + } + } + twice_area / 2.0 } pub fn closest(&self, p: Vec2) -> Option { let mut best: Option<(f64, Vec2)> = None; @@ -540,10 +563,15 @@ pub fn shoelace(pts: &[Vec2]) -> f64 { if n < 3 { return 0.0; } + let Some(origin) = pts.first().copied() else { + return 0.0; + }; + // Measure about a local origin so a small polygon at a large world + // coordinate does not lose its area to cancellation of huge products. let mut s = 0.0; for i in 0..n { if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) { - s += a.cross(*b); + s += (*a - origin).cross(*b - origin); } } s / 2.0 diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index a9f5daf6e..1c17fb282 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -236,3 +236,45 @@ fn elliptical_arc_bounds_exclude_extrema_outside_the_sweep() { assert!((bounds.min.y - e.at_param(e.end).y).abs() < 1e-12); assert!((bounds.max.y - 18.25_f64.sqrt()).abs() < 1e-12); } + +#[test] +fn polyline_area_is_exact_for_bulge_segments() { + // One semicircular arc from left to right plus a straight closing edge. + let mut p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0)], true); + p.vertices[0].bulge = 1.0; + assert!((p.area() - PI / 2.0).abs() < 1e-12, "{}", p.area()); + p.vertices[0].bulge = -1.0; + assert!((p.area() + PI / 2.0).abs() < 1e-12, "{}", p.area()); +} + +#[test] +fn polyline_area_is_stable_far_from_the_origin() { + let o = 1.0e12; + let p = Polyline::from_points( + &[ + Vec2::new(o, o), + Vec2::new(o + 10.0, o), + Vec2::new(o + 10.0, o + 10.0), + Vec2::new(o, o + 10.0), + ], + true, + ); + assert!((p.area() - 100.0).abs() < 1e-12, "{}", p.area()); + // An open polyline's measured area retains the implicit straight closing edge. + let mut open = p; + open.closed = false; + assert!((open.area() - 100.0).abs() < 1e-12); +} + +#[test] +fn shoelace_preserves_small_polygon_area_at_large_coordinates() { + let o = 1.0e12; + let corners = [ + Vec2::new(o, o), + Vec2::new(o + 10.0, o), + Vec2::new(o + 10.0, o + 10.0), + Vec2::new(o, o + 10.0), + ]; + assert_eq!(shoelace(&corners), 100.0); + assert_eq!(shoelace(&corners.iter().rev().copied().collect::>()), -100.0); +}