Merge pull request #7321 from dotCooCoo/fix/brush-wobble

Brushes: art along a smooth curve keeps smooth edges
This commit is contained in:
BFlat
2026-11-14 07:33:17 +07:00
committed by GitHub
3 changed files with 400 additions and 36 deletions
+5 -1
View File
@@ -78,7 +78,11 @@ fn samples(t: &Track, step: Option<f64>) -> 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));
}
}
}
+49
View File
@@ -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) };
+346 -35
View File
@@ -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<Vec2>,
/// Is the vertex a corner (turn > [`CORNER_DEG`])? Open ends are never corners.
pub corner: Vec<bool>,
/// Does the segment come from flattening a curve (rather than from a line of the path)?
pub curved: Vec<bool>,
/// 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<f64>,
}
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<Point>, closed: bool) -> Option<Track> {
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<f64>)>, closed: bool) -> Option<Track> {
let mut pts: Vec<Point> = 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<Option<f64>> = 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<Option<f64>> = (0..nseg).map(|i| ends_curve.get(i + 1).copied().flatten()).collect();
let curved: Vec<bool> = curve_tol.iter().map(Option::is_some).collect();
let dirs: Vec<Vec2> = (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<f64> = (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<Vec2> = 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<Track> {
let tol = tol.max(1e-4);
let mut out = vec![];
let mut cur: Vec<Point> = vec![];
let flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Track>| {
let mut cur: Vec<(Point, Option<f64>)> = vec![];
let flush = |cur: &mut Vec<(Point, Option<f64>)>, closed: bool, out: &mut Vec<Track>| {
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<Track> {
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();