Merge pull request #1091 from dotCooCoo/fix/eps-arc-angles

# Conflicts:
#	contributors/people.toml
This commit is contained in:
Brandon Thomas
2026-10-13 04:29:14 -04:00
3 changed files with 100 additions and 11 deletions
+4
View File
@@ -70,3 +70,7 @@ display_name = "nanox333"
[people.Nael-Nathanael]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+33 -11
View File
@@ -343,6 +343,15 @@ fn matrix_val(m: Affine) -> V {
array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect())
}
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
/// of each after whole turns.
fn ccw_turn(a: f64, b: f64) -> f64 {
(b % 360.0 - a % 360.0).rem_euclid(360.0)
}
/// The most turns one `arc` or `arcn` draws.
const MAX_ARC_TURNS: f64 = 1024.0;
/// The deepest procedure nesting run (deeper recursion stops the program).
const MAX_CALL_DEPTH: usize = 256;
@@ -502,20 +511,33 @@ impl Interp {
}
fn arc(&mut self, neg: bool) {
let a2 = self.popn().to_radians();
let a1 = self.popn().to_radians();
let a2 = self.popn();
let a1 = self.popn();
let r = self.popn();
let c = self.user_point();
let mut sweep = a2 - a1;
if neg {
while sweep > 0.0 {
sweep -= std::f64::consts::TAU;
}
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
// until it is at most `a1` (PostScript Language Reference §8.2, `arc` and `arcn`). Those
// sweeps, and the first endpoint, are worked out from what is left of each angle after
// whole turns.
let sweep = if !neg && a2 < a1 {
ccw_turn(a1, a2)
} else if neg && a2 > a1 {
-ccw_turn(a2, a1)
} else {
while sweep < 0.0 {
sweep += std::f64::consts::TAU;
}
}
a2 - a1
};
// A sweep of more than `MAX_ARC_TURNS` turns is shortened by whole pairs of turns to
// between `MAX_ARC_TURNS - 2` and `MAX_ARC_TURNS` turns, with a skip note. The arc still
// ends at `a2`, and an even-odd fill or clip of it covers the same area as the full sweep.
let max = MAX_ARC_TURNS * 360.0;
let sweep = if sweep.abs() > max {
self.b.skip("arc (too many turns)");
let s = sweep.signum();
s * (max - 720.0 + (s * (a2 % 720.0 - a1 % 720.0)).rem_euclid(720.0))
} else {
sweep
};
let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians());
let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin());
if self.gs.cur.is_some() {
self.line_to(start);
+63
View File
@@ -296,6 +296,69 @@ fn truncated_and_corrupt_eps_never_panic() {
}
}
/// Parses `body` after the header [`eps`] adds, on a thread of its own, and fails the test when
/// parsing takes more than 20 s.
fn eps_soon(body: &str) -> Doc {
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec();
b.extend_from_slice(body.as_bytes());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(parse(&b)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("parsing {body} did not finish")).unwrap()
}
#[test]
fn arcs_with_huge_angles_end_at_their_second_angle() {
use kurbo::{Point, Rect, Shape as _};
// `arc` raises its second angle by multiples of 360 until it is at least the first, and
// `arcn` lowers it until it is at most the first (PostScript Language Reference §8.2),
// however large the angles: the arc starts at the first angle's remainder after whole turns
// and ends at the second angle, the new current point. 10^18 and 10^308 are written in
// digits: the tokenizer reads no exponents.
let (e18, e308) = (format!("1{}", "0".repeat(18)), format!("1{}", "0".repeat(308)));
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
// document pixels.
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
let near = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05);
// Each program is listed with the remainders of its first and second angles after whole
// turns, and whether its sweep is more than 1,024 turns (a skip note is recorded, and the arc
// still goes around the whole circle).
for (body, first, second, whole) in [
(format!("50 50 10 {e18} 0 arc"), 280.0, 0.0, false),
(format!("50 50 10 {e308} 0 arc"), 296.0, 0.0, false),
(format!("50 50 10 0 {e18} arcn"), 0.0, 280.0, false),
(format!("50 50 10 0 {e308} arcn"), 0.0, 296.0, false),
(format!("50 50 10 0 {e18} arc"), 0.0, 280.0, true),
(format!("50 50 10 0 {e308} arc"), 0.0, 296.0, true),
(format!("50 50 10 {e18} 0 arcn"), 280.0, 0.0, true),
(format!("50 50 10 {e308} 0 arcn"), 296.0, 0.0, true),
] {
let doc = eps_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
let skipped: &[&str] = if whole { &["arc (too many turns)"] } else { &[] };
assert_eq!(doc.skipped, skipped, "{body}");
let shapes = doc.flatten();
assert_eq!(shapes.len(), 2, "{body}");
let bounds = |i: usize| (shapes[i].0 * shapes[i].2.geom.to_path()).bounding_box();
// An arc within one quarter of the circle is spanned by its endpoints.
let (a, b) = (at(first), at(second));
let arc = if whole { Rect::new(40.0, 40.0, 60.0, 60.0) } else { Rect::from_points(a, b) };
assert!(near(bounds(0), arc), "{body}: {:?}", bounds(0));
// The second shape is the square drawn at the current point.
assert!(near(bounds(1), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(1));
}
// Sweeps of up to 1,024 turns are drawn whole: five turns counterclockwise and three
// clockwise wind around the disk twice, and a nonzero fill covers it.
let doc = eps_soon("50 50 10 0 1800 arc 50 50 10 0 -1080 arcn fill");
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
// A longer sweep keeps the parity of its turns: 5 × 10^18 degrees is an even number of whole
// turns and 320 degrees, so an even-odd fill covers the center of the disk.
let doc = eps_soon("50 50 10 0 5000000000000000000 arc eofill");
assert_eq!(doc.skipped, ["arc (too many turns)"]);
let img = render(&doc);
assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
}
#[test]
fn closing_an_empty_path_is_a_no_op() {
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).