Ellipse: an unsliced ellipse edits as four anchor points, not six (#2080)

A whole ellipse and each ring contour are built as four cubics through the extremes instead of a tolerance-subdivided arc. Sliced ellipses are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-07 11:52:52 -05:00
co-authored by Claude Fable 5.1
parent 3635c23070
commit b33da8a7b0
2 changed files with 60 additions and 6 deletions
+18 -5
View File
@@ -472,15 +472,28 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
// Four cubics through the right, bottom, left and top extremes (clockwise from 3 o'clock),
// the same four anchors an ellipse has when edited as a vector; an arc subdivided by
// tolerance would give five or six.
let whole = |rx: f64, ry: f64| {
const K: f64 = 0.552_284_749_830_793_4;
let (kx, ky) = (rx * K, ry * K);
let mut p = BezPath::new();
p.move_to(c + Vec2::new(rx, 0.0));
p.curve_to(c + Vec2::new(rx, ky), c + Vec2::new(kx, ry), c + Vec2::new(0.0, ry));
p.curve_to(c + Vec2::new(-kx, ry), c + Vec2::new(-rx, ky), c + Vec2::new(-rx, 0.0));
p.curve_to(c + Vec2::new(-rx, -ky), c + Vec2::new(-kx, -ry), c + Vec2::new(0.0, -ry));
p.curve_to(c + Vec2::new(kx, -ry), c + Vec2::new(rx, -ky), c + Vec2::new(rx, 0.0));
p
};
if full && inner <= 0.0 {
return whole(rx, ry).to_path(tol);
return whole(rx, ry);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
path.extend(whole(rx, ry).path_elements(tol));
let inner_path = whole(rx * inner, ry * inner).to_path(tol);
path.extend(inner_path.reverse_subpaths());
path.extend(whole(rx, ry).elements().iter().copied());
let inner_path = whole(rx * inner, ry * inner);
path.extend(inner_path.reverse_subpaths().elements().iter().copied());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
+42 -1
View File
@@ -263,7 +263,7 @@ fn smoothed_polygon_smooths_every_corner() {
#[test]
fn ellipse_ring_area() {
let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0));
assert!((full - PI * 2500.0).abs() < 2.0);
assert!((full - PI * 2500.0).abs() < 5.0, "{full}");
let ring = ellipse(100.0, 100.0, 0.0, TAU, 0.5);
// Winding area of a ring with a reversed inner contour: outer - inner.
let a = ring.area().abs();
@@ -321,6 +321,47 @@ fn reverse_subpaths_preserves_open_and_closed() {
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
fn cubics(path: &BezPath) -> Vec<kurbo::CubicBez> {
path.segments()
.filter_map(|s| match s {
kurbo::PathSeg::Cubic(c) => Some(c),
_ => None,
})
.collect()
}
#[test]
fn a_whole_ellipse_is_four_cubics_through_the_extremes() {
for (w, h) in [(100.0, 100.0), (200.0, 60.0), (3.0, 900.0)] {
let segs = cubics(&ellipse(w, h, 0.0, TAU, 0.0));
assert_eq!(segs.len(), 4, "{w}x{h}");
let (rx, ry) = (w / 2.0, h / 2.0);
let want = [Point::new(rx, h), Point::new(0.0, ry), Point::new(rx, 0.0), Point::new(w, ry)];
for (i, s) in segs.iter().enumerate() {
assert!((s.p3 - want[i]).hypot() < 1e-9, "{w}x{h}: anchor {i} is {:?}", s.p3);
// Handle length is 0.5523 of the radius along the tangent.
let k = 0.552_284_749_830_793_4;
let (r_along, r_across) = if i % 2 == 0 { (ry, rx) } else { (rx, ry) };
let _ = r_across;
assert!(((s.p1 - s.p0).hypot() - k * r_along).abs() < 1e-9, "{w}x{h}: handle {i}");
}
}
}
#[test]
fn a_whole_ring_is_four_cubics_per_contour() {
let segs = cubics(&ellipse(100.0, 100.0, 0.0, TAU, 0.5));
assert_eq!(segs.len(), 8);
}
#[test]
fn a_sliced_ellipse_still_builds_its_arc() {
let pie = ellipse(100.0, 100.0, 0.0, PI, 0.0);
assert!(!cubics(&pie).is_empty());
assert!(matches!(pie.elements().last(), Some(kurbo::PathEl::ClosePath)));
assert!((area(&pie) - PI * 1250.0).abs() < 2.0);
}
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);