mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge pull request #2140 from ljkx/fix/pencil-stroke-spikes
M4: a Pencil stroke stays where it was drawn, without spikes off it (#1741)
This commit is contained in:
7 files changed
+541
-6
No files matched your search
@@ -8,6 +8,7 @@ use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind, pressure_profile};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
@@ -149,3 +150,68 @@ fn the_pencil_draws_white_on_a_dark_background() {
|
||||
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Pencil stroke stays where it was drawn: the fitted curve shot far off the stroke, a spike
|
||||
/// (#1741, its second point), where the stroke went out and back to the very point it left and
|
||||
/// where samples ran on nearly straight. Pointer pixels become fractional page points, so going
|
||||
/// back to a pixel lands on exactly the same point. Both sample runs are ones kurbo's fit took
|
||||
/// hundreds of units off.
|
||||
///
|
||||
/// Each stroke is drawn in a fresh session, with the Pencil and with the Marker and Highlighter
|
||||
/// of a board, whose ink comes from the same fit. Every point along the stroke drawn must lie
|
||||
/// within 1.5 (twice the fit's accuracy) of the polyline through the samples.
|
||||
#[test]
|
||||
fn the_pencil_keeps_its_stroke_where_it_was_drawn() {
|
||||
let (p, q) = ((86.043_565_719_086_97, -173.744_402_349_100_94), (87.851_850_739_335_35, -179.202_846_445_954_52));
|
||||
let back = [(80.0, -160.0), p, q, p, (95.0, -185.0)];
|
||||
let straight = [
|
||||
(204.0, -198.0),
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
(218.0, -214.0),
|
||||
];
|
||||
for tool in ["pencil", "marker", "highlighter"] {
|
||||
for (what, samples) in [("out and back", &back[..]), ("nearly straight", &straight[..])] {
|
||||
let what = format!("{tool}, {what}");
|
||||
let mut s = if tool == "pencil" { Session::new() } else { crate::board_tests::board() };
|
||||
s.execute("tool.set", &json!({"tool": tool})).unwrap();
|
||||
let before: Vec<NodeId> = s.doc().unwrap().nodes().map(|n| n.id).collect();
|
||||
let (first, last) = (samples[0], samples[samples.len() - 1]);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, first.0, first.1, 1.0)).unwrap();
|
||||
for (x, y) in &samples[1..] {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, *x, *y, 1.0)).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, last.0, last.1, 1.0)).unwrap();
|
||||
// The layer the stroke made, and only it.
|
||||
let doc = s.doc().unwrap();
|
||||
let made: Vec<&figcraft_doc::Node> =
|
||||
doc.nodes().filter(|n| !before.contains(&n.id) && matches!(n.kind, NodeKind::Vector { .. })).collect();
|
||||
let [n] = made[..] else { panic!("{what}: {} vectors made", made.len()) };
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("{what}: not a vector") };
|
||||
let drawn = doc.world_transform(n.id) * network.stroke_path();
|
||||
let polyline = figcraft_geom::BezPath::from_vec(
|
||||
samples
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, (x, y))| if k == 0 { PathEl::MoveTo(Point::new(*x, *y)) } else { PathEl::LineTo(Point::new(*x, *y)) })
|
||||
.collect(),
|
||||
);
|
||||
let d = crate::vector_behaviour_tests::strays(&drawn, &polyline);
|
||||
assert!(d <= 1.5, "{what}: the stroke strays {d} from the samples: {}", drawn.to_svg());
|
||||
let samples_box = figcraft_geom::points_bounds(samples.iter().map(|(x, y)| Point::new(*x, *y))).unwrap();
|
||||
let node_box = doc.world_bounds(n.id).unwrap();
|
||||
if tool == "pencil" {
|
||||
assert!(samples_box.inflate(1.5, 1.5).contains_rect(node_box), "{what}: the layer {node_box:?}, the samples {samples_box:?}");
|
||||
}
|
||||
// The turn is a point of the stroke, where it was drawn.
|
||||
if samples == back {
|
||||
let xf = doc.world_transform(n.id);
|
||||
assert!(
|
||||
network.vertices.iter().any(|v| (xf * Point::new(v.x, v.y) - Point::new(q.0, q.1)).hypot() < 1e-6),
|
||||
"{what}: no point at the turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,8 @@ impl Session {
|
||||
if points.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
// Smooth: fit a curve through the samples.
|
||||
// Smooth: fit a curve through the samples, kept from shooting past them where the stroke
|
||||
// turns back or runs nearly straight (#1741).
|
||||
let mut poly = figcraft_geom::BezPath::new();
|
||||
for (k, p) in points.iter().enumerate() {
|
||||
if k == 0 {
|
||||
@@ -322,7 +323,7 @@ impl Session {
|
||||
poly.line_to(*p);
|
||||
}
|
||||
}
|
||||
let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default());
|
||||
let fitted = figcraft_geom::simplify_path(&poly, 0.75);
|
||||
let d = fitted.to_svg();
|
||||
if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) {
|
||||
return self.ink_finish(&d);
|
||||
|
||||
@@ -799,7 +799,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = drawn.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_geom::simplify_path(&path, tol);
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
Err(_) => (path, winding),
|
||||
@@ -809,7 +809,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = drawn.region_path(index) else { continue };
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_geom::simplify_path(&path, tol);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
}
|
||||
@@ -826,7 +826,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let simplified = figcraft_geom::simplify_path(&path, tol);
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
@@ -834,7 +834,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let simplified = figcraft_geom::simplify_path(&path, tol);
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
|
||||
@@ -326,6 +326,58 @@ fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
|
||||
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
}
|
||||
|
||||
/// How far `path` strays from `from`: the largest distance from a point sampled along `path` to
|
||||
/// the nearest segment of `from`.
|
||||
pub(crate) fn strays(path: &BezPath, from: &BezPath) -> f64 {
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let segs: Vec<_> = from.segments().collect();
|
||||
path.segments()
|
||||
.flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
|
||||
.map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Simplify path keeps nearly collinear points, and a stroke that goes out and back, near where
|
||||
/// they were: kurbo's fit, which Simplify path and the Pencil share, ran a handle hundreds of units
|
||||
/// on along the line (#1741). That holds for each way the command simplifies a layer: an open
|
||||
/// stroke, a filled shape, a region with its own fill, and a stroke beside a filled shape.
|
||||
#[test]
|
||||
fn simplify_keeps_nearly_collinear_points_near_them() {
|
||||
// The points exactly as sampled.
|
||||
let straight = "M209.5986907940155 -204.90932811973988 L211.27791426387097 -207.1909740207534 L214.24650682872084 -211.22882759062324";
|
||||
let back =
|
||||
"M80 -160 L86.04356571908697 -173.74440234910094 L87.85185073933535 -179.20284644595452 L86.04356571908697 -173.74440234910094 L95 -185";
|
||||
let shape = format!("{straight} L260 -150 Z");
|
||||
let cases = [
|
||||
("an open stroke", straight.to_string(), false),
|
||||
("a filled shape", shape.clone(), false),
|
||||
("a region with its own fill", shape.clone(), true),
|
||||
("a stroke beside a filled shape", format!("{shape} {back}"), false),
|
||||
];
|
||||
for (what, d, own_fill) in cases {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L10 10"}))["id"]).unwrap();
|
||||
// At the page origin, as given (`vector.create` would move the points).
|
||||
let mut network = VectorNetwork::from_path(&BezPath::from_svg(&d).unwrap());
|
||||
if own_fill {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("#FF0000").unwrap())]);
|
||||
}
|
||||
{
|
||||
let n = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
n.transform = figcraft_geom::Affine::IDENTITY;
|
||||
n.kind = NodeKind::Vector { network };
|
||||
}
|
||||
let before = net(&s, id);
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id], "tolerance": 0.75}));
|
||||
let after = s.doc().unwrap().world_transform(id) * net(&s, id).stroke_path();
|
||||
let gone = strays(&after, &before.stroke_path());
|
||||
assert!(gone <= 1.5, "{what}: strays {gone}: {}", after.to_svg());
|
||||
let (b0, b1) = (before.stroke_path().bounding_box(), after.bounding_box());
|
||||
assert!(b0.inflate(1.5, 1.5).contains_rect(b1), "{what}: {b1:?} past {b0:?}: {}", after.to_svg());
|
||||
assert_eq!(net(&s, id).regions.len(), before.regions.len(), "{what}: the fill is kept");
|
||||
}
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ use kurbo::ParamCurve;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
pub mod measure;
|
||||
mod simplify;
|
||||
mod transform;
|
||||
pub use simplify::simplify_path;
|
||||
pub use transform::*;
|
||||
|
||||
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Path simplification that keeps to its input where kurbo's fit would shoot past it.
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
/// How far, as a multiple of the accuracy asked for, a fitted run may stray from the run it
|
||||
/// replaces before the run is kept as it was. kurbo's own fits stay within about the accuracy; its
|
||||
/// failures stray by tens to hundreds of units, so twice the accuracy tells them apart without
|
||||
/// undoing fits that are merely a little over.
|
||||
const STRAY: f64 = 2.0;
|
||||
|
||||
/// `path` simplified to within `accuracy` by kurbo's [`simplify_bezpath`], kept from straying
|
||||
/// far from `path` where kurbo's fit does.
|
||||
///
|
||||
/// Two inputs made kurbo's fit shoot far past the path it simplified (#1741, a Pencil stroke with
|
||||
/// a spike off it), and both are common in pointer samples:
|
||||
/// - A join where the path turns straight back (samples going down and back up the same line):
|
||||
/// kurbo keeps two segments in one smooth fit when their tangents are parallel whichever way
|
||||
/// they point (`|cross| <= |dot| * angle_thresh`), so it fits a smooth curve through the cusp.
|
||||
/// The path is cut where it turns back by more than a right angle, each run between the cuts is
|
||||
/// simplified on its own, and the runs are joined again, so the turn stays a sharp corner. Any
|
||||
/// other join that isn't straight within kurbo's threshold is a corner to kurbo already, so the
|
||||
/// cut changes nothing there.
|
||||
/// - A run of nearly collinear segments: kurbo measures its error across the path, not along it, so
|
||||
/// a fitted cubic whose handle runs hundreds of units on along the line passes its check. Each
|
||||
/// fitted run is measured against the run it replaces, and one that strays more than
|
||||
/// twice `accuracy` from it is kept as it was instead.
|
||||
///
|
||||
/// Subpaths and closes are kept, and a closing segment stays implicit, as kurbo leaves it.
|
||||
///
|
||||
/// [`simplify_bezpath`]: kurbo::simplify::simplify_bezpath
|
||||
pub fn simplify_path(path: &BezPath, accuracy: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut run = BezPath::new();
|
||||
// Where the current subpath started, where the pen is, and the last segment's end tangent.
|
||||
let mut start = Point::ZERO;
|
||||
let mut at = Point::ZERO;
|
||||
let mut last_tan: Option<Vec2> = None;
|
||||
// Whether the next run that draws anything has to start the subpath with its move.
|
||||
let mut fresh = true;
|
||||
for el in path.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
(start, at, last_tan, fresh) = (p, p, None, true);
|
||||
run.move_to(p);
|
||||
continue;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
if !fresh {
|
||||
out.close_path();
|
||||
}
|
||||
// A segment after a close starts a new subpath at the closed one's start.
|
||||
(at, last_tan, fresh) = (start, None, true);
|
||||
run.move_to(start);
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(at, p)),
|
||||
PathEl::QuadTo(p1, p2) => PathSeg::Quad(kurbo::QuadBez::new(at, p1, p2)),
|
||||
PathEl::CurveTo(p1, p2, p3) => PathSeg::Cubic(kurbo::CubicBez::new(at, p1, p2, p3)),
|
||||
};
|
||||
if run.elements().is_empty() {
|
||||
run.move_to(at);
|
||||
}
|
||||
let Some((t0, t1)) = tangents(seg) else { continue };
|
||||
if last_tan.is_some_and(|last| last.dot(t0) < 0.0) {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
run.move_to(at);
|
||||
}
|
||||
run.push(*el);
|
||||
at = seg.end();
|
||||
last_tan = Some(t1);
|
||||
}
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
out
|
||||
}
|
||||
|
||||
/// Simplify `run` (one move and the segments after it) with kurbo and append it to `out`; the
|
||||
/// run's move is kept only when it starts a subpath (`fresh`), else the run carries on from the
|
||||
/// last point drawn. `run` is left empty.
|
||||
fn flush(out: &mut BezPath, run: &mut BezPath, accuracy: f64, fresh: &mut bool) {
|
||||
if run.elements().len() > 1 {
|
||||
let fitted = kurbo::simplify::simplify_bezpath(run.iter(), accuracy, &Default::default());
|
||||
let fitted = if keeps_to(&fitted, run, STRAY * accuracy) { fitted } else { std::mem::take(run) };
|
||||
for el in fitted.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) if !*fresh => {}
|
||||
_ => {
|
||||
out.push(*el);
|
||||
*fresh = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*run = BezPath::new();
|
||||
}
|
||||
|
||||
/// Whether every point of `fitted` lies within `tol` of `source`: one subpath each, from the same
|
||||
/// start to the same end, `fitted` being kurbo's simplification of `source`.
|
||||
///
|
||||
/// kurbo ends each fitted segment on the source, so the two are walked together: for each fitted
|
||||
/// segment, the source segments from where it starts to the first one its end lies on are the only
|
||||
/// ones its sample points are measured against. The work is linear in the two lengths. A fitted end
|
||||
/// found on no source segment counts as straying.
|
||||
fn keeps_to(fitted: &BezPath, source: &BezPath, tol: f64) -> bool {
|
||||
/// Parts each fitted segment is cut into: the points between them are the ones measured.
|
||||
const SAMPLES: u32 = 16;
|
||||
let src: Vec<PathSeg> = source.segments().collect();
|
||||
// kurbo puts each end on the source by evaluating it: off by rounding at most.
|
||||
let on2 = (tol * 1e-3).powi(2);
|
||||
let (tol2, mut cur) = (tol * tol, 0);
|
||||
for seg in fitted.segments() {
|
||||
let Some(last) = (cur..src.len()).find(|&i| src.get(i).is_some_and(|s| s.nearest(seg.end(), 1e-9).distance_sq <= on2)) else {
|
||||
return false;
|
||||
};
|
||||
let near = src.get(cur..=last).unwrap_or_default();
|
||||
for i in 1..SAMPLES {
|
||||
let p = seg.eval(f64::from(i) / f64::from(SAMPLES));
|
||||
if !near.iter().any(|s| s.nearest(p, 1e-6).distance_sq <= tol2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cur = last;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The directions `seg` leaves its start and reaches its end, skipping control points that sit on
|
||||
/// an end; `None` for a segment of no length, which kurbo skips too.
|
||||
fn tangents(seg: PathSeg) -> Option<(Vec2, Vec2)> {
|
||||
let pts: Vec<Point> = match seg {
|
||||
PathSeg::Line(l) => vec![l.p0, l.p1],
|
||||
PathSeg::Quad(q) => vec![q.p0, q.p1, q.p2],
|
||||
PathSeg::Cubic(c) => vec![c.p0, c.p1, c.p2, c.p3],
|
||||
};
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let t0 = pts.iter().map(|p| *p - first).find(|d| d.hypot2() > 0.0)?;
|
||||
let t1 = pts.iter().rev().map(|p| last - *p).find(|d| d.hypot2() > 0.0)?;
|
||||
Some((t0, t1))
|
||||
}
|
||||
@@ -366,3 +366,277 @@ fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
|
||||
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
|
||||
assert!(number_past("M.5.5e10", 1e3));
|
||||
}
|
||||
|
||||
/// A path through `pts` with straight lines (pencil samples).
|
||||
fn polyline(pts: &[(f64, f64)]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for (k, (x, y)) in pts.iter().enumerate() {
|
||||
if k == 0 { p.move_to((*x, *y)) } else { p.line_to((*x, *y)) }
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// How far `out` strays from `input`: the largest distance from a point sampled along `out` to the
|
||||
/// nearest segment of `input` (closing segments included).
|
||||
fn deviation(out: &BezPath, input: &BezPath) -> f64 {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let segs: Vec<kurbo::PathSeg> = input.segments().collect();
|
||||
out.segments()
|
||||
.flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
|
||||
.map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Pencil strokes kurbo's fit shot far past keep to their samples (#1741): a stroke that goes out
|
||||
/// and back to the very point it left, nearly collinear samples, and strokes that turn back along
|
||||
/// their own line. The first two are from fuzzing pencil-like strokes, where kurbo's curve went
|
||||
/// about 390 and 160 units off; at whole-pixel coordinates the same shapes happened to fit well.
|
||||
#[test]
|
||||
fn simplifying_keeps_to_strokes_kurbo_shot_past() {
|
||||
let cases: [(&str, &[(f64, f64)]); 5] = [
|
||||
(
|
||||
"out and back to the same point",
|
||||
&[
|
||||
(86.043_565_719_086_97, -173.744_402_349_100_94),
|
||||
(87.851_850_739_335_35, -179.202_846_445_954_52),
|
||||
(86.043_565_719_086_97, -173.744_402_349_100_94),
|
||||
],
|
||||
),
|
||||
(
|
||||
"nearly collinear",
|
||||
&[
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
],
|
||||
),
|
||||
(
|
||||
"a dip straight down and back up",
|
||||
&[(0.0, 0.0), (10.0, 0.0), (20.0, 2.0), (30.0, 6.0), (30.0, 12.0), (30.0, 6.0), (40.0, 2.0), (50.0, 0.0)],
|
||||
),
|
||||
("a slip back at the end", &[(0.0, 0.0), (10.0, 3.0), (20.0, 8.0), (30.0, 15.0), (30.0, 25.0), (30.0, 22.0)]),
|
||||
(
|
||||
"a near reversal in a curve",
|
||||
&[(0.0, 0.0), (8.0, 2.0), (16.0, 6.0), (24.0, 12.0), (32.0, 20.0), (24.0, 12.000_01), (16.0, 6.0), (8.0, 2.0)],
|
||||
),
|
||||
];
|
||||
for (what, pts) in cases {
|
||||
keeps_to_one_stroke(what, &polyline(pts));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accuracies the simplification is checked at: fine, the Pencil's, and coarse.
|
||||
const ACCURACIES: [f64; 3] = [0.1, 0.75, 8.0];
|
||||
|
||||
/// Check that `input` (one open subpath), simplified at each of [`ACCURACIES`], strays no more
|
||||
/// than twice the accuracy from it and leaves no part of it further off than that, stays in its
|
||||
/// box grown by as much, and is still one subpath from its start to its end.
|
||||
fn keeps_to_one_stroke(what: &str, input: &BezPath) {
|
||||
for accuracy in ACCURACIES {
|
||||
let out = simplify_path(input, accuracy);
|
||||
let (svg, limit) = (out.to_svg(), 2.0 * accuracy);
|
||||
let d = deviation(&out, input);
|
||||
assert!(d <= limit, "{what} at {accuracy}: strays {d} from the input: {svg}");
|
||||
let left = deviation(input, &out);
|
||||
assert!(left <= limit, "{what} at {accuracy}: leaves the input {left} off: {svg}");
|
||||
let (inb, outb) = (input.bounding_box(), out.bounding_box());
|
||||
assert!(inb.inflate(limit, limit).contains_rect(outb), "{what} at {accuracy}: {outb:?} past {inb:?}");
|
||||
let moves = out.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
|
||||
assert_eq!(moves, 1, "{what} at {accuracy}: {svg}");
|
||||
// From its start to its end: kurbo evaluates a fitted end on the input, so it is off by
|
||||
// rounding at most.
|
||||
let ends = |p: &BezPath| p.segments().next().map(|s| s.start()).zip(p.segments().last().map(|s| s.end()));
|
||||
let ((start, end), (start_in, end_in)) = (ends(&out).unwrap(), ends(input).unwrap());
|
||||
assert!((start - start_in).hypot() < 1e-9 && (end - end_in).hypot() < 1e-9, "{what} at {accuracy}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strokes made of curves keep to them as polylines do (#1741): a quadratic or a cubic that goes
|
||||
/// out and comes straight back, one whose control points sit on its ends (so its direction is
|
||||
/// read from the control point after them), and nearly collinear curves.
|
||||
#[test]
|
||||
fn simplifying_keeps_to_curves_that_turn_back_or_run_straight() {
|
||||
let (p, q, r) = (
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
);
|
||||
// The nearly collinear samples, each line as a cubic with its handles a third of the way along.
|
||||
let third = |a: (f64, f64), b: (f64, f64), t: f64| (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
|
||||
let mut straight_cubics = BezPath::new();
|
||||
straight_cubics.move_to(p);
|
||||
for (a, b) in [(p, q), (q, r)] {
|
||||
straight_cubics.curve_to(third(a, b, 1.0 / 3.0), third(a, b, 2.0 / 3.0), b);
|
||||
}
|
||||
let mut straight_quads = BezPath::new();
|
||||
straight_quads.move_to(p);
|
||||
straight_quads.quad_to(third(p, q, 0.5), q);
|
||||
straight_quads.quad_to(third(q, r, 0.5), r);
|
||||
let cases = [
|
||||
("a quadratic out and back", BezPath::from_svg("M0 0 Q15 10 30 0 Q15 10 0 0 Q-15 10 -30 0").unwrap()),
|
||||
("a cubic out and back", BezPath::from_svg("M0 0 C10 8 20 8 30 0 C20 8 10 8 0 0 C-10 8 -20 8 -30 0").unwrap()),
|
||||
("handles on the ends", BezPath::from_svg("M0 0 C0 0 30 4 30 4 C30 4 0 0 0 0 L-30 -4").unwrap()),
|
||||
("a cubic dip down and back up", BezPath::from_svg("M0 0 L10 0 C15 0 20 5 20 12 C20 5 25 0 30 0 L40 0").unwrap()),
|
||||
("nearly collinear cubics", straight_cubics),
|
||||
("nearly collinear quadratics", straight_quads),
|
||||
];
|
||||
for (what, input) in cases {
|
||||
keeps_to_one_stroke(what, &input);
|
||||
}
|
||||
}
|
||||
|
||||
/// `sides` simplified by kurbo one at a time and joined into one subpath.
|
||||
fn fitted_apart(sides: &[&BezPath], accuracy: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for side in sides {
|
||||
let fit = kurbo::simplify::simplify_bezpath(side.iter(), accuracy, &Default::default());
|
||||
let skip = usize::from(!out.elements().is_empty());
|
||||
out.extend(fit.elements().iter().copied().skip(skip));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `curve` cut into `n` cubics, drawn on from where `path` is: forwards, or back from its end.
|
||||
fn in_pieces(path: &mut BezPath, curve: kurbo::CubicBez, n: u32, back: bool) {
|
||||
use kurbo::ParamCurve;
|
||||
for i in 0..n {
|
||||
let i = if back { n - 1 - i } else { i };
|
||||
let c = curve.subsegment(f64::from(i) / f64::from(n)..f64::from(i + 1) / f64::from(n));
|
||||
if back { path.curve_to(c.p2, c.p1, c.p0) } else { path.curve_to(c.p1, c.p2, c.p3) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a path turns straight back it is cut, so the result is kurbo's fit of each side on its
|
||||
/// own, with a point of the path at the turn. Fitted as one, kurbo rounds the turn off: its curve
|
||||
/// passes the stray check, but falls short of the turn or takes more segments. A cubic whose
|
||||
/// control points sit on its ends turns back as a line does.
|
||||
#[test]
|
||||
fn simplifying_fits_each_side_of_a_turn_back_on_its_own() {
|
||||
use kurbo::ParamCurve;
|
||||
let svg = |d: &str| BezPath::from_svg(d).unwrap();
|
||||
let s = kurbo::CubicBez::new((0.0, 0.0), (40.0, -60.0), (80.0, 60.0), (120.0, 0.0));
|
||||
let (mut s_out, mut s_back) = (BezPath::new(), BezPath::new());
|
||||
s_out.move_to(s.p0);
|
||||
in_pieces(&mut s_out, s, 8, false);
|
||||
s_back.move_to(s.p3);
|
||||
in_pieces(&mut s_back, s, 8, true);
|
||||
let cases = [
|
||||
("lines", svg("M0 0 L10 0 L20 0 L30 0"), svg("M30 0 L20 0 L10 0")),
|
||||
("handles on the ends", svg("M0 0 L10 0 L20 0 C20 0 30 0 30 0"), svg("M30 0 C30 0 20 0 20 0 L10 0")),
|
||||
("a curve in pieces", s_out, s_back),
|
||||
];
|
||||
for (what, out_side, back_side) in cases {
|
||||
let turn = out_side.segments().last().map(|seg| seg.end()).unwrap();
|
||||
let mut input = out_side.clone();
|
||||
input.extend(back_side.elements().iter().copied().skip(1));
|
||||
for accuracy in ACCURACIES {
|
||||
let ours = simplify_path(&input, accuracy);
|
||||
let apart = fitted_apart(&[&out_side, &back_side], accuracy);
|
||||
assert_eq!(ours.elements(), apart.elements(), "{what} at {accuracy}");
|
||||
assert!(ours.segments().any(|seg| (seg.end() - turn).hypot() < 1e-9), "{what} at {accuracy}: {}", ours.to_svg());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a path never turns back and kurbo's fit holds, the result is kurbo's own, element for
|
||||
/// element: smooth runs, corners sharper than a right angle, closed subpaths and several subpaths.
|
||||
/// A fit counts as holding up to twice the accuracy asked for, whatever that is: the large wave's
|
||||
/// is further off at the coarse accuracy than the Pencil's limit of 1.5.
|
||||
#[test]
|
||||
fn simplifying_is_kurbos_where_its_fit_holds() {
|
||||
let wave: Vec<(f64, f64)> = (0..=60).map(|i| (f64::from(i) * 4.0, (f64::from(i) / 6.0).sin() * 30.0)).collect();
|
||||
let zigzag: Vec<(f64, f64)> = (0..=8).map(|i| (f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 40.0 })).collect();
|
||||
let mut closed = polyline(&wave[..20]);
|
||||
closed.close_path();
|
||||
let mut several = polyline(&wave);
|
||||
several.extend(polyline(&zigzag).elements().iter().map(|e| match *e {
|
||||
kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(p + Vec2::new(0.0, 100.0)),
|
||||
kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(p + Vec2::new(0.0, 100.0)),
|
||||
e => e,
|
||||
}));
|
||||
let curves = BezPath::from_svg("M0 0 C10 -20 30 -20 40 0 C50 20 70 20 80 0 Q100 -30 120 0 L140 10 Z M200 0 L240 0").unwrap();
|
||||
// A sine 300 high in 40 smooth cubics.
|
||||
let sine = |x: f64| ((x / 120.0).sin() * 300.0, (x / 120.0).cos() * 2.5);
|
||||
let mut large = BezPath::new();
|
||||
large.move_to((0.0, 0.0));
|
||||
for i in 0..40 {
|
||||
let (x0, x1) = (f64::from(i) * 40.0, f64::from(i + 1) * 40.0);
|
||||
let ((y0, d0), (y1, d1)) = (sine(x0), sine(x1));
|
||||
large.curve_to((x0 + 40.0 / 3.0, y0 + d0 * 40.0 / 3.0), (x1 - 40.0 / 3.0, y1 - d1 * 40.0 / 3.0), (x1, y1));
|
||||
}
|
||||
let coarse = simplify_path(&large, 8.0);
|
||||
assert!(coarse.elements().len() < 10 && (1.5..=8.0).contains(&deviation(&coarse, &large)), "{}", deviation(&coarse, &large));
|
||||
for (what, input) in [
|
||||
("a wave", polyline(&wave)),
|
||||
("a zigzag", polyline(&zigzag)),
|
||||
("closed", closed),
|
||||
("two subpaths", several),
|
||||
("curves", curves),
|
||||
("a large wave", large),
|
||||
] {
|
||||
for accuracy in ACCURACIES {
|
||||
let ours = simplify_path(&input, accuracy);
|
||||
let theirs = kurbo::simplify::simplify_bezpath(input.iter(), accuracy, &Default::default());
|
||||
assert_eq!(ours.elements(), theirs.elements(), "{what} at {accuracy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed path with a reversal in it stays one closed subpath, and each subpath of several keeps
|
||||
/// its own start.
|
||||
#[test]
|
||||
fn simplifying_keeps_closes_and_subpaths_across_a_reversal() {
|
||||
let mut input = polyline(&[(0.0, 0.0), (40.0, 0.0), (40.0, 30.0), (40.0, 20.0), (0.0, 30.0)]);
|
||||
input.close_path();
|
||||
input.extend(polyline(&[(100.0, 0.0), (100.0, 20.0), (100.0, 10.0)]).elements().iter().copied());
|
||||
let out = simplify_path(&input, 0.75);
|
||||
let els = out.elements();
|
||||
let moves: Vec<Point> = els.iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
|
||||
assert_eq!(moves, [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
|
||||
assert_eq!(els.iter().filter(|e| matches!(e, kurbo::PathEl::ClosePath)).count(), 1, "{}", out.to_svg());
|
||||
let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
|
||||
assert!(matches!(els[close + 1], kurbo::PathEl::MoveTo(_)), "the first subpath closes before the second starts");
|
||||
assert!(deviation(&out, &input) <= 1.5, "{}", out.to_svg());
|
||||
// Nothing to draw: nothing out, not a stray move or close.
|
||||
let mut empty = BezPath::new();
|
||||
empty.move_to((5.0, 5.0));
|
||||
empty.line_to((5.0, 5.0));
|
||||
empty.close_path();
|
||||
assert!(simplify_path(&empty, 0.75).elements().is_empty());
|
||||
for d in ["", "M5 5", "M5 5 M6 6", "M5 5 Z"] {
|
||||
let input = if d.is_empty() { BezPath::new() } else { BezPath::from_svg(d).unwrap() };
|
||||
assert!(simplify_path(&input, 0.75).elements().is_empty(), "{d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpaths simplified keep their starts: a move with nothing drawn from it between two
|
||||
/// subpaths leaves no trace, and a segment drawn on from a close starts a subpath where the closed
|
||||
/// one started, as it does in the path given.
|
||||
#[test]
|
||||
fn simplifying_keeps_where_each_subpath_starts() {
|
||||
let starts =
|
||||
|p: &BezPath| -> Vec<Point> { p.elements().iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect() };
|
||||
let between = BezPath::from_svg("M0 0 L10 0 L20 5 M50 50 M100 0 L110 0 L120 5").unwrap();
|
||||
let out = simplify_path(&between, 0.75);
|
||||
assert_eq!(starts(&out), [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
|
||||
assert!(deviation(&out, &between) <= 1.5, "{}", out.to_svg());
|
||||
// After the close, the pen is back at (0, 0): the next segments start there, also across a
|
||||
// reversal.
|
||||
use kurbo::PathEl::{ClosePath, LineTo, MoveTo};
|
||||
let on = BezPath::from_vec(vec![
|
||||
MoveTo(Point::new(0.0, 0.0)),
|
||||
LineTo(Point::new(10.0, 0.0)),
|
||||
LineTo(Point::new(10.0, 10.0)),
|
||||
ClosePath,
|
||||
LineTo(Point::new(20.0, 20.0)),
|
||||
LineTo(Point::new(30.0, 20.0)),
|
||||
LineTo(Point::new(20.0, 20.0)),
|
||||
]);
|
||||
let out = simplify_path(&on, 0.75);
|
||||
let els = out.elements();
|
||||
let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
|
||||
assert_eq!(els.get(close + 1), Some(&kurbo::PathEl::MoveTo(Point::new(0.0, 0.0))), "{}", out.to_svg());
|
||||
assert_eq!(starts(&out), [Point::new(0.0, 0.0), Point::new(0.0, 0.0)], "{}", out.to_svg());
|
||||
assert_eq!(out.segments().last().map(|s| kurbo::ParamCurve::end(&s)), Some(Point::new(20.0, 20.0)));
|
||||
assert!(deviation(&out, &on) <= 1.5, "{}", out.to_svg());
|
||||
}
|
||||
Reference in new issue
Block a user