Brushes: art along a smooth curve keeps smooth edges

Art and pattern brushes bent their art along the chords of the flattened
path, with the tangent blended linearly between vertices, and scatter
brushes placed and turned their copies on the same chords. On a smooth
curve the bent art's edges followed the chords, and the curve fitted to
the result turned by up to a few degrees at its smooth anchors.

- On a chord that comes from flattening a curve, the frame now follows
  the cubic through both vertices with their tangents, so position and
  tangent meet at every vertex. A vertex tangent between two such chords
  is the tangent of the circle through the vertex and its neighbors, and
  it stays within a few times the flattening tolerance of each chord, so
  the frame stays near the curve next to a long chord. A curved chord
  whose end tangents turn further than that keeps to the chord.
- Art on a line of the path stays on the line, and a line keeps its own
  direction where it meets a curve.
- A calligraphic nib that follows the pen samples between vertices on
  the same frame, so its outline keeps its distance from the curve.
This commit is contained in:
dotCooCoo
2026-11-13 14:58:15 -08:00
parent 4558196e16
commit 90be8fba18
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();