mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge pull request #7321 from dotCooCoo/fix/brush-wobble
Brushes: art along a smooth curve keeps smooth edges
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user