From c48d844d2258613a55bd6fe9ef1c8254c1fcfade Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 13:11:56 -0500 Subject: [PATCH] M4: round the sharp corners of a closed path (pathops, #2222) Add figcraft_pathops::round_corners(path, radius, smoothing): every vertex of a closed subpath that is still sharp rounds with a circular arc, convex and concave alike. The sweep's noise is cleaned first (short pieces welded, straight runs merged), smooth joins are not corners, and a corner shrinks to what the edges beside it can hold (half an edge when both ends round, the whole piece beside a smooth join). Corners between straight edges use smoothed_corner; corners beside a curve cut both sides and join them with one cubic. A subpath with nothing to round comes back unchanged. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 designed; Sonnet 5.5 wrote at medium --- crates/pathops/src/lib.rs | 6 + crates/pathops/src/round.rs | 312 ++++++++++++++++++++++++++++++ crates/pathops/src/round_tests.rs | 238 +++++++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 crates/pathops/src/round.rs create mode 100644 crates/pathops/src/round_tests.rs diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index c24a22248..86f8764c4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -4,6 +4,8 @@ //! sweep-line topology from `linesweeper`); results use the even-odd rule. //! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment, //! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]). +//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path, +//! as a boolean group draws its result. //! //! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a //! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`]. @@ -24,6 +26,9 @@ mod dot_dash_tests; #[cfg(test)] mod ops_tests; mod points; +mod round; +#[cfg(test)] +mod round_tests; #[cfg(test)] mod rounded_side_strokes_tests; #[cfg(test)] @@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity}; +pub use round::round_corners; #[derive(Debug, PartialEq)] pub enum PathOpsError { diff --git a/crates/pathops/src/round.rs b/crates/pathops/src/round.rs new file mode 100644 index 000000000..40ab03759 --- /dev/null +++ b/crates/pathops/src/round.rs @@ -0,0 +1,312 @@ +//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc, +//! convex and concave alike, the way a boolean group draws its result. +//! +//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long, +//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each +//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges +//! beside it can hold ([`Ctx::corner`]). + +use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2}; +use std::f64::consts::PI; + +/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its +/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad. +const SHARP_TURN: f64 = 1e-3; +/// Accuracy of arc length measurements. +const ACC: f64 = 1e-6; + +#[derive(Clone, Copy)] +enum Seg { + Line(Point, Point), + Cubic(CubicBez), +} + +impl Seg { + fn start(&self) -> Point { + match self { + Self::Line(a, _) => *a, + Self::Cubic(c) => c.p0, + } + } + + fn end(&self) -> Point { + match self { + Self::Line(_, b) => *b, + Self::Cubic(c) => c.p3, + } + } + + fn set_start(&mut self, p: Point) { + match self { + Self::Line(a, _) => *a = p, + Self::Cubic(c) => c.p0 = p, + } + } + + fn is_line(&self) -> bool { + matches!(self, Self::Line(..)) + } + + /// As a cubic (a line gets its handles at the thirds). + fn cubic(&self) -> CubicBez { + match self { + Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b), + Self::Cubic(c) => *c, + } + } + + fn len(&self) -> f64 { + match self { + Self::Line(a, b) => (*b - *a).hypot(), + Self::Cubic(c) => c.arclen(ACC), + } + } + + /// The length of the control polygon: cheap, and zero only for a point. + fn span(&self) -> f64 { + let c = self.cubic(); + (c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot() + } + + /// The cubic leaving the start of the segment, or (`from_start` false) leaving its end. + fn outward(&self, from_start: bool) -> CubicBez { + let c = self.cubic(); + if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } + } +} + +/// Unit direction in which `c` leaves its start (skipping coincident handles). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The point `t` along `c` from its start, the unit direction back toward the start there, and the +/// parameter reached. +fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> { + if line { + if !len.is_finite() || len <= 0.0 { + return None; + } + let u = (c.p3 - c.p0) / len; + return Some((c.p0 + u * t, -u, t / len)); + } + let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0); + let d = c.deriv().eval(k).to_vec2(); + let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? }; + Some((c.eval(k), back, k)) +} + +/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing +/// one, and where those cuts sit on their segments (each segment's own parameter). +struct Corner { + curves: Vec, + t_in: f64, + t_out: f64, +} + +/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between +/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than +/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A +/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates +/// come back as they are. +pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath { + if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) { + return path.clone(); + } + let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + let bb = path.bounding_box(); + let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9); + let mut out = BezPath::new(); + for sub in figcraft_geom::split_subpaths(path) { + let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath)); + match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() { + Some(p) => out.extend(p.elements().iter().copied()), + None => out.extend(sub.elements().iter().copied()), + } + } + out +} + +/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped, +/// each segment starting where the one before ends, and straight runs merged into one line. +fn clean(sub: &BezPath, weld: f64) -> Vec { + let mut segs: Vec = sub + .segments() + .map(|s| match s { + PathSeg::Line(l) => Seg::Line(l.p0, l.p1), + other => Seg::Cubic(other.to_cubic()), + }) + .filter(|s| s.span() >= weld) + .collect(); + let n = segs.len(); + for i in 0..n { + let prev_end = segs.get((i + n - 1) % n).map(Seg::end); + if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) { + s.set_start(p); + } + } + merge_runs(segs, weld) +} + +/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C +/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic. +fn merge_runs(segs: Vec, weld: f64) -> Vec { + let mergeable = |a: &Seg, b: &Seg| match (a, b) { + (Seg::Line(p, q), Seg::Line(_, r)) => { + let (d, e) = (*r - *p, *q - *p); + let l = d.hypot(); + l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0 + } + _ => false, + }; + let mut merged: Vec = Vec::with_capacity(segs.len()); + for s in segs { + match merged.last_mut() { + Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()), + _ => merged.push(s), + } + } + while merged.len() > 2 { + let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break }; + if !mergeable(&last, &first) { + break; + } + if let Some(f) = merged.first_mut() { + *f = Seg::Line(last.start(), first.end()); + } + merged.pop(); + } + merged +} + +/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and +/// segment `i` starts. +struct Ctx<'a> { + segs: &'a [Seg], + /// Segment lengths, measured only for segments with a corner at one end. + lens: &'a [Option], + /// Per vertex: the turn between its segments and the length it wants along each of them. + wants: &'a [Option<(f64, f64)>], + radius: f64, + smoothing: f64, + weld: f64, +} + +/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the +/// length a corner there wants along each; `None` where the vertex is not a corner. +fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec> { + let n = segs.len(); + (0..n) + .map(|i| { + let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?); + let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?); + let angle = u1.dot(u2).clamp(-1.0, 1.0).acos(); + if angle < 1e-6 || PI - angle < SHARP_TURN { + return None; + } + let t = radius / (angle / 2.0).tan(); + let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t }; + want.is_finite().then_some((angle, want)) + }) + .collect() +} + +impl Ctx<'_> { + fn want(&self, i: usize) -> f64 { + self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w) + } + + /// What a corner may use of a segment of `len`: half of it, or what the corner at its other + /// end leaves, whichever is more. + fn room(len: f64, other: f64) -> f64 { + (len / 2.0).max(len - other) + } + + fn corner(&self, i: usize) -> Option { + let n = self.segs.len(); + let (angle, _) = self.wants.get(i).copied().flatten()?; + let prev = (i + n - 1) % n; + let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?); + let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?); + let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1))); + if !room.is_finite() || room <= self.weld { + return None; + } + let (c1, c2) = (a.outward(false), b.outward(true)); + if a.is_line() && b.is_line() { + self.between_lines(&c1, &c2, room, (la, lb)) + } else { + self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle) + } + } + + fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option { + let (u1, u2) = (start_dir(c1)?, start_dir(c2)?); + let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?; + let (first, last) = (curves.first()?, curves.last()?); + let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb); + (t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()))) + .then_some(Corner { curves, t_in, t_out }) + } + + /// One cubic between the cuts on both sides, its handles along each side's direction at its cut. + fn beside_curve( + &self, + (c1, line1, la): (&CubicBez, bool, f64), + (c2, line2, lb): (&CubicBez, bool, f64), + room: f64, + angle: f64, + ) -> Option { + let t = (self.radius / (angle / 2.0).tan()).min(room); + let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?); + let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos(); + let chord = (q - p).hypot(); + let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) }; + let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite()); + ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 }) + } +} + +/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds +/// (the caller keeps the subpath exactly as it came in). +fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option { + let segs = clean(sub, weld); + let n = segs.len(); + if n < 2 { + return None; + } + let wants = wants(&segs, radius, smoothing); + let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some); + let lens: Vec> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect(); + let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld }; + let corners: Vec> = (0..n).map(|i| ctx.corner(i)).collect(); + corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))? +} + +/// Walk the segments: what is left of each between the corner at its start and the one at its +/// end, then the next corner's curves. +fn emit(segs: &[Seg], corners: &[Option]) -> Option { + let n = segs.len(); + let mut out = BezPath::new(); + let first = segs.first()?; + out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3)); + for (i, seg) in segs.iter().enumerate() { + let next = corners.get((i + 1) % n).and_then(Option::as_ref); + let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out); + let t1 = next.map_or(1.0, |c| c.t_in); + if t1 - t0 > 1e-9 { + match seg { + Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)), + Seg::Cubic(c) => { + let q = c.subsegment(t0..t1); + out.curve_to(q.p1, q.p2, q.p3); + } + } + } + for c in next.iter().flat_map(|c| c.curves.iter()) { + out.curve_to(c.p1, c.p2, c.p3); + } + } + out.close_path(); + crate::finite(&out).then_some(out) +} diff --git a/crates/pathops/src/round_tests.rs b/crates/pathops/src/round_tests.rs new file mode 100644 index 000000000..eda74b464 --- /dev/null +++ b/crates/pathops/src/round_tests.rs @@ -0,0 +1,238 @@ +//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular +//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the +//! edges allow shrinks to fit. + +use figcraft_doc::BoolOp; +use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2}; +use figcraft_geom::{ellipse, rounded_rect, split_subpaths}; + +use crate::{boolean, boolean_step, round_corners}; + +fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { + kurbo_rect(x, y, w, h).to_path(0.1) +} + +fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect { + figcraft_geom::Rect::new(x, y, x + w, y + h) +} + +fn l_shape() -> BezPath { + boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap() +} + +/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement. +fn notch() -> BezPath { + let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0); + boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap() +} + +/// Every `CurveTo` as `[from, c1, c2, to]`. +fn arcs(path: &BezPath) -> Vec<[Point; 4]> { + let mut cur = Point::ZERO; + let mut out = Vec::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p, + PathEl::CurveTo(a, b, c) => { + out.push([cur, a, b, c]); + cur = c; + } + _ => {} + } + } + out +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-3 +} + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +/// Some curve runs through exactly these four points, in either direction. +fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool { + let w = want.map(|(x, y)| pt(x, y)); + let rev = [w[3], w[2], w[1], w[0]]; + arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q))) +} + +/// Some curve joins these two end points, in either direction. +fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool { + let (f, t) = (pt(from.0, from.1), pt(to.0, to.1)); + arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f))) +} + +fn passes_through(path: &BezPath, p: Point) -> bool { + path.elements().iter().any(|el| match el { + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p), + _ => false, + }) +} + +fn unit(v: Vec2) -> Option { + (v.hypot() > 1e-9).then(|| v / v.hypot()) +} + +fn start_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0)) +} + +fn end_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p)) +} + +/// The largest direction change at any vertex of a closed subpath, in radians. +fn sharpest_turn(path: &BezPath) -> f64 { + let mut worst: f64 = 0.0; + for sub in split_subpaths(path) { + let segs: Vec = sub.segments().collect(); + for (i, s) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) { + worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos()); + } + } + } + worst +} + +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn all_finite(p: &BezPath) -> bool { + crate::finite(p) +} + +#[test] +fn every_sharp_corner_of_the_result_rounds_convex_and_concave() { + let r = round_corners(&l_shape(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 6); + assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)])); + assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)])); + assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r)); + assert_eq!(r.winding(pt(1.0, 1.0)), 0); + assert_ne!(r.winding(pt(148.0, 102.0)), 0); + let bb = r.bounding_box(); + assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6); +} + +#[test] +fn a_childs_rounded_corners_are_kept_and_do_not_stack() { + let r = round_corners(¬ch(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 8); + assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)])); + assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)])); + assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0))); + assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); +} + +#[test] +fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() { + let r = round_corners(¬ch(), 500.0, 0.0); + assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)])); + assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)])); + assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0))); + assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); + assert!(all_finite(&r)); + let l = round_corners(&l_shape(), 500.0, 0.0); + assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0))); + assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0))); + assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6); +} + +#[test] +fn nothing_sharp_nothing_changes() { + let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0); + let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0); + assert_eq!(round_corners(&round, 20.0, 0.0), round); + assert_eq!(round_corners(&oval, 20.0, 0.0), oval); + let l = l_shape(); + for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(round_corners(&l, radius, 0.0), l); + } + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert_eq!(round_corners(&open, 10.0, 0.0), open); +} + +#[test] +fn points_the_sweep_leaves_on_an_edge_are_not_corners() { + let mut p = BezPath::new(); + for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)] + .into_iter() + .enumerate() + { + if i == 0 { + p.move_to(q); + } else { + p.line_to(q); + } + } + p.close_path(); + let r = round_corners(&p, 500.0, 0.0); + assert_eq!(arcs(&r).len(), 4); + assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0))); +} + +fn two_circles(op: BoolOp) -> BezPath { + let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0); + boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap() +} + +#[test] +fn corners_beside_curves_round_too() { + for op in [BoolOp::Union, BoolOp::Intersect] { + let sharp = two_circles(op); + assert!(sharpest_turn(&sharp) > 1.0); + let r = round_corners(&sharp, 10.0, 0.0); + assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r)); + assert!(all_finite(&r)); + match op { + BoolOp::Union => assert!(area(&r) > area(&sharp)), + _ => assert!(area(&r) < area(&sharp)), + } + } +} + +#[test] +fn smoothing_draws_the_polygon_profile_between_straight_edges() { + let l = l_shape(); + let plain = round_corners(&l, 20.0, 0.0); + let smooth = round_corners(&l, 20.0, 0.6); + assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0))); + assert!((area(&smooth) - area(&plain)).abs() > 1.0); + let circles = two_circles(BoolOp::Union); + assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0)); +} + +#[test] +fn junk_never_panics() { + let mut bad = BezPath::new(); + bad.move_to((f64::NAN, 0.0)); + bad.line_to((10.0, f64::INFINITY)); + bad.close_path(); + assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3); + assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty()); + let mut one = BezPath::new(); + one.move_to((0.0, 0.0)); + one.line_to((10.0, 0.0)); + one.close_path(); + assert!(all_finite(&round_corners(&one, 5.0, 0.5))); + assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0))); + let mut zig = BezPath::new(); + zig.move_to((0.0, 0.0)); + for i in 1..40_000 { + zig.line_to((f64::from(i) * 0.5, f64::from(i % 2))); + } + zig.close_path(); + assert!(all_finite(&round_corners(&zig, 2.0, 0.0))); +}