diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs index 50317496d..065e5eec9 100644 --- a/crates/engine/src/brush_tests.rs +++ b/crates/engine/src/brush_tests.rs @@ -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() @@ -155,10 +156,12 @@ fn the_pencil_draws_white_on_a_dark_background() { /// 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 mut s = Session::new(); - s.execute("tool.set", &json!({"tool": "pencil"})).unwrap(); 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 = [ @@ -168,30 +171,47 @@ fn the_pencil_keeps_its_stroke_where_it_was_drawn() { (214.246_506_828_720_84, -211.228_827_590_623_24), (218.0, -214.0), ]; - for (what, samples) in [("out and back", &back[..]), ("nearly straight", &straight[..])] { - 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(); + 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 = 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" + ); + } } - s.pointer(&PointerEvent::at(PointerKind::Up, last.0, last.1, 1.0)).unwrap(); - let doc = s.doc().unwrap(); - let n = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap(); - let NodeKind::Vector { network } = &n.kind else { panic!("{what}: not a vector") }; - let drawn = doc.world_transform(n.id) * network.stroke_path(); - let samples_box = figcraft_geom::points_bounds(samples.iter().map(|(x, y)| Point::new(*x, *y))).unwrap(); - let (path_box, node_box) = (figcraft_geom::Shape::bounding_box(&drawn), doc.world_bounds(n.id).unwrap()); - assert!( - samples_box.inflate(1.5, 1.5).contains_rect(path_box), - "{what}: the stroke {path_box:?} goes past the samples {samples_box:?}: {}", - drawn.to_svg() - ); - 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. - let doc = s.doc().unwrap(); - let turned = doc.nodes().find(|n| n.name == "Vector" && doc.world_bounds(n.id).is_some_and(|b| b.y1 < -150.0 && b.x1 < 100.0)).unwrap(); - let NodeKind::Vector { network } = &turned.kind else { panic!("not a vector") }; - let xf = doc.world_transform(turned.id); - assert!(network.vertices.iter().any(|v| (xf * Point::new(v.x, v.y) - Point::new(q.0, q.1)).hypot() < 1e-6), "no point at the turn"); } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 4666c3028..d1d6907ee 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -326,25 +326,56 @@ fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) { doc.get_mut(id).unwrap().kind = NodeKind::Vector { network }; } -/// Simplify path keeps nearly collinear points 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). +/// 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() { - let mut s = Session::new(); - let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L10 10"}))["id"]).unwrap(); - // The points exactly as sampled, at the page origin (`vector.create` would move them). - let d = "M209.5986907940155 -204.90932811973988 L211.27791426387097 -207.1909740207534 L214.24650682872084 -211.22882759062324"; - let before = BezPath::from_svg(d).unwrap(); - { - let n = s.doc_mut().unwrap().get_mut(id).unwrap(); - n.transform = figcraft_geom::Affine::IDENTITY; - n.kind = NodeKind::Vector { network: VectorNetwork::from_path(&before) }; + // 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"); } - assert_eq!(net(&s, id).stroke_path().elements(), before.elements(), "the samples as they were"); - 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 (b0, b1) = (before.bounding_box(), after.bounding_box()); - assert!(b0.inflate(1.5, 1.5).contains_rect(b1), "{b1:?} past {b0:?}: {}", after.to_svg()); } fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 10e09318c..786bf1935 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -393,7 +393,6 @@ fn deviation(out: &BezPath, input: &BezPath) -> f64 { /// 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() { - use kurbo::ParamCurve; let cases: [(&str, &[(f64, f64)]); 5] = [ ( "out and back to the same point", @@ -422,23 +421,127 @@ fn simplifying_keeps_to_strokes_kurbo_shot_past() { ), ]; for (what, pts) in cases { - let input = polyline(pts); - let out = simplify_path(&input, 0.75); - let d = deviation(&out, &input); - assert!(d <= 1.5, "{what}: strays {d} from the samples: {}", out.to_svg()); + 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(1.5, 1.5).contains_rect(outb), "{what}: {outb:?} past {inb:?}"); - // One subpath, starting and ending where the stroke does. + 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}: {}", out.to_svg()); - let (first, last) = (pts[0], pts[pts.len() - 1]); - assert_eq!(out.segments().next().map(|s| s.start()), Some(Point::new(first.0, first.1)), "{what}"); - assert_eq!(out.segments().last().map(|s| s.end()), Some(Point::new(last.0, last.1)), "{what}"); + 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(); @@ -452,12 +555,30 @@ fn simplifying_is_kurbos_where_its_fit_holds() { 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(); - for (what, input) in - [("a wave", polyline(&wave)), ("a zigzag", polyline(&zigzag)), ("closed", closed), ("two subpaths", several), ("curves", curves)] - { - let ours = simplify_path(&input, 0.75); - let theirs = kurbo::simplify::simplify_bezpath(input.iter(), 0.75, &Default::default()); - assert_eq!(ours.elements(), theirs.elements(), "{what}"); + // 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}"); + } } } @@ -482,4 +603,40 @@ fn simplifying_keeps_closes_and_subpaths_across_a_reversal() { 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 { 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()); }