diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs index 0b679497ce..888294356c 100644 --- a/crates/brush/src/calli.rs +++ b/crates/brush/src/calli.rs @@ -78,7 +78,11 @@ fn samples(t: &Track, step: Option) -> Vec<(Point, Vec2, f64)> { let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize; for k in 1..pieces { let u = k as f64 / pieces as f64; - out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len)); + let s = t.cum[i] + seg * u; + // On a chord of a flattened curve the samples follow the smooth frame through the + // vertices. + let (q, tg) = if t.curved.get(i).copied().unwrap_or(false) { t.frame_in(i, u) } else { (p.lerp(t.verts[i + 1], u), t.dirs[i]) }; + out.push((q, tg, s / len)); } } } diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 2d5156d162..2d62a1a993 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1037,6 +1037,26 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer { StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) } } +/// A nib that follows the pen is sampled between the vertices of the flattened path too. Along a +/// curve those samples sit on the curve, and the outline keeps its distance from it. +#[test] +fn pen_driven_nib_outline_follows_the_curve_between_vertices() { + // The top half of a circle of radius 50; a round nib 20 pt across, the same at any pressure. + let (r, half) = (50.0, 10.0); + let c = Point::new(r, r); + let ring = shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r)).to_bezpath(); + let half_circle: BezPath = ring.elements().iter().take(3).copied().collect(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(20.0, 0.0), &half_circle, &pressed(vec![(0.0, 0.5), (1.0, 0.5)])); + let mut pts = vec![]; + for n in &out { + all_points(n, &mut pts); + } + // Away from the round caps at the two ends, every outline point is `half` from the curve. + let middle = pts.iter().filter(|p| ((p.y - c.y).atan2(p.x - c.x).abs() - std::f64::consts::FRAC_PI_2).abs() <= 1.2); + let worst = middle.map(|p| ((p.distance(c) - (r + half)).abs()).min((p.distance(c) - (r - half)).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "an outline point strays {worst:.4} pt from the nib's reach"); +} + /// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to /// size + variation at the heaviest, along the stroke where the pen pressed. #[test] @@ -1686,6 +1706,35 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() { assert!(anchors(&out) < 2_000_000); } +/// A straight-sided art brush on a smooth curve draws smooth edges: they do not turn at the smooth +/// anchors the fit puts along them. +#[test] +fn art_brush_edges_stay_smooth_along_a_curve() { + for &(r, w, h, sw) in &[(100.0, 100.0, 10.0, 1.0), (300.0, 100.0, 10.0, 2.0), (30.0, 100.0, 10.0, 1.0), (2000.0, 100.0, 10.0, 1.0)] { + // The top half of a circle, as one open path of two curves. + let ring = shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r)).to_bezpath(); + let half_circle: BezPath = ring.elements().iter().take(3).copied().collect(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(w, h), ArtScale::Stretch), &half_circle, &stroke(sw)); + let NodeKind::Path { path, .. } = &out[0].kind else { panic!("an art brush on a path draws a path") }; + + // The turn at each smooth anchor of the edges: the angle between the incoming and the + // outgoing direction. Real corners (the ends of the art) turn by far more than 60°. + let mut kink = 0.0f64; + for sp in &path.subpaths { + for (k, a) in sp.anchors.iter().enumerate().skip(1).take(sp.anchors.len().saturating_sub(2)) { + let h_in = if a.h_in != a.p { a.h_in } else { sp.anchors[k - 1].p }; + let h_out = if a.h_out != a.p { a.h_out } else { sp.anchors[k + 1].p }; + let (u, v) = (a.p - h_in, h_out - a.p); + if u.hypot() < 1e-9 || v.hypot() < 1e-9 || u.dot(v) < 0.5 * u.hypot() * v.hypot() { + continue; + } + kink = kink.max(u.cross(v).atan2(u.dot(v)).abs().to_degrees()); + } + } + assert!(kink < 0.5, "r={r} art={w}x{h} weight {sw}: an edge turns by {kink:.3}° at a smooth anchor"); + } +} + /// Anchors under `n`, and how many of them keep a handle (so the segment is a curve). fn anchors_and_curved(n: &Node) -> (usize, usize) { let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) }; diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 6c65246176..3b5fb1add3 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -1,8 +1,9 @@ //! Arc-length parameterised polylines ("tracks") that brushes lay art along. //! //! A track is one flattened subpath. `frame(s)` gives the point and unit tangent at arc length -//! `s`; tangents are blended across the gentle vertices of flattened curves (so warped art bends -//! smoothly) but stay discontinuous at real corners (so art folds there, like Illustrator). +//! `s`. Along a flattened curve the frame runs on a smooth curve through the vertices, so warped +//! art bends smoothly; along a line of the path it stays on the line. At real corners the tangent +//! stays discontinuous, so art folds there. use vectorcraft_geom::{BezPath, PathEl, Point, Vec2}; @@ -22,6 +23,11 @@ pub struct Track { pub tangents: Vec, /// Is the vertex a corner (turn > [`CORNER_DEG`])? Open ends are never corners. pub corner: Vec, + /// Does the segment come from flattening a curve (rather than from a line of the path)? + pub curved: Vec, + /// How far (radians) a curved segment's end tangents may turn from its direction for `frame` + /// to follow a curve through them; 0 for a line. + turn: Vec, } fn unit(v: Vec2) -> Vec2 { @@ -35,16 +41,53 @@ fn aligned(a: Vec2, b: Vec2) -> bool { a.dot(b) > 1.0 - 1e-12 } +/// How far a vertex tangent may turn from a chord of a flattened curve, as a multiple of the +/// flattening tolerance over the chord's length. A circular arc needs 4; the rest is slack for +/// flattening that lands a little inside its tolerance. +const TURN_PER_TOL: f64 = 6.0; + +/// `t` turned by the smallest angle that brings it within `limit` radians of each `dir`. When no +/// angle satisfies every limit, `t` turns to the middle of the gap between them. +fn held_within(t: Vec2, limits: [Option<(Vec2, f64)>; 2]) -> Vec2 { + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for (d, limit) in limits.into_iter().flatten() { + let to = t.cross(d).atan2(t.dot(d)); + lo = lo.max(to - limit); + hi = hi.min(to + limit); + } + let turn = if lo <= hi { 0f64.max(lo).min(hi) } else { (lo + hi) / 2.0 }; + if turn == 0.0 || !turn.is_finite() { + return t; + } + let (sin, cos) = turn.sin_cos(); + Vec2::new(t.x * cos - t.y * sin, t.x * sin + t.y * cos) +} + /// Left-hand normal in y-down document space (for a path running +x it points +y). pub fn normal(t: Vec2) -> Vec2 { Vec2::new(-t.y, t.x) } impl Track { - fn new(mut pts: Vec, closed: bool) -> Option { - pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + /// `raw` pairs each vertex with the tolerance the segment ending at it was flattened with + /// when it comes from a curve, and `None` when it is a line of the path. + fn new(raw: Vec<(Point, Option)>, closed: bool) -> Option { + let mut pts: Vec = Vec::with_capacity(raw.len()); + // `ends_curve[k]`: the flattening tolerance of the segment ending at vertex `k`, when it + // comes from a curve. + let mut ends_curve: Vec> = Vec::with_capacity(raw.len()); + for (p, c) in raw { + // A duplicate vertex adds no segment, so it is dropped with its tolerance. + if pts.last().is_some_and(|q| q.distance(p) < 1e-9) { + continue; + } + pts.push(p); + ends_curve.push(c); + } + let mut closing_curve = None; if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) < 1e-9 { pts.pop(); + closing_curve = ends_curve.pop().flatten(); } if pts.len() < 2 { return None; @@ -52,8 +95,11 @@ impl Track { let closed = closed && pts.len() > 2; if closed { pts.push(pts[0]); + ends_curve.push(closing_curve); } let nseg = pts.len() - 1; + let curve_tol: Vec> = (0..nseg).map(|i| ends_curve.get(i + 1).copied().flatten()).collect(); + let curved: Vec = curve_tol.iter().map(Option::is_some).collect(); let dirs: Vec = (0..nseg).map(|i| unit(pts[i + 1] - pts[i])).collect(); let mut cum = vec![0.0]; for i in 0..nseg { @@ -62,34 +108,78 @@ impl Track { let mut tangents = Vec::with_capacity(nseg + 1); let mut corner = Vec::with_capacity(nseg + 1); let cos_limit = CORNER_DEG.to_radians().cos(); + let seg = |i: usize| (dirs[i], cum[i + 1] - cum[i], curved[i]); for i in 0..=nseg { let incoming = if i > 0 { - Some(dirs[i - 1]) + Some(seg(i - 1)) } else if closed { - Some(dirs[nseg - 1]) + Some(seg(nseg - 1)) } else { None }; let outgoing = if i < nseg { - Some(dirs[i]) + Some(seg(i)) } else if closed { - Some(dirs[0]) + Some(seg(0)) } else { None }; match (incoming, outgoing) { - (Some(a), Some(b)) => { + (Some((a, la, ca)), Some((b, lb, cb))) => { corner.push(a.dot(b) < cos_limit); - tangents.push(unit(a + b)); + tangents.push(match (ca, cb) { + // Between two chords of a flattened curve: each direction weighted by the + // length of the other chord, which is the tangent of the circle through + // the vertex and its two neighbors. + (true, true) => unit(a * lb + b * la), + // Where a line of the path meets a curve, the line's direction. + (false, true) => a, + (true, false) => b, + (false, false) => unit(a + b), + }); } (a, b) => { // An end of an open track (both `None` only for a track without segments). corner.push(false); - tangents.push(a.or(b).unwrap_or(Vec2::new(1.0, 0.0))); + tangents.push(a.or(b).map_or(Vec2::new(1.0, 0.0), |(d, _, _)| d)); } } } - Some(Track { verts: pts, cum, closed, dirs, tangents, corner }) + // An open track's end on a curve continues the bend of its last chord: the tangent there + // is the neighboring vertex's tangent mirrored about the chord, as on the circle through + // the last three vertices. + if !closed && nseg >= 2 { + for (end, inner, d) in [(0, 1, dirs[0]), (nseg, nseg - 1, dirs[nseg - 1])] { + let (s_end, s_inner) = if end == 0 { (0, 1) } else { (nseg - 1, nseg - 2) }; + if !corner[inner] && curved[s_end] && curved[s_inner] { + let t = tangents[inner]; + tangents[end] = unit(d * (2.0 * d.dot(t)) - t); + } + } + } + // A curve lies within its flattening tolerance `tol` of each of its chords, so its tangent + // at a vertex turns only a little from a chord: at most about `4 * tol / len` on a + // circular arc. Next to a long chord the estimates above can turn much further (a + // straight curve element beside a tight bend), and `frame` would then bow off the chord. + // Between curved chords, each vertex tangent is held within `TURN_PER_TOL * tol / len` of + // both chords. Where a line meets a curve the tangent stays the line's direction, and a + // curved chord whose end tangents turn further than that from it keeps to the chord. + let turn: Vec = (0..nseg) + .map(|j| match curve_tol.get(j) { + Some(Some(tol)) => (TURN_PER_TOL * tol / (cum[j + 1] - cum[j]).max(1e-12)).min(1.0).asin(), + _ => 0.0, + }) + .collect(); + for (i, t) in tangents.iter_mut().enumerate() { + let incoming = if i > 0 { Some(i - 1) } else { closed.then_some(nseg - 1) }; + let outgoing = if i < nseg { Some(i) } else { closed.then_some(0) }; + let sides = [incoming, outgoing]; + if corner[i] || sides.iter().flatten().any(|&j| !curved[j]) { + continue; + } + *t = held_within(*t, sides.map(|j| j.map(|j| (dirs[j], turn[j])))); + } + Some(Track { verts: pts, cum, closed, dirs, tangents, corner, curved, turn }) } pub fn len(&self) -> f64 { @@ -135,11 +225,11 @@ impl Track { let n = self.segments(); let s = if self.closed && l > 0.0 { s.rem_euclid(l) } else { s }; if s <= 0.0 && !self.closed { - let t = self.dirs[0]; + let t = self.tangents[0]; return (self.verts[0] + t * s, t); } if s >= l && !self.closed { - let t = self.dirs[n - 1]; + let t = self.tangents[n]; return (self.verts[n] + t * (s - l), t); } // Binary search the segment. @@ -149,10 +239,44 @@ impl Track { }; let seg = self.cum[i + 1] - self.cum[i]; let u = if seg > 1e-12 { ((s - self.cum[i]) / seg).clamp(0.0, 1.0) } else { 0.0 }; - let p = self.verts[i].lerp(self.verts[i + 1], u); - let t0 = if self.corner[i] { self.dirs[i] } else { self.tangents[i] }; - let t1 = if self.corner[i + 1] { self.dirs[i] } else { self.tangents[i + 1] }; - (p, unit(t0 + (t1 - t0) * u)) + self.frame_in(i, u) + } + + /// Segment `i`'s direction and the tangents [`Self::frame`] uses at its two ends: the vertex + /// tangents, or the segment's direction at a corner. + fn end_tangents(&self, i: usize) -> Option<(Vec2, Vec2, Vec2)> { + let (&d, &c0, &c1, &t0, &t1) = + (self.dirs.get(i)?, self.corner.get(i)?, self.corner.get(i + 1)?, self.tangents.get(i)?, self.tangents.get(i + 1)?); + Some((d, if c0 { d } else { t0 }, if c1 { d } else { t1 })) + } + + /// Point and unit tangent at the fraction `u` (0 to 1) of segment `i`, as [`Self::frame`] + /// gives them. `i` must be below [`Self::segments`]. + pub fn frame_in(&self, i: usize, u: f64) -> (Point, Vec2) { + let (Some(&p0), Some(&p1), Some((d, t0, t1))) = (self.verts.get(i), self.verts.get(i + 1), self.end_tangents(i)) else { + return (self.verts.last().copied().unwrap_or(Point::ZERO), Vec2::new(1.0, 0.0)); + }; + let seg = self.cum.get(i + 1).zip(self.cum.get(i)).map_or(0.0, |(b, a)| b - a); + if aligned(t0, d) && aligned(t1, d) { + return (p0.lerp(p1, u), d); + } + // The tangents may turn this far from a curved chord, a little beyond it for rounding. + let within = self.turn.get(i).map_or(-1.0, |a| a.sin() + 1e-9); + let bends = |t: Vec2| t.dot(d) <= 0.0 || t.cross(d).abs() > within; + if !self.curved.get(i).copied().unwrap_or(false) || bends(t0) || bends(t1) { + // A line of the path, or a curved chord at a sharper turn than the curve can make + // there: the point stays on it, and the tangent turns across it toward the vertex + // tangents. + return (p0.lerp(p1, u), unit(t0 + (t1 - t0) * u)); + } + // On a chord of a flattened curve the frame follows the cubic through both vertices with + // their tangents, at the chord's length per unit of `u`. Its position and tangent then + // meet those of the next segment at each vertex. + let (u2, u3) = (u * u, u * u * u); + let (m0, m1) = (t0 * seg, t1 * seg); + let p = p0 + (p1 - p0) * (3.0 * u2 - 2.0 * u3) + m0 * (u3 - 2.0 * u2 + u) + m1 * (u3 - u2); + let dp = (p1 - p0) * (6.0 * u - 6.0 * u2) + m0 * (3.0 * u2 - 4.0 * u + 1.0) + m1 * (3.0 * u2 - 2.0 * u); + (p, unit(dp)) } /// The point at arc length `s`, offset `d` along the left normal. @@ -183,8 +307,8 @@ impl Track { if self.closed && (lo < -1e-9 || hi > l + 1e-9) { return false; } - // Past an open track's ends `frame` extrapolates along the end segment's direction, so - // those segments are part of the span too. + // Past an open track's ends `frame` extrapolates along the end tangent, which is checked + // against the end segment's direction below, so those segments are part of the span too. let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)]; let touched = (0..n).filter(|&i| { let (a, b) = (self.cum.get(i), self.cum.get(i + 1)); @@ -193,9 +317,7 @@ impl Track { let mut dir: Option = None; let mut any = false; for i in touched.chain(ends.into_iter().flatten()) { - let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) = - (self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1)) - else { + let Some((d, t0, t1)) = self.end_tangents(i) else { return false; }; if dir.is_some_and(|first: Vec2| !aligned(first, d)) { @@ -203,11 +325,11 @@ impl Track { } dir = Some(d); any = true; - // `frame` blends the two vertex tangents across the segment unless the vertex is a - // corner, where it uses the segment's own direction. So a neighbour outside the span - // can still bend the tangent inside it, and checking the directions alone would miss - // that: segments d, d, e leave vertex 2's tangent at unit(d + e). - if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) { + // `frame` blends the two end tangents across a line, and follows a curve through them + // on a curved chord. So a neighbour outside the span can still bend the tangent + // inside it, and checking the directions alone would miss that: segments d, d, e + // leave vertex 2's tangent at unit(d + e). + if !aligned(t0, d) || !aligned(t1, d) { return false; } } @@ -220,9 +342,10 @@ const MAX_FLATTENED: usize = 4_000_000; /// Flatten `bp` into tracks (one per subpath) with `tol` accuracy. pub fn tracks(bp: &BezPath, tol: f64) -> Vec { + let tol = tol.max(1e-4); let mut out = vec![]; - let mut cur: Vec = vec![]; - let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { + let mut cur: Vec<(Point, Option)> = vec![]; + let flush = |cur: &mut Vec<(Point, Option)>, closed: bool, out: &mut Vec| { if let Some(t) = Track::new(std::mem::take(cur), closed) { out.push(t); } @@ -236,19 +359,40 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { return out; } let mut els = vec![]; + // When the element being flattened is a curve, the tolerance `flatten` uses for it: `flatten` + // pulls each element before it reports that element's lines, and raises the tolerance of a + // long curve as `vectorcraft_geom::flatten` describes. + let in_curve = std::cell::Cell::new(None); + let (last, start) = (std::cell::Cell::new(Point::ZERO), std::cell::Cell::new(Point::ZERO)); + let source = bp.iter().inspect(|el| { + let from = last.get(); + let (curve, to) = match *el { + PathEl::MoveTo(p) => { + start.set(p); + (None, p) + } + PathEl::LineTo(p) => (None, p), + PathEl::QuadTo(a, p) => (Some(from.distance(a) + a.distance(p)), p), + PathEl::CurveTo(a, b, p) => (Some(from.distance(a) + a.distance(b) + b.distance(p)), p), + PathEl::ClosePath => (None, start.get()), + }; + last.set(to); + let per = vectorcraft_geom::MAX_FLATTEN_PER_CURVE * vectorcraft_geom::MAX_FLATTEN_PER_CURVE; + in_curve.set(curve.map(|length| if length.is_finite() { tol.max(length / per) } else { f64::INFINITY })); + }); // Guarded: kurbo works out every subdivision of a curve even when the callback drops them. - vectorcraft_geom::flatten(bp.iter(), tol.max(1e-4), |el| { + vectorcraft_geom::flatten(source, tol, |el| { if els.len() < MAX_FLATTENED { - els.push(el); + els.push((el, in_curve.get())); } }); - for el in els { + for (el, from_curve) in els { match el { PathEl::MoveTo(p) => { flush(&mut cur, false, &mut out); - cur.push(p); + cur.push((p, None)); } - PathEl::LineTo(p) => cur.push(p), + PathEl::LineTo(p) => cur.push((p, from_curve)), PathEl::ClosePath => flush(&mut cur, true, &mut out), _ => {} } @@ -368,6 +512,173 @@ mod tests { assert_eq!(t.map(110.0, 0.0), Point::new(110.0, 0.0)); } + /// Between two lines that meet at a gentle turn, the frame stays on the lines: only the chords + /// of a flattened curve are smoothed. Where a line meets a curve at a gentle turn, the frame + /// stays on the line with the line's direction, and its tangent does not jump at the join. + #[test] + fn a_polylines_frame_stays_on_its_lines() { + // A line, then a line or a nearly straight curve at a 20° turn, below the corner angle, so + // the vertex is not a corner. + let turn = 20f64.to_radians(); + let (c, s) = (100.0 * turn.cos(), 100.0 * turn.sin()); + for curve in [false, true] { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + if curve { + bp.curve_to((100.0 + c / 3.0, s / 3.0 + 0.5), (100.0 + 2.0 * c / 3.0, 2.0 * s / 3.0 + 0.5), (100.0 + c, s)); + } else { + bp.line_to((100.0 + c, s)); + } + let t = &tracks(&bp, 0.05)[0]; + for at in [10.0, 30.0, 50.0, 70.0, 90.0] { + let (p, tg) = t.frame(at); + assert!(p.y.abs() < 1e-9 && (p.x - at).abs() < 1e-9, "curve {curve}: at {at} the frame left the first line: {p:?}"); + if curve { + assert!(tg.y.abs() < 1e-12, "at {at} the line's tangent turned toward the curve: {tg:?}"); + } + } + if curve { + // The curve's first chord keeps to the chord rather than bending toward the line. + let (a, b) = (t.cum[1], t.cum[2]); + let (p, q) = (t.verts[1], t.verts[2]); + let mid = t.frame((a + b) / 2.0).0; + assert!((mid - p.midpoint(q)).hypot() < 1e-9, "the first curve chord bows: {mid:?}"); + } + let (before, after) = (t.frame(100.0 - 1e-7).1, t.frame(100.0 + 1e-7).1); + assert!(before.dot(after) > 1.0 - 1e-9, "curve {curve}: the tangent jumps at the join: {before:?} {after:?}"); + } + } + + /// A curve of no length between two lines leaves them lines. + #[test] + fn a_curve_of_no_length_leaves_a_line_a_line() { + let turn = 20f64.to_radians(); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + bp.curve_to((100.0, 0.0), (100.0, 0.0), (100.0, 0.0)); + bp.line_to((100.0 + 100.0 * turn.cos(), 100.0 * turn.sin())); + let t = &tracks(&bp, 0.05)[0]; + assert!(t.curved.iter().all(|c| !c), "no segment is a curve: {:?}", t.curved); + for at in [10.0, 50.0, 90.0] { + let (p, _) = t.frame(at); + assert!(p.y.abs() < 1e-9 && (p.x - at).abs() < 1e-9, "at {at} the frame left the first line: {p:?}"); + } + } + + /// Where a line joins a curve smoothly, the line keeps its direction up to the join. + #[test] + fn a_line_into_a_smooth_curve_keeps_its_direction() { + // A line along y = 0 into a quarter circle of radius 50 that starts along +x. + let r = 50.0; + let mut bp = BezPath::new(); + bp.move_to((-100.0, 0.0)); + bp.line_to((0.0, 0.0)); + bp.curve_to((r * K, 0.0), (r, r - r * K), (r, r)); + let t = &tracks(&bp, 0.05)[0]; + for s in [10.0, 50.0, 90.0, 99.0] { + let (p, tg) = t.frame(s); + assert!(p.y.abs() < 1e-9 && tg.y.abs() < 1e-9, "at {s} the line's frame turned: {p:?} {tg:?}"); + } + } + + /// The handle length of a cubic quarter circle. + const K: f64 = 0.552_284_749_8; + + /// A curve element whose handles lie on its chord is a line drawn as a curve. Next to a tight + /// bend, at either end of a track, the frame along it stays within the flattening tolerance + /// of the line. + #[test] + fn a_straight_curve_element_beside_a_tight_bend_stays_on_its_line() { + let tol = 0.05; + for bend_first in [false, true] { + // A 200 pt line along y = 0 drawn as a cubic, joined smoothly to a quarter circle of + // radius 5 that turns toward +y. + let mut bp = BezPath::new(); + if bend_first { + bp.move_to((-5.0, -5.0)); + bp.curve_to((-5.0, -5.0 + 5.0 * K), (-5.0 * K, 0.0), (0.0, 0.0)); + bp.curve_to((50.0, 0.0), (150.0, 0.0), (200.0, 0.0)); + } else { + bp.move_to((0.0, 0.0)); + bp.curve_to((50.0, 0.0), (150.0, 0.0), (200.0, 0.0)); + bp.curve_to((200.0 + 5.0 * K, 0.0), (205.0, 5.0 - 5.0 * K), (205.0, 5.0)); + } + let t = &tracks(&bp, tol)[0]; + // The bend lies outside 0 <= x <= 200, so every frame point in that range is on the + // line's stretch of the track. + let on_line: Vec<(Point, Vec2)> = + (0..=2000).map(|k| t.frame(t.len() * k as f64 / 2000.0)).filter(|(p, _)| (0.0..=200.0).contains(&p.x)).collect(); + let worst = on_line.iter().map(|(p, _)| p.y.abs()).fold(0.0, f64::max); + assert!(worst <= 2.0 * tol, "bend first {bend_first}: the frame strays {worst:.4} pt off the line"); + // Along the line the tangent turns by no more than the line's flattening allows. + let lean = on_line.iter().map(|(_, tg)| tg.y.abs()).fold(0.0, f64::max); + assert!(lean <= 0.01, "bend first {bend_first}: the tangent turns {lean:.4} off the line"); + } + } + + /// A curve long enough that flattening raises its tolerance still has its frame follow the + /// curve between the vertices, not the chords. + #[test] + fn a_long_curve_flattened_coarser_is_still_followed() { + // A line along y = 0 into a cubic quarter circle of radius 5,000,000 that starts along +x. + let r = 5.0e6; + let curve = kurbo::CubicBez::new((0.0, 0.0), (r * K, 0.0), (r, r - r * K), (r, r)); + let mut bp = BezPath::new(); + bp.move_to((-100.0, 0.0)); + bp.line_to((0.0, 0.0)); + bp.curve_to(curve.p1, curve.p2, curve.p3); + let t = &tracks(&bp, 0.05)[0]; + let worst = (1..400) + .map(|k| t.frame(100.0 + (t.len() - 100.0) * k as f64 / 400.0).0) + .map(|p| kurbo::ParamCurveNearest::nearest(&curve, p, 1e-9).distance_sq.sqrt()) + .fold(0.0, f64::max); + assert!(worst < 0.05, "the frame strays {worst:.4} pt from the curve"); + } + + /// A closed curve is smooth across its seam: the frame stays on the circle and at right angles + /// to its radius all the way round, the start included. + #[test] + fn a_closed_curves_frame_is_smooth_across_its_seam() { + let (r, tol) = (50.0, 0.05); + let mut bp = BezPath::new(); + bp.move_to((r, 0.0)); + bp.curve_to((r, r * K), (r * K, r), (0.0, r)); + bp.curve_to((-r * K, r), (-r, r * K), (-r, 0.0)); + bp.curve_to((-r, -r * K), (-r * K, -r), (0.0, -r)); + bp.curve_to((r * K, -r), (r, -r * K), (r, 0.0)); + bp.close_path(); + let t = &tracks(&bp, tol)[0]; + assert!(t.closed && t.curved.iter().all(|c| *c), "every chord of the circle is a chord of a curve"); + for k in -20..=20 { + let (p, tg) = t.frame(k as f64 * 0.37); + let radial = p.to_vec2() / p.to_vec2().hypot(); + assert!((p.to_vec2().hypot() - r).abs() <= tol, "at {k} the frame is off the circle: {p:?}"); + assert!(tg.dot(radial).abs() < 0.5f64.to_radians().sin(), "at {k} the tangent leans off the circle: {tg:?}"); + } + } + + /// A closed path whose last side is the implicit line back to the start keeps that line + /// straight. + #[test] + fn an_implicit_closing_line_is_not_a_curve() { + let r = 50.0; + let mut bp = BezPath::new(); + bp.move_to((r, 0.0)); + bp.curve_to((r, r * K), (r * K, r), (0.0, r)); + bp.curve_to((-r * K, r), (-r, r * K), (-r, 0.0)); + bp.close_path(); + let t = &tracks(&bp, 0.05)[0]; + assert!(t.closed); + assert_eq!(t.curved.last(), Some(&false), "the closing line is a line"); + let n = t.segments(); + for u in [0.1, 0.5, 0.9] { + let (p, _) = t.frame(t.cum[n - 1] + (t.cum[n] - t.cum[n - 1]) * u); + assert!(p.y.abs() < 1e-9, "the frame leaves the closing line: {p:?}"); + } + } + #[test] fn closed_square_has_four_corners() { let mut bp = BezPath::new();