diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6..59cacbbdd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (ยง12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a65996681..201fe0e08 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,11 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +}