diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 884dd27cf..28bbcda75 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -27,10 +27,16 @@ fn near(a: f64, b: f64, tol: f64) -> bool { (a - b).abs() <= tol } +/// Points along every outline under `n`, its anchors among them. Bent art keeps its curves and +/// long lines since #3926, so its anchors alone no longer trace it. fn all_points(n: &Node, out: &mut Vec) { n.walk(&mut |c| { - if let Some(p) = c.path_data() { - out.extend(p.anchors().map(|(_, _, a)| a.p)); + for sp in c.path_data().into_iter().flat_map(|p| &p.subpaths) { + out.extend(sp.anchors.iter().map(|a| a.p)); + for i in 0..sp.segment_count() { + let seg = sp.segment(i); + out.extend((1..32).map(|j| kurbo::ParamCurve::eval(&seg, j as f64 / 32.0))); + } } }); } @@ -185,9 +191,12 @@ fn art_brush_follows_curves() { } } let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); + // The long edges bend as curves (#3926), where they used to be cut into some 400 chords. The + // path is run along in 0.05 pt chords, which the curves follow, so a few dozen remain. + let (anchors, curved) = anchors_and_curved(&out[0]); + assert!(anchors < 100 && curved > anchors / 2, "long edges bend as curves: {curved} of {anchors} anchors with handles"); let mut pts = vec![]; all_points(&out[0], &mut pts); - assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len()); for p in pts { let r = p.distance(Point::new(100.0, 100.0)); assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre"); @@ -1226,11 +1235,12 @@ fn a_pattern_tile_keeps_its_curves_on_a_straight_path() { assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height()); } -/// The other half of the same behaviour: where the path really does bend, the tile is still -/// subdivided, because an affine cannot reproduce a bend. The fit is checked before it is used, -/// so a bent run falls back to exactly what it did before. +/// The other half of the same behaviour: where the path really does bend, an affine cannot +/// reproduce the bend, so the tile is bent, and it still comes out as curves: cubics fitted to the +/// bent art rather than the chords it used to be flattened into (#3926). Here a 4-anchor circle +/// tile runs round a circle of radius 200, so its outline stays in the band 20 either side of it. #[test] -fn a_pattern_tile_on_a_bent_path_is_still_subdivided() { +fn a_pattern_tile_on_a_bent_path_keeps_its_curves() { let circle = Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); let brush = Brush { @@ -1240,9 +1250,23 @@ fn a_pattern_tile_on_a_bent_path_is_still_subdivided() { // A circle to run along: every tile sits on a curving section. let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0)); - assert!(!out.is_empty()); - let bent = out.iter().filter(|n| anchors_and_curved(n).0 > 8).count(); - assert!(bent > out.len() / 2, "tiles on a bend are still densified: {bent} of {}", out.len()); + assert!(out.len() > 20, "tiles all the way round: {}", out.len()); + let (mut lo, mut hi) = (f64::MAX, f64::MIN); + for n in &out { + let (anchors, curved) = anchors_and_curved(n); + assert!((4..=16).contains(&anchors), "a bent circle tile is a few curves, not a polygon: {anchors} anchors"); + assert!(curved == anchors, "every anchor keeps a handle: {curved} of {anchors}"); + let mut pts = vec![]; + all_points(n, &mut pts); + for q in pts { + let r = q.distance(Point::new(200.0, 200.0)); + (lo, hi) = (lo.min(r), hi.max(r)); + } + } + // The path is a cubic circle (0.06 out at most) run along in chords of 0.05, and the fit holds + // a quarter of the 0.25 flattening tolerance, so the band holds to 0.2. + assert!(lo > 179.8 && hi < 220.2, "the curves stay in the tile's band round the path: {lo:.3}..{hi:.3}"); + assert!(lo < 180.2 && hi > 219.8, "and reach both edges of it: {lo:.3}..{hi:.3}"); } /// The kind of every node in a piece of brushed art, in paint order. diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 95632ad49..99f7e6304 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -1,13 +1,15 @@ //! Art, scatter and pattern brushes: art bent along the path by arc length. //! //! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by -//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and -//! subdivided first so long straight edges bend with the path instead of cutting chords. +//! `(y - centre) × cross` along the path's left normal. Art outlines come out as cubics fitted +//! to the bent art, so curves stay curves and long straight edges bend with the path (#3926); +//! where that would take more anchors than chords, they are flattened and subdivided instead. use std::sync::Arc; +use kurbo::ParamCurveNearest; use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile}; -use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath, Vec2}; +use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; @@ -34,22 +36,26 @@ const MAX_WALK: usize = 10_000; /// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with. const MASK_STEPS: usize = 32; -/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and -/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by -/// `width_scale`. Text, images and symbol instances can't bend and are dropped. -pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option { +/// Bend every path under `n` through `f`. Each art segment comes out as cubics that follow it +/// through the bend to within [`fit_tol`] of `doc_tol`, the flattening tolerance in document +/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than +/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord +/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`. +/// Text, images and symbol instances can't bend and are dropped. +pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option { let mut out = n.clone(); out.appearance.scale_strokes(width_scale); match &mut out.kind { NodeKind::Path { path, live, .. } => { *live = None; - *path = warp_path(path, f, flat_tol, max_du); + *path = warp_path(path, f, flat_tol, max_du, fit_tol(doc_tol)); if path.is_empty() { return None; } } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => { - let kids: Vec> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect(); + let kids: Vec> = + children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, doc_tol, width_scale)).map(Arc::new).collect(); if kids.is_empty() { return None; } @@ -60,54 +66,156 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max Some(out) } -fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData { - let bp = p.to_bezpath(); +/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords +/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords. +fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData { let mut subs = vec![]; - let mut cur: Vec = vec![]; - let mut els = vec![]; - kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e)); - let finish = |cur: &mut Vec, closed: bool, subs: &mut Vec| { - let pts = std::mem::take(cur); - if pts.len() < 2 { - return; + for sp in &p.subpaths { + let Some(dense) = densified(sp, flat_tol, max_du) else { continue }; + if let Some(fit) = fitted(sp, f, fit_tol, dense.len()) { + subs.push(fit); + continue; } - let mut dense = vec![pts[0]]; - let n = pts.len(); - let segs = if closed { n } else { n - 1 }; - for i in 0..segs { - let (a, b) = (pts[i], pts[(i + 1) % n]); - let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB); - for j in 1..=k { - let q = a.lerp(b, j as f64 / k as f64); - if closed && i == segs - 1 && j == k { - break; - } - dense.push(q); - } - } - let mapped: Vec = dense.into_iter().map(f).collect(); - subs.push(SubPath::polyline(&mapped, closed)); - }; - for e in els { - match e { - PathEl::MoveTo(q) => { - finish(&mut cur, false, &mut subs); - cur.push(q); - } - PathEl::LineTo(q) => cur.push(q), - PathEl::ClosePath => { - if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 { - cur.pop(); - } - finish(&mut cur, true, &mut subs); - } - _ => {} + let mapped: Vec = dense.into_iter().map(f).filter(|q| q.is_finite()).collect(); + if mapped.len() >= 2 { + subs.push(SubPath::polyline(&mapped, sp.closed)); } } - finish(&mut cur, false, &mut subs); PathData::new(subs) } +/// `sp` flattened with `flat_tol` and its chords subdivided so none is longer than `max_du` along +/// x, in art space: the points the chord route maps. `None` below two points. +fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option> { + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut pts = vec![]; + kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| { + if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e { + pts.push(q); + } + }); + if sp.closed && pts.len() > 1 && pts.first()?.distance(*pts.last()?) < 1e-9 { + pts.pop(); + } + if pts.len() < 2 { + return None; + } + let segs = if sp.closed { pts.len() } else { pts.len() - 1 }; + let mut dense = vec![*pts.first()?]; + for (i, (&a, &b)) in pts.iter().zip(pts.iter().cycle().skip(1)).take(segs).enumerate() { + let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB); + for j in 1..=k { + if sp.closed && i == segs - 1 && j == k { + break; + } + dense.push(a.lerp(b, j as f64 / k as f64)); + } + } + Some(dense) +} + +/// Times an art segment is halved at most while looking for cubics that fit it. +const FIT_DEPTH: u32 = 12; +/// Parameter step of the finite differences that stand in for the bend's derivative. +const FIT_DT: f64 = 1e-6; +/// Sections a fitted piece is checked at the ends of (the ends themselves are exact). +const FIT_CHECKS: usize = 8; +/// Share of the flattening tolerance the fitted curves hold to. The chords were exact at their +/// points and a few hundredths off between them, so a straight edge bends about as closely. +const FIT_SHARE: f64 = 0.25; +/// Tolerance paths are flattened to before art is laid along them. +const TRACK_TOL: f64 = 0.05; + +/// The distance (document space) fitted curves keep to the bent art, for flattening tolerance +/// `doc_tol`: never below [`TRACK_TOL`], since following the flattened path more closely than it +/// follows the real one only traces its chords. +fn fit_tol(doc_tol: f64) -> f64 { + (doc_tol * FIT_SHARE).max(TRACK_TOL) +} + +/// `sp` through `f` as cubics within `tol` (document space), one or more per art segment so its +/// anchors, corners included, stay anchors. `None` when that takes more than `most` anchors, when +/// a piece still does not fit after [`FIT_DEPTH`] halvings, or when `f` cannot place a point. +/// +/// Each piece is the cubic with the bent piece's ends and their derivatives, which is the piece +/// itself where the bend is affine and converges fast where it is not. It is checked against the +/// bend at the same parameters, so the distance measured is never less than the true one. The +/// halving is our own rather than kurbo's `fit_to_bezpath`, which halves until the floats run out +/// on a map that never fits; the depth and the anchor count bound this one. +fn fitted(sp: &SubPath, f: &dyn Fn(Point) -> Point, tol: f64, most: usize) -> Option { + if !(tol.is_finite() && tol > 0.0) { + return None; + } + let start = f(sp.anchors.first()?.p); + if !start.is_finite() { + return None; + } + let mut bp = BezPath::new(); + bp.move_to(start); + // Pieces allowed: an open subpath's first anchor is one more, a closed one's last piece ends on it. + let mut room = if sp.closed { most } else { most.checked_sub(1)? }; + for i in 0..sp.segment_count() { + let c = sp.segment(i); + let line = sp.segment_is_line(i); + // A line's handles sit on its ends, which stalls its parameter there; thirds keep it even. + let c = if line { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }; + let at = |t: f64| Some(f(c.eval(t))).filter(|q| q.is_finite()); + fit_into(&at, (0.0, 1.0), line, tol, FIT_DEPTH, &mut bp, &mut room)?; + } + if sp.closed { + bp.close_path(); + } + // The last piece ends on the first anchor's image, so a closed subpath merges it back in; the + // count is checked again for a map that jumps right there and keeps the two apart. + PathData::from_bezpath(&bp).subpaths.into_iter().next().filter(|s| s.anchors.len() <= most) +} + +/// Pieces for parameters `t0..t1` of the bent segment `at` onto `bp`, which ends at `at(t0)`: +/// halved until each is within `tol`, at most `depth` times and `room` pieces. A `line` that is +/// still straight within `tol` stays a line. +fn fit_into( + at: &dyn Fn(f64) -> Option, + (t0, t1): (f64, f64), + line: bool, + tol: f64, + depth: u32, + bp: &mut BezPath, + room: &mut usize, +) -> Option<()> { + let span = t1 - t0; + let (p0, p3) = (at(t0)?, at(t1)?); + // Derivatives read from inside the piece, so a kink at either end (a corner, a turn of the + // track) stays one. + let d0 = (at(t0 + FIT_DT)? - p0) * (span / FIT_DT); + let d1 = (p3 - at(t1 - FIT_DT)?) * (span / FIT_DT); + let c = CubicBez::new(p0, p0 + d0 / 3.0, p3 - d1 / 3.0, p3); + let chord = kurbo::Line::new(p0, p3); + let (mut curve_fits, mut line_fits) = (c.is_finite(), line); + for k in 1..FIT_CHECKS { + if !(curve_fits || line_fits) { + break; + } + let u = k as f64 / FIT_CHECKS as f64; + let q = at(t0 + span * u)?; + curve_fits &= q.distance(c.eval(u)) <= tol; + line_fits &= chord.nearest(q, 1e-9).distance_sq <= tol * tol; + } + if line_fits || curve_fits { + *room = room.checked_sub(1)?; + if line_fits { + bp.line_to(p3); + } else { + bp.curve_to(c.p1, c.p2, p3); + } + return Some(()); + } + let depth = depth.checked_sub(1)?; + let mid = t0 + span / 2.0; + fit_into(at, (t0, mid), line, tol, depth, bp, room)?; + fit_into(at, (mid, t1), line, tol, depth, bp, room) +} + /// Art turned so it runs left → right, flipped as requested; returns it with its bounds. fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> { let mut a = art.clone(); @@ -281,7 +389,7 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option 1e-9 { len / tw } else { 1.0 }; let max_du = MAX_SEG / (2.0 * k * s.max(1.0)); - let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, k); + let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, flat_tol * k, k); // Art point → (distance along from the left edge, stretched to `len`, and offset across). let along = move |q: Point| { let (x, y) = local(q); @@ -686,7 +794,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source)); let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source)); let mut out = Budget::default(); - for t in tracks(bp, 0.05) { + for t in tracks(bp, TRACK_TOL) { if out.full() { break; } @@ -921,4 +1029,108 @@ mod tests { let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something"); assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves"); } + + /// A 4-anchor circle tile filling `b`. + fn circle_tile(b: Rect) -> Node { + Node::path( + NodeId(0), + vectorcraft_geom::shapes::ellipse(b), + Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ) + } + + /// `per` points along each segment of `sp`, through `f`. + fn traced(sp: &SubPath, f: &dyn Fn(Point) -> Point, per: usize) -> Vec { + (0..sp.segment_count()) + .flat_map(|i| { + let c = sp.segment(i); + (0..per).map(move |j| f(c.eval(j as f64 / per as f64))) + }) + .collect() + } + + /// How far apart two closed outlines get (Hausdorff distance, each taken as a polygon). + fn apart(a: &[Point], b: &[Point]) -> f64 { + let to = |q: Point, pts: &[Point]| { + pts.iter() + .zip(pts.iter().cycle().skip(1)) + .map(|(&a, &b)| { + let ab = b - a; + let t = ((q - a).dot(ab) / ab.hypot2().max(1e-18)).clamp(0.0, 1.0); + q.distance(a + ab * t) + }) + .fold(f64::MAX, f64::min) + }; + let one_way = |a: &[Point], b: &[Point]| a.iter().map(|&q| to(q, b)).fold(0.0, f64::max); + one_way(a, b).max(one_way(b, a)) + } + + /// #3926: on a bend the tile is fitted with cubics instead of flattened into chords. A circle + /// tile bent round a circle stays a handful of smooth curves, closer to the bent art than the + /// chords were, in a small fraction of their anchors. + #[test] + fn a_circle_tile_bent_round_a_circle_stays_a_few_curves() { + let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let t = tracks(&round, 0.05).remove(0); + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let along = AlongMap::linear(b.width(), 100.0, 140.0); + let tile = circle_tile(b); + let NodeKind::Path { path: art, .. } = &tile.kind else { panic!("the tile is a path") }; + let art = &art.subpaths[0]; + + let bent = bend(&t, &tile, b, along, 1.0).expect("the tile is drawn"); + let NodeKind::Path { path, .. } = &bent.kind else { panic!("a bent path is a path") }; + assert_eq!(path.subpaths.len(), 1); + let sp = &path.subpaths[0]; + assert!(sp.closed, "still a closed outline"); + let n = sp.anchors.len(); + assert!((4..=16).contains(&n), "a few anchors, the tile's own four among them: {n}"); + assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every anchor keeps both handles"); + assert!(sp.anchors.first().map(|a| a.p) != sp.anchors.last().map(|a| a.p), "closed without a duplicate anchor"); + + // The same map `bend` uses, at scale 1, and the tolerance it fits to. + let (cy, x0) = (b.center().y, b.x0); + let f = |p: Point| t.map(along.at(p.x - x0), p.y - cy); + let tol = tolerance(b.height().max(b.width())); + let exact = traced(art, &f, 1000); + let chords: Vec = densified(art, tol, MAX_SEG).expect("the tile flattens").into_iter().map(f).collect(); + let curves = traced(sp, &|p| p, 200); + let (e_curves, e_chords) = (apart(&curves, &exact), apart(&chords, &exact)); + // Checked at eight points a piece, so a little room between them. + assert!(e_curves <= fit_tol(tol) * 1.5, "the curves follow the bent art within {}: {e_curves:.4}", fit_tol(tol)); + assert!(e_curves < e_chords, "closer than the chords were: {e_curves:.4} against {e_chords:.4}"); + assert!(chords.len() >= 4 * n, "far fewer anchors than the {} chords took: {n}", chords.len()); + } + + /// Fitting is bounded however badly the map behaves: where it gives points that are not finite + /// the tile falls back to chords with those points left out (or to nothing), and where it never + /// fits (a map that jumps everywhere) the halving stops at its depth and anchor count. Either + /// way the call comes back promptly with finite geometry. + #[test] + fn a_map_that_misbehaves_still_returns_promptly_and_finite() { + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let tile = circle_tile(b); + let finite = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => { + path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) + } + _ => false, + }; + let maps: [&dyn Fn(Point) -> Point; 4] = [ + &|p| if p.x > 25.0 { Point::new(f64::NAN, p.y) } else { p }, + &|p| if p.y > 10.0 { Point::new(f64::INFINITY, p.y) } else { p + Vec2::new(p.x.sin(), 0.0) }, + &|p| Point::new(p.x, p.y + if (p.x * 1e3).sin() > 0.0 { 5.0 } else { 0.0 }), + &|_| Point::new(f64::NAN, f64::NAN), + ]; + for (i, f) in maps.iter().enumerate() { + let t0 = std::time::Instant::now(); + let out = warp_node(&tile, f, 0.25, 1.5, 0.25, 1.0); + assert!(t0.elapsed() < std::time::Duration::from_secs(2), "map {i} took {:?}", t0.elapsed()); + match (i, &out) { + (3, out) => assert!(out.is_none(), "a map that places nothing draws nothing"), + (_, Some(n)) => assert!(finite(n), "map {i} drew non-finite geometry"), + (_, None) => panic!("map {i} places most points, so something is drawn"), + } + } + } }